Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

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 physical focalLength (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.