Projection Textures — Slide Projectors, Decals & Gobos
Run the sample · projector_test.ts
A projector casts a texture (or video) onto whatever scene geometry sits inside its frustum — a slide projector, a sticker decal, dappled "gobo" light, a video billboard wrapping over terrain. It is the unlit, shape-controlled cousin of a textured spotlight: where a spotlight's cookie only modulates lit radiance on a cone, a projector composites its image directly, supports rectangular or perspective frusta, alpha transparency, cropping, and a per-projector lit/unlit choice.
How it works#
A projector is a Projector component.
Its GameObject transform supplies the position and forward (-Z) direction; the
shape fields define the frustum the image is cast through:
shape: 'perspective'— a perspective frustum, optionally driven by a physicalfocalLength(mm) +sensorHeight, so projecting a photo matches the lens it was shot with. This is the camera-projection case.shape: 'rect'— an orthographic box (orthoWidth×orthoHeight): a flat decal/poster with no perspective foreshortening.
Rendering is screen-space, in the deferred pipeline. The
ProjectorPass runs a
fullscreen triangle: for each shaded pixel it reconstructs world position from the
G-buffer depth, transforms it into the projector's clip space
(projectionViewProj()), and — when the pixel lands inside the frustum
(xy ∈ [-1,1], z ∈ [0,1]) — samples the source texture and blends it. Pixels
outside the frustum (or on the sky) are discarded. See
projector.wgsl.
Three blend modes map to fixed-function blend states, with the fragment packing its output to match each:
blend |
Look | Pipeline blend |
|---|---|---|
'alpha' |
sticker / slide | src-alpha / 1-src-alpha |
'add' |
light projector / caustic | one / one |
'multiply' |
gobo / stain | zero / src |
Lit vs. unlit#
projector.lit chooses the insertion point, handled by two features that share
the pass machinery (projector_feature.ts):
- Unlit (
ProjectorFeature) composites onto the HDR buffer after lighting — a flat, self-lit image. - Lit (
ProjectorDecalFeature) writes the G-buffer albedo before lighting, so the projected texture receives scene light and shadow like paint on the surface.
Toggling lit at runtime moves a projector between the two features automatically
(each filters the scene's Projector components by the flag). Both are enabled by
deferredPreset({ projectors: true }).
Many projectors on one texture: the atlas#
Each standalone projector texture is a separate GPUTexture. With dozens of small
projectors that eats the texture budget, so static images can instead be packed
into a shared ProjectorAtlas — a
texture_2d_array with a shelf-packer. ProjectorAtlas.add() returns an
AtlasSlot (layer + normalized uvRect); the pass samples a per-layer '2d'
view and composes the packing rect with the projector's own crop. Hundreds of
atlas projectors then cost one bound texture, and duplicate source images cost no
extra memory.
Video can't be atlased — its content changes every frame. A
VideoSource stays standalone: the 'copy' backend uploads the current
HTMLVideoElement frame into an owned texture each frame
(copyExternalImageToTexture). The sample drives a video projector from a live
canvas.captureStream() so it needs no asset file.
Extending it: custom projection programs#
The built-in projection is a straight frustum cast. For richer behavior — e.g. a
photo carrying its own depth map, used to occlude or parallax-correct the
projection per texel — Projector.programId marks a projector for a custom
program. The data model (the Projector component, the atlas, the matrices) is
reusable; a custom projection writes its own Pass subclass that consumes the
same Projector list but binds its extra textures and runs its own
world→UV shader snippet. This keeps the common path simple while leaving the
advanced, per-texel reprojection cases fully open.
Controls in the sample#
Focal length, opacity, blend, crop (zoom), edge falloff and the lit toggle drive the "slide" projector live; a slider scales the atlas-projector count. WASD + click-drag to fly.