Material
What a 3D surface looks like: its colour, its image, how shiny it is, which shader draws it. Assign
one to a mesh with mesh.material (Mesh). The image it samples is a 3D
Texture; a 2D sprite has no material — its image is a Texture2D.
- change the color of a 3D object
- put an image on a 3D object
- make a surface shiny, matte or metallic
- make an object see-through, like glass
- draw a gizmo on top of everything, or show both faces of a plane
- outline the selected object
- write your own shader and set its uniforms
- share one material between many objects as a project asset
Platforms: iOS, Android, desktop, the web player, the le.codes viewer app; the web (Web Lite)
draws decal, lightmap and foliage as unlit, has no stencil, and runs a custom .mat only
under lecodes web dev (a lecodes web build page draws it unlit white); headless tests record
materials and draw nothing.
At a glance
const scene = new Scene()
scene.add(Light.sun())
scene.add(Mesh.sphere({ material: Material.unlit({ color: '#0f0' }), position: [2, 0, 0] }))
scene.open()
const main = async () => {
const tex = await Texture.load(asset('./crate.jpg'))
const crate = Mesh.box({ material: Material.lit({ color: '#ffffff', map: tex }) })
scene.add(crate)
crate.material.set('roughness', 0.3).set('metallic', 1)
}
main()Built-in materials: Material.lit, unlit, video, shadow and the rest
The built-in looks are static factories; their shaders belong to the runtime, nothing ships with
the project. An app's own shader is a .mat file loaded with Material.load
(below); defineMaterial makes a material a shared project
asset.
Material.lit(options?: { color?: ColorInput, map?: Texture | Canvas | null,
roughness?: number, metallic?: number, …state }): Material // PBR lit
Material.unlit(options?: { color?: ColorInput, map?: Texture | Canvas | null,
transparent?: boolean, …state }): Material // flat, ignores lights
Material.video(map?: Texture): Material // samples a VideoPlayer texture
Material.shadow(color = '#000000aa'): Material // shadow-catcher: invisible except where shadows fall
Material.particles(options?): Material // the default look of Particles + Trail
Material.decal(options?): Material // the default look of a DecalSet
Material.terrain(), Material.terrainLightmap() // Terrain's splat material (live / baked)
Material.lightmap(), Material.lightmapMasked() // a baked static (Lightmap)
Material.foliage(), Material.foliageMasked() // the vegetation tier (Foliage)
Material.load(url: string): Promise<Material> // an app's own compiled .mat…state = the render-state options below (depthTest, depthWrite, doubleSided, stencil),
accepted by lit and unlit.
litneeds light to be visible — IBL and/or aLight.sun();roughness/metallicare below.unlitis opaque by default;transparent: truealpha-blends it (below).videotakes the texture fromVideoPlayer.texture.shadowis the AR staple: a ground plane that shows only the shadows cast on it.particlesis what aParticlessystem (andTrail) draws with when you don't pass a custom material — you rarely construct it yourself, since theParticles/Trailoptions forward to it (map,sheet,emissive,blend,soft,depthFade,render,stretch, and for quadsemissiveMap,emissiveColor,frameBlend).render: 'point'uses theparticlesmaterial; the quad modes shareparticles-quad.decalis the projected-decal material of aDecalSet; its options are the set'smap,normalMap,bump,edge,angleFade,emissive.terrain/terrainLightmap,lightmap/lightmapMasked,foliage/foliageMaskedare built byTerrain,Model.loadand the scene loader for you — see Lightmap (and itsMaterial.lightmapShadingtier knob) and Foliage.
Built-in materials come from the runtime
Every factory above except load asks the runtime for its shader by name (lit, unlit,
unlit-transparent, shadow, video, particles, particles-quad, decal, decal-relief,
terrain, terrain-lightmap, lightmap, lightmap-masked, foliage, foliage-masked, and the
-lite lightmap pair). The host carries them — there is no .filamat to copy into a project and
no file to load. The compile names every built-in the bundle uses in its header, so a host that
fetches its shaders on demand (the web host) has them before the app starts.
a host that carries no material of that name makes the factory throw
(Material: this host carries no "lit" material). Two fall back instead:
unlit({ transparent: true }) warns and draws opaque when the blended twin is missing, and
Material.lightmapShading = 'lite' warns once and uses the full lightmap shader.
Shiny, matte or metal: roughness / metallic {#roughness-metallic}
A lit surface's finish is two 0–1 scalars: roughness 0 = mirror, 1 = matte; metallic
0 = dielectric (plastic, wood, paint), 1 = metal. Both are plain uniforms — unset means the
shader's default, and .set('roughness', v) changes them later.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const chrome = Mesh.sphere({ material: Material.lit({ color: '#dddddd', roughness: 0.1, metallic: 1 }), position: [-1, 0, 0] })
const rubber = Mesh.sphere({ material: Material.lit({ color: '#c0392b', roughness: 0.9, metallic: 0 }), position: [1, 0, 0] })
scene.add(chrome)
scene.add(rubber)
rubber.material.set('roughness', 0.4) // later: a plain uniform writeSee-through: glass and fading markers — transparent: true {#transparent}
A semi-transparent object — a glass pane, a fading marker, a sprite with cut-out alpha — is
Material.unlit({ transparent: true }) with an alpha in its colour. A blended draw writes no
depth, is sorted back to front and casts no shadow; only then does the colour's own alpha
('#ffffff80') mean anything.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const pane = Mesh.box({ material: Material.unlit({ color: '#88ccff80', transparent: true }), scale: [2, 2, 0.05] })
scene.add(pane)
pane.material.color = '#88ccff30' // more see-throughColor & map
Change the color of a 3D object's material, or swap its image, by writing to it —
mesh.material.color / mesh.material.map:
material.color = '#ff8800' // any ColorInput (write-only)
material.map = tex // Texture, or a Canvas (baked to a texture on assign)
material.map = null // remove the mapconst scene = new Scene()
scene.add(Light.sun())
scene.open()
const box = Mesh.box({ material: Material.lit({ color: '#888888' }) })
scene.add(box)
box.material.color = '#ff8800' // recolour it
box.material.color = 'rgb(40, 160, 255)'
const main = async () => {
box.material.map = await Texture.load(asset('./crate.jpg')) // the color now tints the image
box.material.map = null // back to the plain color
}
main()color and map are conveniences over the underlying uniforms (baseColor / baseColorMap on
lit/unlit, color on shadow). On lit/unlit the shader multiplies them — baseColor × baseColorMap, both defaulting to white (an unset map is backed by a 1×1 white texture) — so a
color alone or a map alone shows through unchanged, a color set alongside a map tints the
map, and map = null resets the texture back to white. On a custom material they don't know
the uniform names — color is a silent no-op and map writes baseColorMap; set your own
uniforms directly instead.
lit and opaque unlit have no alpha channel (baseColor is RGB): a translucent
colour draws opaque, and the engine logs once that it dropped the alpha. Translucency is
Material.unlit({ transparent: true }).
Draw on top of everything (gizmos): depthTest / depthWrite {#depth-state}
depthTest: false is the editor-gizmo case: the thing never hides behind scene geometry. Keep
depthWrite on so its own parts still occlude each other, and put its mesh in renderPriority 7
(see mesh.md) so nothing drawn after it covers it.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const m = Material.unlit({ color: '#ff5e5e', depthTest: false }) // drawn over the scene
const gizmo = Mesh.sphere({ material: m, scale: 0.2, renderPriority: 7, castShadows: false })
scene.add(gizmo)
m.depthTest = false // write-only; default true
m.depthWrite = true // write-only; default = the shader's (on for opaque, off when blended)Per-instance overrides of the depth state the shader package carries (Filament's
MaterialInstance::setDepthCulling / setDepthWrite). Both are optional on the host side: a host
built before them warns once and draws the material as the shader has it. The lite engine maps
them to the program's depth flags.
Show both faces of a plane: doubleSided
A plane seen from behind, cloth, a leaf: a material draws front faces only unless it is
doubleSided.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const flag = Mesh.plane({ material: Material.lit({ color: '#c9a227', doubleSided: true }) }) // both faces
scene.add(flag)
flag.material.doubleSided = false // write-only (Filament setCullingMode): back to front faces onlyOutline a selected object: stencil
The selection-outline recipe: the object marks the stencil buffer, a slightly larger copy draws only where the mark is NOT — a rim around the object, over everything.
const scene = new Scene({ stencil: true }) // the scene keeps a stencil buffer (off by default)
scene.add(Light.sun())
scene.open()
const body = Mesh.box({ material: Material.lit({ color: '#c9a227', stencil: { write: true, ref: 1, onPass: 'replace' } }) })
const rim = Material.unlit({ color: '#ffd45f', depthTest: false, stencil: { test: 'notEqual', ref: 1 } })
const hull = Mesh.box({ material: rim, scale: 1.06, renderPriority: 7, castShadows: false })
scene.add(body)
scene.add(hull)
body.material.stencil = null // deselect: back to no test, no write
hull.visible = falsescene.setStencil(true) turns the buffer on later. castShadows: false on the hull matters: a
caster 3 % off the body's surface shadows the body's own lit faces — a diagonal hatching that
looks like a broken stencil and is shadow acne.
stencil takes { write, ref, test, onPass, onFail, onDepthFail, readMask, writeMask }
(MaterialStencil): test compares ref against the buffer (always … notEqual), the ops say
what the buffer gets (keep, zero, replace = ref, increment, decrement, invert).
Optional on the host; the lite engine has no stencil buffer — a test passes, a write is dropped, so
an outline hull there draws whole (an approximation, like lite's lighting).
Your own shader and its uniforms: .mat, Material.load, set() {#custom-shaders--uniforms}
An app's own shader is a .mat file in the project, loaded with Material.load; its uniforms are
set through .set() / the uniforms proxy, which lowers each JS value to the right native uniform
call.
Material.load(url: string): Promise<Material> // asset('./x.mat'), compiled for the running host
new Material(response: FetchResponse) // the same, from a response you already have
material.set(key: string, value): this // chainable
material.uniforms[key] = value // same thing, property-style
material.uniforms.speed // reads return the last value YOU set (no native read)const scene = new Scene()
scene.add(Light.sun())
scene.open()
const main = async () => {
const holo = await Material.load(asset('./hologram.mat'))
holo.set('tint', '#38e0ff').set('speed', 2) // chainable
scene.add(Mesh.box({ material: holo }))
holo.uniforms.speed = 4 // same thing, property-style
// or, from a response you already have:
const other = new Material(await fetch(asset('./hologram.mat')))
other.set('dir', [0, 1, 0])
}
main()A .mat in the project is compiled with Filament's matc into one artifact per graphics backend,
and asset('./x.mat') resolves to the one the running host needs. The platform compiles them on
push; locally lecodes dev compiles on the fly and lecodes shaders compile [--platform …]
compiles every shader into .lecodes/shaders (matc is downloaded on first use). For a host with
no asset server — the desktop host, lecodes desktop run, render --desktop / --sim,
app build desktop — the artifacts are staged next to the bundle and read from there, so a custom
shader works fully offline.
A material that should take part in screen-space reflections
declares it in its .mat — reflections : screenspace next to shadingModel; the built-in
materials do not, so that is the one way to a reflecting surface today.
lecodes shaders new <name> writes a starter .mat (--ref lit|unlit|particles|… copies an
engine material to start from instead) plus its <name>.material.ts asset; bare
lecodes shaders lists the project's shaders, their compile state and each one's parameter table.
Uniform values: what each JS type becomes
| JS value | Lowered to |
|---|---|
a color string ('#rrggbb', 'red', 'rgb(…)', …) |
RGB color |
a color string with alpha ('#rrggbbaa', '#rgba', alpha < 1) |
RGBA color |
number |
float |
boolean |
bool |
Texture |
sampler (honors the texture's wrapS/wrapT) |
number[] / Float32Array |
float array / vector |
null |
clears the uniform (a texture resets to the 1×1 white) |
every string in set() is a color — any CSS color string, parsed by
Color; a string that is not one throws. RGB on a float4 parameter keeps
the instance's alpha, RGBA sets it (the color setter always sends RGBA, so an opaque color
resets the alpha to 1). A packed number is a float here, not a color. A Vec3/Quat wrapper
is not lowered — spread it to a plain array first: material.set('dir', [...v]). A name
the shader does not declare is ignored (the native engine logs the dropped write; the JS-side
uniforms still remembers it).
A colour per vertex: requires: [color] {#vertex-colours}
A custom material that declares requires: [color] gets a colour per vertex from
Geometry.colors — the way to draw many colours in ONE mesh
(a debug drawer, a curve whose colour follows a value). See geometry.md for the packing and the
sRGB caveat.
Share one material as a project asset: defineMaterial {#material-assets}
defineMaterial in a *.material.ts file makes a material a project asset — ONE shared GPU
instance: every node using the asset renders the same material, and editing it changes all of
them (two looks are two assets).
// materials/gold.material.ts
export default defineMaterial({ lit: { color: '#c9a227', roughness: 0.35, metallic: 1 } })
// materials/holo-glass.material.ts — a project shader + its parameter values
export default defineMaterial({
shader: asset('../shaders/lens.mat'),
params: { tint: '#1d2b26', opacity: 0.22, mask: asset('../textures/reticle.png') },
})
// main.ts
import gold from './materials/gold.material'
const scene = new Scene()
scene.add(Light.sun())
scene.open()
gold.load().then((m) => scene.add(Mesh.box({ material: m }))) // the ONE shared MaterialdefineMaterial(def: MaterialData): MaterialHandle
type MaterialData =
| { lit: LitMaterialDef } // Material.lit's options; `map` may also be an asset('./x.png') URL
| { unlit: UnlitMaterialDef } // Material.unlit's options, likewise
| { shadow: ColorInput }
| { shader: string, params?: Record<string, MaterialParamValue> } // asset('./x.mat') + values
type MaterialParamValue = number | boolean | string | number[]
handle.load(): Promise<Material> // the ONE shared instance: built on first call (textures loaded)
handle.material: Material | null // that instance, once built
handle.def: MaterialDataScene files take the handle as material: gold, inline defs use the same grammar — see
Scene files › Material assets.
in params, a string is a colour only when it starts with #; any other string is a
texture URL (asset('./x.png') compiles to one). params: { tint: 'red' } tries to load a
texture named red and warns — write '#ff0000'.
Shader parameters in the scene editor: .mat annotations {#material-assets--editor-annotations}
The scene editor renders a custom shader's parameters as a card straight from the .mat. Filament
only records a parameter's name and type, so the rest lives in the comment on the parameter's line
(or the comment line above it):
parameters : [
{ type : float3, name : tint }, // @color what the coating does to the world behind it
{ type : float, name : opacity }, // @range(0, 1) @default(0.22) how much the glass darkens
{ type : float, name : angle }, // @range(0, 3.1416) @step(0.01) stripe direction, radians
{ type : sampler2d, name : mask }, // the reticle mask, white on alpha
]@color→ a colour picker forfloat3/float4(@vecforces the plain vector; without either, a name containingcolor/colour,tint,albedo,emissive,glareorglowcounts as a colour)@range(min, max)→ a slider;@step(s)its step@default(v)→ the value a new material starts with (0.22,#202020,[1, 0, 0],true)- the remaining text is the tooltip
lecodes shaders prints the parsed table per shader; unreadable tags are reported, the parameter
still lists. The editor edits an asset live through its handle: setParam(name, value) sets one
parameter on the shared instance (every node using it follows), reset(def) rebuilds the instance
from a new definition and re-assigns it to every node a scene applied it to.
The scene occlusion material
scene.occlusionMaterial hands back the scene's occlusion material — an unlit instance in the
occluder blue, made on first use — as a regular Material, so you can set uniforms on it. See
Scene.
Pitfalls
// ✗ expecting material.color to work on a custom shader
customMat.color = '#f00' // no-op: the SDK doesn't know your uniform's name
// ✓ address the uniform directly
customMat.set('baseColor', '#ff0000')
// ✗ passing a Vec3 as a uniform value
mat.set('lightDir', dir) // not an Array — lowers to NULL, not a vec3
// ✓
mat.set('lightDir', [...dir])
// ✗ a translucent colour on lit
Material.lit({ color: '#ffffff80' }) // lit has no alpha: draws opaque
// ✓
Material.unlit({ color: '#ffffff80', transparent: true })See also
- Mesh — assigning materials (
mesh.material,setMaterial(index)) - Texture — loading maps, wrap modes, video textures
- Light — what
litmaterials respond to - Scene files —
material:on a node, material assets - Particles, Decals, Terrain, Lightmap, Foliage — the systems that build the other built-ins
- Scene —
occlusionMaterial,setMaterialGlobalParameterfor custom shaders