Decals — DecalSet, DecalTrail
Marks stuck to surfaces: bullet holes, footprints, blood, scorch marks, posters, skid marks. A
DecalSet is one node that draws up to max decals; each decal projects its image onto the opaque
scene inside a box, so it wraps corners, stairs and curved surfaces. Effects that fly are
Particles; a texture that is part of a model is its Material.
- leave a bullet hole where a shot hits a wall or the floor
- put footprints, blood or scorch marks on the ground
- fade a mark out after a few seconds
- move, restyle or remove a placed decal later
- draw skid marks behind a car's wheels
- make dents or footprints that bend the lighting with a normal map
Platforms: iOS, Android, desktop, the web player, the le.codes viewer app; not on the web (Web
Lite) or in headless tests — spawn returns −1 there and count stays 0.
At a glance: a bullet hole where a shot hits — Physics.raycast + spawn
const scene = new Scene()
scene.add(Light.sun())
const ground = Mesh.plane({ material: Material.lit({ color: '#a09c90' }), normal: [0, 1, 0], scale: 20 })
ground.aspect(Shape, { box: [20, 0.01, 20] }).aspect(Physics, { motion: 'static' })
scene.add(ground)
scene.open()
const main = async () => {
const atlas = await Texture.load(asset('./decals.png')) // two cells side by side
const holes = new DecalSet({ map: atlas, sheet: [2, 1], size: 0.2, max: 128 })
scene.add(holes)
const hit = Physics.raycast([0, 5, 0], [0, -1, 0]) // a shot straight down
if (hit) holes.spawn(hit.point, hit.normal, { frame: 'random' })
holes.spawn([1, 0, 0], [0, 1, 0], { frame: 1, size: [0.14, 0.3], up: [0, 0, -1], life: 5, fadeOut: 1 })
console.log(`decals alive: ${holes.count} of ${holes.max}`)
}
main()Make a set of decals — new DecalSet
new DecalSet(options?: DecalSetOptions)
set.spawn(point: Vec3Like, normal: Vec3Like, options?: DecalSpawnOptions): number // the slot
set.place(placement: DecalPlacement): number // a decal by a full frame
set.update(slot, placement: Partial<DecalPlacement>): this // move / restyle a place()d decal
set.remove(slot): this
set.clear(): this
set.count: number // decals alive now
set.max: number // the slot budget
set.material: Material
set.renderPriority = 3 // write-only, 0 … 7type DecalSetOptions = DecalOptions & { // every DecalOptions field is the set's default
map?: Texture // the atlas (the default material's image)
sheet?: number | [cols, rows] // the atlas grid: n = n columns in ONE row; default 1 (the whole texture)
normalMap?: Texture // a tangent-space normal atlas on the same grid → a RELIEF decal (below)
bump?: number // relief strength; default 1
edge?: number // 0..1 soft fraction of the box's half depth at both ends; default 0.3
angleFade?: number // cosine of the surface angle past which it fades; default 0.3 (≈72°), 0 = none
emissive?: number // HDR boost of the image; default 0
material?: Material // instead of Material.decal({ map, normalMap, … }) — keep decal.mat's contract
max?: number // slots — the most decals alive at once; default 256
renderPriority?: number // default 3
name?: string
}map, normalMap, bump, edge, angleFade and emissive configure the default material; with
your own material they are ignored. Unlike Particles, where sheet: 8 is an 8×8
grid, a decal atlas given as one number is a single row: sheet: 4 is four cells side by side.
A set draws all its decals as ONE renderable. Each decal lands on opaque surfaces only — never on particles, glass or other decals — and needs no access to their geometry.
Slots are a ring: a full set recycles its oldest decal, so max is the on-screen budget.
spawn / place return the slot, or −1 when the host draws no decals.
Coordinates are in the set's space: a set added at the scene root with no transform takes world coordinates (the usual case); a set parented to a door rides with it and takes door-local ones.
a decal reads the scene's depth, which the engine keeps bound only while a set with
live decals is on screen. scene.setDepthEffects(false) turns depth-reading effects off
engine-wide — decal sets then draw nothing (see Scene).
a host without decals warns once ([decals] this host has no createDecalSet — decals are not drawn); spawn then returns −1 and count is 0.
Decal size, lifetime, fade and tint — DecalOptions
type DecalOptions = {
size?: number | [w, h] // image size on the surface, world units; default 0.2
depth?: number // how far in front of and behind the point it still lands; default min(w, h)
frame?: number | 'random' // atlas cell, row-major from the top-left; default 0
tint?: ColorInput // multiplied into the image; default white
opacity?: number // 0..1 on top of the tint's alpha; default 1
life?: number // seconds until it is gone; 0 = until recycled (default)
fadeIn?: number // seconds; default 0
fadeOut?: number // seconds at the end of `life` (ignored with life 0); default 0
gradient?: [bottom, top] // opacity multipliers at the image's −Y / +Y edge; default [1, 1]
caps?: [bottom, top] // tilt of those edges (a unit-space slope); default [0, 0]
}angleFade keeps a hit on the floor off the wall it meets: surfaces turned too far from the
projection axis fade out.
Place a decal on a surface, move it later — spawn / place / update
// on a surface: a point on it and the normal out of it (a raycast hit, a foot plant with Vec3.up)
type DecalSpawnOptions = DecalOptions & {
up?: Vec3Like // world direction of the image's top, projected onto the surface; default a random spin
rotation?: number // extra spin about the normal, radians; default random without `up`, else 0
}
// by a full frame: like a node looking INTO the surface (its −Z projects, +Y is the image's top)
type DecalPlacement = DecalOptions & {
position: Vec3Like
rotation?: QuatLike | Vec3Like // a quaternion, or Euler degrees (YXZ) like Node.eulerAngles; default identity
}spawn is the gameplay path — a footprint passes its travel direction as up. place is for a
decal you will move or restyle (an editor-placed stain, a marker): update(slot, { … }) changes the
fields it is given, keeps the rest, and keeps the decal's birth time (its life clock). update
knows only slots made by place.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const main = async () => {
const stains = new DecalSet({ map: await Texture.load(asset('./stain.png')), max: 16 })
scene.add(stains)
// looking down: −Z projects, so −90° about X points it at the floor
const stain = stains.place({ position: [2, 0.01, 2], rotation: [-90, 0, 0], size: [0.6, 0.6], tint: '#5a3a20' })
stains.update(stain, { position: [2.5, 0.01, 2] }) // moved; the rest and its life clock kept
stains.remove(stain)
}
main()Dents and footprints that bend the light — normalMap
With normalMap the decal bends the surface's lighting instead of painting a colour, so a
footprint or a dent shows on any surface without a colour of its own. The normal atlas is
tangent-space (+X = the image's right, +Y its top) and must be linear —
Texture.load(src, { srgb: false }) or Texture.fromPixels(…, { srgb: false }). With map too,
the colour multiplies in (a crater darkens by the map's alpha). The sun term applies in shadow as
well: the decal reads no shadow.
Skid marks and drag trails — DecalTrail
new DecalTrail(set: DecalSet, options: DecalTrailOptions)
trail.add(point: Vec3Like, normal: Vec3Like, opacity = 1): number // the new segment's slot, or −1
trail.end(point?: Vec3Like, normal = Vec3.up): this
trail.set / trail.options
type DecalTrailOptions = Omit<DecalOptions, 'size' | 'caps' | 'gradient'> & {
width: number // the strip's width on the surface, world units
}Feed it points as the thing moves: each new point becomes one box-decal segment from the previous
one, its image's up along the travel. The joints are mitred — each box is cut along the
bisector it shares with its neighbour — so a bend has no overlap darkening its outside and no gap
on its inside; a turn sharper than about 120° (a mitre steeper than 60°) breaks the strip and starts
a new one. The opacity given with each point is interpolated along its segment, so a mark can
darken as a slide deepens and fade as it ends. The first add after a start or an end() only
anchors the strip (−1), and so does a point that has not moved. end(point) draws one last segment
out to point at opacity 0 — a mark that tapers away instead of stopping dead.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const main = async () => {
const tread = await Texture.load(asset('./tread.png'))
const marks = new DecalSet({ map: tread, max: 900 })
scene.add(marks)
const skid = new DecalTrail(marks, { width: 0.25, tint: '#202020' })
// in a game: per wheel, every 0.25 m while it slides — here a curve of 20 points
for (let i = 0; i <= 20; i++) {
const slip = i / 20
skid.add([i * 0.25, 0, Math.sin(i * 0.2)], [0, 1, 0], 0.12 + 0.88 * slip)
}
skid.end([5.5, 0, Math.sin(4.4)]) // when the slide ends: one last segment fading to 0
}
main()Segments are the set's decals — the same ring, the same max budget: a set of 900 holds 900
segments across every trail drawn from it.
Pitfalls
- A normal map loaded as colour (the default sRGB) bends the light wrongly ➜ load it with
{ srgb: false }. update()on a slot fromspawn()does nothing ➜place()the decals you will move.- Marks on a moving object: a set at the scene root takes world coordinates and stays behind ➜
parent a set to the object (
door.add(set)) and use its local coordinates.