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

TypeScript
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

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

Note

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

Host-gated

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

TypeScript
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

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

TypeScript
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

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

TypeScript
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 from spawn() 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.

See also

  • Material — Material.decal({ … }), the default decal material
  • Physics — Physics.raycast for the hit point and normal
  • Texture — atlases, Texture.fromPixels, srgb: false
  • Particles — effects that fly rather than stick
  • Scene — setDepthEffects