8 — Projection Textures
▶ Live demo: run this tutorial in your browser — built from src/08_projection_textures.ts. Toggle the projector with T (mode), B (blend), L (lit).
A projector casts an image onto whatever surfaces are already in front of it — terrain, buildings, anything in the G-buffer — like a slide projector or a map overlay draped over the relief. It reconstructs each shaded pixel's world position from depth, tests it against the projector's frustum, and composites the image where it lands. Sources: samples/geo_osm_projection.ts, src/engine/components/projector.ts.
Turning the feature on#
Projectors are an opt-in pair of render features. Pass projectors: true to the
deferred preset (the geo tutorials wrap it in createGeoEngine):
const { engine, device, cameraGO, controller } = await createGeoEngine(
canvas, { projectors: true }, { y: 600, z: 1500, pitch: -0.35 },
);
That adds two features: the unlit ProjectorFeature (composites the image
after lighting, a flat overlay) and the lit ProjectorDecalFeature (writes
the image into the G-buffer albedo before lighting, so it picks up scene light
and shadow like paint on the surface). A Projector is routed to one or the
other by its lit flag.
The Projector component#
A Projector is a Component. Its GameObject's world transform supplies the
projector's position and forward (local −Z); the shape/range fields
define the frustum the image is cast through.
import { Projector } from 'taos/engine/index.js';
const projGO = new GameObject({ name: 'CityProjector' });
const projector = projGO.addComponent(new Projector());
projector.source = { kind: 'texture', texture: myGpuTexture };
projector.blend = 'alpha'; // 'alpha' | 'add' | 'multiply'
projector.edgeFalloff = 0.05; // soften the frustum edge
projector.near = 1;
projector.far = 6000;
engine.scene.add(projGO);
Key fields:
| Field | Meaning |
|---|---|
source |
{ kind: 'texture' | 'atlas' | 'video', ... } — what gets projected |
shape |
'rect' (orthographic box) or 'perspective' (slide-projector frustum) |
orthoWidth / orthoHeight |
footprint size in meters when shape === 'rect' |
fovY / focalLength |
vertical FOV (or a physical lens) when shape === 'perspective' |
aspect |
width/height of the projected image |
near / far |
frustum depth range; surfaces beyond far get nothing |
blend |
'alpha' (sticker), 'add' (light), 'multiply' (stain/gobo) |
opacity, tint, crop, edgeFalloff |
strength, color, sub-rect, edge softening |
lit |
false = unlit overlay; true = painted into albedo, lit + shadowed |
The source here is a plain GPUTexture you own — in the sample it's a small
translucent "target" drawn into a 2D canvas, so the transparent background lets
the alpha blend read as a sticker on the city.
Top-down vs. perspective#
The two shapes give two very different looks:
'rect'— an orthographic box. Point it straight down and it drapes the image over the relief with no perspective foreshortening: a map overlay.'perspective'— a real frustum cast from the projector's position, like a slide projector throwing the image across the scene from an angle.
Anchoring to the ground through reanchors#
Because the floating origin shifts as you fly (Tutorial 2),
a projector placed once in world space would drift off the terrain. Anchor it in
ECEF instead and re-derive its world transform every frame from the live
GeoFrame:
const anchorEcef = geodeticToEcef(LON * DEG2RAD, LAT * DEG2RAD, GROUND_M);
function placeProjector(): void {
const fr = geo.frame;
const a = fr.worldFromEcefPoint(anchorEcef); // ground in world space now
const ground = new Vec3(a.x, a.y, a.z);
const ud = fr.worldFromEcefDir(fr.up);
const up = new Vec3(ud.x, ud.y, ud.z).normalize(); // local "up" at the anchor
// Top-down map drape: hang above the ground, look straight down.
projGO.setPosition(...ground.add(up.scale(RIG_UP)).toArray());
projector.shape = 'rect';
projector.orthoWidth = projector.orthoHeight = FOOTPRINT;
projGO.rotation = lookRotation(up.scale(-1));
}
Call placeProjector() in beforeFrame so the image stays locked to the city no
matter how far you roam. geo.attach(engine, { cameraGO }) installs the
floating-origin reanchor in beforeFrame; register placeProjector after it so it
re-derives the projector transform against the freshly reanchored frame.
geo.attach(engine, { cameraGO }); // installs the floating-origin reanchor
engine.beforeFrame(() => {
placeProjector();
});
Lit vs. unlit#
Flip projector.lit to move the same projector between the two features:
lit = false(default): a flat, self-lit overlay composited on top of the lit scene — good for HUD markers, reticles, map labels.lit = true: the image is written into the albedo before lighting, so it's shaded and shadowed like it was painted onto the building or terrain — good for decals, posters, projected signage.
The projector reads the deferred G-buffer, so it works the same on 3D tiles and quantized-mesh terrain together. geo_osm_projection.ts is the full version of this tutorial: a night-lit Grand Canyon with a file picker (project any image), a visible frustum wireframe, and live tunables.
Next#
- Tutorial 9 — Terrain Physics: build Jolt colliders from streamed terrain so dropped objects collide with real mountains.