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

TypeScript
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.

TypeScript
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.

  • lit needs light to be visible — IBL and/or a Light.sun(); roughness / metallic are below.
  • unlit is opaque by default; transparent: true alpha-blends it (below).
  • video takes the texture from VideoPlayer.texture.
  • shadow is the AR staple: a ground plane that shows only the shadows cast on it.
  • particles is what a Particles system (and Trail) draws with when you don't pass a custom material — you rarely construct it yourself, since the Particles/Trail options forward to it (map, sheet, emissive, blend, soft, depthFade, render, stretch, and for quads emissiveMap, emissiveColor, frameBlend). render: 'point' uses the particles material; the quad modes share particles-quad.
  • decal is the projected-decal material of a DecalSet; its options are the set's map, normalMap, bump, edge, angleFade, emissive.
  • terrain / terrainLightmap, lightmap / lightmapMasked, foliage / foliageMasked are built by Terrain, Model.load and the scene loader for you — see Lightmap (and its Material.lightmapShading tier 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.

Note

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.

TypeScript
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 write

See-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.

TypeScript
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-through

Color & map

Change the color of a 3D object's material, or swap its image, by writing to it — mesh.material.color / mesh.material.map:

TypeScript
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 map
TypeScript
const 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.

Note

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.

TypeScript
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.

TypeScript
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 only

Outline 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.

TypeScript
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 = false

scene.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.

TypeScript
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)
TypeScript
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)
Note

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).

TypeScript
// 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 Material
TypeScript
defineMaterial(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: MaterialData

Scene files take the handle as material: gold, inline defs use the same grammar — see Scene files › Material assets.

Note

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):

TypeScript
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 for float3/float4 (@vec forces the plain vector; without either, a name containing color/colour, tint, albedo, emissive, glare or glow counts 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

TypeScript
// ✗ 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 lit materials 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, setMaterialGlobalParameter for custom shaders