Rendering
The modoki runtime composites three rendering layers into a single view. Every renderable ECS entity carries a Renderable.layer of 3d, 2d, or ui, and the engine routes it to the matching backend. The two WebGL/WebGPU canvases are stacked, with the React DOM UI layer painted on top.
See also: Architecture · UI System · Materials & Textures · 2D Skinning
Three Rendering Layers
| Layer | Backend | Driver |
|---|---|---|
3d | Three.js (WebGPU/WebGL2) | Scene3D.tsx, synced by scene3DSync.ts |
2d | PixiJS v8 | Scene2D.tsx, draw utils in render2DUtils.ts |
ui | React DOM | UIRenderer (see UI System) |
Layering: Scene3D mounts an absolutely-positioned container at zIndex: 0. The PixiJS canvas(es) and the React DOM UI sit above it, so 3D acts as the background and 2D / UI overlay it.
3d — Three.js
Scene3D.tsx owns a single THREE.Scene, a PerspectiveCamera, and the renderer. It does no ECS bookkeeping itself — each frame it calls into scene3DSync.ts:
syncCamera(world, scene, camera)— pushes the activeCamera+Transformonto the Three camera, applies FOV/near/far andclearColor.syncEnvironment(world, scene)— binds the cached HDREnvironmenttexture (envmap + optional background).syncLights(world, scene, ecsLights)— creates/updates/removesTHREE.Lightinstances from theLighttrait (ambient / directional / point / spot), re-aiming spot & directional targets from the authoredtarget*point, or from the world transform when it's unset.syncRenderables(world, scene, state)— the main mesh sync. Handles GLB meshes (Renderable3D, including bakedTHREE.LODsets) and procedural primitives (Renderable3DPrimitive). Internally it uses:syncMaterial(...)— resolves.mat.jsonreferences, inline texture paths, and the default material; fans the result out to single meshes or every LOD child.applyTransform(...)— copies the propagated world transform (fromworldTransforms) onto the object, falling back to the localTransform.
Object lifetimes are tracked in a RenderState (ecsObjects, ecsSprites, ecsMaterials, …); entities that disappear from the query are removed and their owned geometry/materials disposed.
2d — PixiJS v8
Scene2D.tsx renders Renderable2D entities into their nearest Canvas2D ancestor's PixiJS container. A sprite is drawn either as a tinted Graphics primitive (driven by Renderable2D.color) or an image. Shared draw computations live in render2DUtils.ts (drawPrimitiveShapeGfx(), etc.) so the editor and runtime share one code path. Full detail: 2D Rendering (PixiJS) below.
ui — React DOM
UI entities (Renderable.layer = 'ui') are projected to React/DOM by UIRenderer, laid out with CSS flexbox and bound to the store. Full details in UI System.
Camera & Framing
The active camera is an ECS entity carrying Camera + Transform; syncCamera(world, scene, persp, ortho) (scene3DSync.ts) drives the Three cameras from it each frame and returns whichever Camera.projection selects (the render camera).
Camera trait — runtime/traits/Camera.ts
| Field | Default | Meaning |
|---|---|---|
projection | 'perspective' | 'perspective' (uses fov) or 'orthographic' (uses orthoSize). |
fov | 30 | Vertical field of view, degrees (perspective). |
orthoSize | 5 | Half the visible world-height, world units (ortho): top=+orthoSize, bottom=−, left/right derived from the viewport aspect. Unity-style knob — good for board/top-down games. |
near / far | 0.1 / 500 | Clip planes (applied to both cameras). |
overlayDistance | 3 | Camera-space overlay plane distance. |
clearColor | 0x000000 | Scene background colour (unless the Environment sync owns a texture background). |
syncCamera writes pos/rot to BOTH the perspective and orthographic cameras so a live projection toggle is seamless, applies FOV/near/far (change-gated), sets the ortho frustum from orthoSize × aspect via applyOrthoFrustum (aspect comes from the live perspective camera, kept current on resize), and pushes clearColor onto scene.background — reading the ACTUAL scene.background (not a cache) so a scene reload re-applies it, and leaving a TEXTURE background alone (owned by the Environment sync). A camera whose entity is deactivated is SKIPPED: an inactive ortho camera would otherwise clobber the active pose and flip the whole scene to orthographic (the projection pick is monotone persp→ortho).
CameraFrame — declarative auto-fit
A CameraFrame entity is an oriented framing box (its Transform scale IS the box size, matching a size-1 primitive box); the framing loop places the camera so the box fits the viewport. selectActiveFrame picks the FIRST CameraFrame with active === true that isn't deactivated — a false active is a real off switch (no "fall back to any frame", so toggling it off releases the camera). The fit itself is pure, side-effect-free, unit-tested math in cameraFraming.ts (computeFrameFit).
The key geometric shortcut: translating the camera along its own forward axis changes only a point's depth in view space, never its lateral coordinates — so the perspective fit distance has a CLOSED FORM (accumulated per-corner: D ≥ |lateral|/(frac·tan) − depth), no iterative dolly/binary-search. Options:
mode—'contain'(fit both axes),'fitWidth','fitHeight'.- margins (
marginTop/Bottom/Left/Right, viewport fractions) — asymmetric margins SHIFT the framed content whenautoAimrecenters; otherwise they shrink the fit symmetrically. autoAim— true → camera owns lateral position and recenters the box into the margined sub-rect; false → keep the AUTHORED lateral position and dolly for size only (the fit measures each corner from the camera's optical axis, not the box center).- anchors (
anchorV/anchorH+anchorPosV/anchorPosH) — pin a chosen box edge (or its center) to a viewport fraction, overriding the mode/margin centering. - blend (
continuous,blendTime,blendEase) — a runtime active-frame switch (setActiveCameraFrame, ref by name/guid/id; a no-match is a NO-OP) blends the camera into the new frame over the TARGET frame'sblendTime/blendEase(easings incameraFraming.tsease()).
computeActiveFrameFit returns {position, orthoSize, …}; Scene3D applies position to the camera (and orthoSize in ortho mode). Ortho fits set orthoSize from the max lateral extent; perspective fits set the dolly distance. Both keep the nearest box corner in front of near.
Lights & Shadows
syncLights(world, scene, ecsLights) (scene3DSync.ts) creates / updates / removes a THREE.Light per Light entity (three/traits/Light.ts), tracked in a Map<entityId, THREE.Light>.
- Types —
lightTypeselectsAmbientLight/DirectionalLight/PointLight/SpotLight(createLightFromTrait). Switching the type at runtime disposes the old instance and recreates it (lightMatchesType). - Per-frame fields —
color,intensity,castShadoware re-applied every frame;distancefor point/spot;angle+penumbrafor spot. Subclasses ignore irrelevant fields (anAmbientLighthas nodistance). - Aiming — directional/spot lights aim at a
targetObject3D added to the scene. Two ways to author it, checked in this order each frame bysyncLights. A reaped or type-switched light removes its stray target too (removeLightTarget), else empties accumulate on churn.- 1.
Light.targetX/targetY/targetZ— a WORLD-space point to aim AT. Set any one of them non-zero and the light points there, ignoring its rotation entirely. This is usually what you want: "this spot lights that statue" is a position, not an euler. - 2. Rotation fallback — when all three are 0 the aim is the light's local −Z forward put through its WORLD euler, so a parented spot follows its transform instead of always aiming at the origin. To aim at a point this way, set the rotation from the normalized light→target direction
u:ry = asin(−u.x),rx = atan2(u.y, −u.z)(roll is irrelevant — rotating about Z cannot move the −Z axis). (0,0,0)means UNSET, not "aim at the world origin" — that is what keeps every scene authored before the fields were wired working unchanged, since they all serialize0,0,0. To aim at the origin, nudge one axis (targetY: 0.001); the direction error is immeasurable at any real light distance. Both directions are pinned intests/runtime/syncLights.test.ts.- ⚠️ Historical trap (fixed 2026-07-26): the
target*fields used to be DEAD. They were declared on the trait, shown in the Inspector under a "Target" group, and written into prefab defaults — and read by nothing, so a light with zero rotation always pointed along −Z, dead horizontal, no matter what you typed intotarget*. Symptom: ground planes render black (their +Y normal is edge-on to the light, N·L ≈ 0) while walls and object sides light normally, andcastShadowappears to do nothing because the shadow map is cast edge-on. Cost a long false hunt for a "WebGPU shadow bug" indemos/3d-physics-demo, and made a correctly-targeted spot indemos/postfx-demolook dead at intensity 4000. If you meet a scene that still aims six spots by hand-computedasin/atan2rotations, that is the old workaround — it still works (all-zero targets), and can be simplified to plain targets. - ⚠️ Post-FX stages that reconstruct DEPTH must be handed the SCENE camera's near/far as caller-owned uniforms — never TSL's global
cameraNear/cameraFar(fixed 2026-07-26). Those globals resolve from whatever camera renders the CURRENT pass, and a post-FX stage like DOF's circle-of-confusion pass is a full-screen QUAD with its own camera — so they silently resolve to the quad's near/far and the reconstructed viewZ becomes effectively constant across the frame. three's ownPassNode.getViewZNode()sidesteps this with the pass's private_cameraNear/_cameraFar;buildViewZNode(postfx/dofViewZ.ts) takes them as explicit parameters, and the DOF stage updates them each frame in itsStageHandle.prepare(). Symptom to recognise: depth-of-field with no depth — near and far objects blur by the same amount and move together asfocusDistancechanges, and the effect completely ignores the scene camera'snear(the diagnostic: changeCamera.nearand watch nothing happen). It cost hours because it looks like a tuning problem, not a wiring one. - Euler order is XYZ — the same order the renderer applies to every other object (
applyTransform'sobj.rotation.set(rx, ry, rz)at three's default, andgetWorldTransform3D's decomposition). ⚠️ Historical trap (fixed 2026-07-26):syncLightsused to derive the aim with a hand-rolled(−sin ry·cos rx, sin rx, −cos ry·cos rx), which is YXZ — so one authored euler meant one orientation on a mesh/camera and a different one on a light. The two agree only whenry ≈ 0, true of most authored spots, which is why it survived so long; a light with both pitch and yaw was mis-aimed (measured: 80° on one scene's key light). The fix routes the forward throughapplyEuler, and the 24 affected lights across ~20 scenes were migrated so their directions are unchanged. If you ever re-test this, use a pose where the orders disagree — both pitch and yaw non-zero. A test atry = 0passes either way and proves nothing (tests/runtime/syncLights.test.ts).
- 1.
- Particle layer — every created light enables
PARTICLE_LAYER(Three lights are layer-gated); without it, lit mesh-particle materials render black. - A light whose query row vanishes is removed + disposed at the end of the pass.
Shadows (configureLightShadow)
Applied each frame while castShadow is on and the light is directional/spot. Light trait knobs (defaults tuned for a clean papercraft drop shadow):
| Field | Default | Meaning |
|---|---|---|
shadowMapSize | 2048 | Depth-map resolution (square). A change reallocs the depth texture — GUARDED so it only regenerates when the size actually changes. |
shadowCameraSize | 16 | Directional shadow-camera ortho half-extent (world units) — must ENCLOSE the scene. |
shadowBias | -0.0003 | Depth bias (fights acne). |
shadowNormalBias | 0.008 | Normal-offset bias (fights peter-panning). |
shadowRadius | 4 | PCF blur radius. |
showShadowFrustum | false | Editor-only: outline the shadow-camera coverage box in SceneView (runtime ignores it). |
The shadow camera near/far are fixed at 0.1 / 200; the directional light's ortho frustum is set from shadowCameraSize. Casters/receivers are flagged via applyShadowFlags (traverses the object, setting castShadow + receiveShadow on every mesh) — inert unless a light casts AND the renderer's shadow map is enabled.
Scene lights in custom shaders (sceneLightUniforms.ts)
Standard MeshStandardMaterials get scene lights for free through Three's LightsNode. A custom shader (a .shader.json file shader, or a code-registered TSL builder) assigns its own fragmentNode, which BYPASSES that lighting pipeline — so historically each custom shader baked in a fixed sun direction/colour. sceneLightUniforms.ts closes that: it picks a small set of the scene's actual Light traits each frame and exposes them to custom shaders as uniforms.
- The picker —
sceneLightPicker.ts(pickSceneLights) is a PURE function (headless, unit-tested) that turnsLightSample[]into: the brightest directional as the key light (keyDirtoward-light +keyColorlinear rgb×intensity), the summed ambient (ambientColor), and the strongestMAX_SHADER_POINT_LIGHTS(4) point/spot lights (world pos + colour +invRange). Colours are sRGB-hex → linear to match Three's pipeline. Ranking is by intensity — camera-INDEPENDENT, so the editor SceneView and runtime GameView agree and lights don't pop as the camera moves. - Why scene-global, not per-mesh — materials are shared + refcounted, so a true "nearest lights to THIS mesh" pick would force per-entity material clones. Instead ONE singleton set of
uniform()nodes is shared by every custom material;updateSceneLightUniforms(world)refreshes their values at the end ofsyncLights(so every render surface feeds it, and a gizmo-moved light updates immediately). The singleton is created lazily on first bind — a scene with no custom shaders pays nothing. Per-mesh selection is a possible future extension. - Shader inputs — a file shader binds these by argument name (it declares only what it uses):
sceneDiffuse(vec3, a ready-made Lambert term —albedo * (ambientColor + sceneDiffuse)), plus the rawkeyLightDir/keyLightColor/ambientColorfor shaders that want their own (stylized) lighting math. Point-light falloff is windowed(1 - (d·invRange)⁴)², which collapses to no attenuation wheninvRangeis 0 (infinite range). Code TSL builders reach the same uniforms viagetSceneLightUniforms()from@modoki/engine/runtime/rendering. Worked examples (both migrated off hardcoded suns):games/space-console/.../shaders/ship-halo.{wgsl,glsl}andgames/space-console/runtime/shaders/planet.ts.
HDR Environment & IBL
An Environment entity (three/traits/Environment.ts) binds an HDR equirect as the scene's image-based lighting + optional background. syncEnvironment(world, scene) (scene3DSync.ts) binds the CACHED texture each frame:
| Field | Default | Meaning |
|---|---|---|
hdrPath | '' | HDR asset ref (GUID). |
intensity | 1 | IBL / reflection strength (scene.environmentIntensity). |
showAsBackground | false | Also draw the envmap as the scene background. |
backgroundIntensity | 1 | Background exposure when shown. |
backgroundBlurriness | 0 | Background blur, 0..1. |
The texture is acquired + refcounted per scene by SceneManager (see Architecture) so getCachedEnvironment(hdrPath) returns a ready texture before first render. Every scene.environment / intensity / background write is CHANGE-GATED — this runs every frame, but the texture + scalars rarely change and reassigning them flags the render state dirty on some backends. An ultrahdr-format source is display-referred (dimmer for IBL), so both its env + bg intensity are boosted by ULTRAHDR_INTENSITY_BOOST toward scene-linear parity (the user's intensity still scales on top). A runtime-spawned Environment (editor live-edit) that skipped the acquire path kicks off an async load and lands on a later frame. Removing/deactivating the Environment clears scene.environment (the texture is owned by envCache, never disposed here).
HDR conversion (Node — dev server + build)
Source .hdr files are downscaled offline into a content cache by env-convert.ts + hdr-codec.ts — DEPENDENCY-FREE (no ImageMagick / native tool, unlike toktx for KTX2):
- Decode —
three'sHDRLoader.parse(robust: handles RLE + flat), lazy-imported sothreestays out of the plugin's top-level bundle, decoded to an RGBAFloat32Array(FloatType). - Downscale — an area-average box filter in LINEAR radiance space (
downscaleRGBA) tosettings.maxSizeon the longest edge (envTargetDims, never upscales). Averaging in linear space is the correct high-quality filter for an equirect that feeds a blurred PMREM. - Re-encode — a hand-rolled canonical new-RLE RGBE
.hdr(encodeHDR, literal/uncompressed runs — a layoutHDRLoaderalways parses;floatToRgbeshared-exponent encoding). Width must be in[8, 32767](a real ≥256 equirect always is; a pathologically narrow HDR THROWS → source fallback rather than silent corruption). - Cache — the output
~env.hdrlands in the content cache (env-cache.ts), keyed on source bytes + settings. A cache hit SKIPS the expensive decode entirely, reading src + variant dims cheaply from the ASCII header resolution line (readHdrHeaderDims).
Fog
A Fog entity (three/traits/Fog.ts) drives scene-wide fog. syncFog(world, scene) (scene3DSync.ts) applies it every frame, mirroring syncEnvironment's first-active-entity-wins + clear-on-none convention:
| Field | Default | Meaning |
|---|---|---|
enabled | false | Turn fog on for this scene. |
mode | 'linear' | 'linear' (Near/Far), 'exponential' (Density), or 'height' (Density + Height). |
color | 0xa8b4c0 | Fog color, blended over distant/fogged surfaces. |
near / far | 10 / 100 | Linear mode: distance (world units) where fog starts / reaches full color. |
density | 0.02 | Exponential + height modes: thickness — higher closes in sooner. Rule of thumb: density ≈ 1 / typical viewing distance — the 0.02 default is tuned for scenes spanning hundreds of units and reads as "no fog" in a small scene (a few world units needs density closer to 0.2–0.5). |
height | 10 | Height mode: world-Y fog ceiling — geometry BELOW this fogs (denser the lower + farther), geometry above stays clear. Requires Y-up. |
The hybrid mechanism (why there are two code paths)
linear/exponential → the classic scene.fog object. Despite this engine rendering exclusively through WebGPURenderer with TSL/NodeMaterial (see "No custom GLSL" below), NodeMaterial.fog defaults to true, and three's own NodeManager.updateFog() transparently converts scene.fog (a THREE.Fog/FogExp2 instance) into the equivalent TSL node graph (fog(color, rangeFogFactor(near, far)) / densityFogFactor(density)) on every render — no manual scene.fogNode/TSL wiring needed. syncFog just assigns scene.fog directly, and three caches the derived TSL node by the Fog/FogExp2 object's own identity, refreshing color/near/far/density each frame via reference() nodes (NodeUpdateType.OBJECT) — so mutating the SAME object's fields already gets "update without recompiling the shader" for free. syncFog only allocates a NEW Fog/FogExp2 instance when mode switches to/from one of these (different classes, cached separately under different keys).
height → scene.fogNode directly. There is no classic-object equivalent for height fog, so it drives scene.fogNode via fog(color, exponentialHeightFogFactor(density, height)) — the same TSL primitives, hand-assembled. NodeManager.getFogNode() prefers scene.fogNode over a derived-from-scene.fog node, so syncFog explicitly nulls whichever path is inactive (a stale scene.fog/scene.fogNode would otherwise silently win).
A stable node identity is a correctness requirement here, not an optimization. Node.getHash() returns the node's own instance id, and NodeManager.getCacheKey() folds fogNode.getCacheKey() into the render-object's SHADER CACHE KEY — so rebuilding the height-fog node every frame would recompile every affected material's shader every frame. syncFog keeps one HeightFogState (the color/density/height uniform() nodes + the composed fog node) per physical THREE.Scene in a WeakMap, built once and mutated in place — the same pattern sceneLightUniforms.ts uses for custom-shader lighting. Toggling height → linear/exponential → height reuses the cached node rather than rebuilding it.
⚠️ Scene-global TSL uniforms MUST use renderGroup — the uniform-group rule
This bit us twice (HDR env intensity, then height fog) before the root cause was understood. The rule: a bare uniform() is a PER-OBJECT uniform, and a per-object uniform on static geometry never updates. Read this before adding any new TSL uniform.
Three's uniform() defaults to objectGroup — one uniform buffer per render object. Those buffers are only re-uploaded inside Bindings.updateForRender(renderObject), which Renderer calls only when NodeMaterialObserver.needsRefresh(renderObject) is true. That observer watches only MATERIAL properties (its fixed refreshUniforms list), the world matrix, geometry, and lights — so for a static mesh with a plain (non-node) material it returns false forever once initialized. A scene-global value written into such a uniform therefore updates its JS-side .value correctly but never reaches the GPU on non-animating geometry, while animated objects update fine — a maddening partial staleness that looks like "some things update, some don't". (Diagnosing it requires a TRUE framebuffer read: a forced modoki_capture_viewport render does NOT fix it, which rules out a render-on-demand scheduling gap and points at the uniform upload.)
renderGroup is the fix and the intended mechanism: a shared group (shared: true, updateType: RENDER) whose single bind group / buffer is shared by every material referencing those nodes and re-uploaded once per render call, so it cannot go per-object stale. Three's own NodeManager.updateFog() does exactly this for the classic scene.fog path (reference(...).setGroup(renderGroup)) — which is precisely why linear/exponential fog never had the bug and height fog did.
// ✅ scene-global (fog, scene lights, wind, global time)
const color = uniform(new THREE.Color()).setGroup(renderGroup);
// ✅ genuinely per-object (reads from object.userData)
const t = uniform(0).onObjectUpdate(({ object }) => object.userData.stripeTime ?? 0);
// ❌ scene-global in the default per-object group → stale on static meshes
const bad = uniform(new THREE.Color());Guarded by a unit test (syncFog.test.ts, "puts every height-fog uniform in renderGroup") so the .setGroup calls can't be "simplified" away.
Is this a three.js bug? For fog, no — it was our misuse of a documented grouping mechanism. For scene.environmentIntensity it's arguably a three-side wart: materialEnvIntensity (nodes/accessors/MaterialProperties.js) is a single objectGroup uniform serving BOTH the per-material material.envMapIntensity and the scene-global scene.environmentIntensity fallback — correct for the former, structurally stale-prone for the latter. We can't re-group three's own node, so refreshEnvIntensityObserver (above) remains the justified workaround there — but it is a workaround, and should never be copied for engine-owned uniforms; use renderGroup.
Height-fog semantics (three/src/nodes/fog/Fog.js): distance = max(height − positionWorld.y, 0); m = distance × viewZ; factor = 1 − exp(−(density × m)²). Fog needs BOTH depth below the ceiling AND camera distance — a fragment just under height stays clear even far away. Y-up only.
Height mode is EXTRA density-sensitive (measured live on games/3d-test's ~5-unit island with a camera ~25 units out): because m is the PRODUCT of depth-below-ceiling and camera distance, density: 0.3 (already the high end for plain exponential fog at that distance) fully saturated the entire visible scene to the fog color — indistinguishable from "nothing rendered" when the fog color happens to resemble the background. density: 0.03 at the same distance gave a clean gradient (clear palm-tree tops, hazy water). Start an order of magnitude below the exponential-mode rule of thumb and raise slowly.
NPR interaction — custom shaders must opt out of fog
A custom NodeMaterial shader that sets fragmentNode to an NPR outputStruct (via nprFragmentOutput, see "NPR Outline Post-Process" below) breaks when fog is enabled and the material's fog flag is left at its default true: NodeMaterial.setupOutput() still runs setupFog() on the struct, which REPLACES it with a single vec4 — collapsing the 3 MRT targets (output/normal/lineColor) down to 1, which WebGPU then discards as an incomplete draw (the exact "targets[1]/[2] have no fragment output" failure nprFragmentOutput's own docblock warns about, just triggered by fog instead of a missing wiring). Use applyNprFragmentOutput(mat, colorRGBA, preserve?) instead of nprFragmentOutput + a manual fragmentNode assignment — it sets both fragmentNode and fog = false in one call, so a scene that later gains a Fog entity doesn't silently drop the shader's draws. games/space-console's stripes/matcap/planet shaders use it.
Material Sync
syncMaterial(obj, id, curMat, state) (scene3DSync.ts) binds a mesh renderer's material each frame. A renderer references a MATERIAL only (a .mat.json GUID) — never a texture directly (textures live on the material; resolution + the KTX2 variant pick are in Materials & Textures):
- An empty ref falls back to a shared engine default (
MeshStandardMaterial, grey,roughness 0.5,metalness 0). - A material created inline for one entity is tracked in
_ownedMaterialsand disposed when reassigned; shared cache materials are NEVER disposed here (the scene refcount owns them). - When the ref is UNCHANGED but the async
.mat.jsonload only just finished,syncMaterialre-checksresolveMaterialand swaps the resolved material in — retrying each frame until it lands. - A
THREE.LODfans the material out to every LOD child mesh (materialTargetsOf).
Tint — the Tint trait renders a per-(material,color,amount) CLONE of the shared base material (.color set to the tint, nprColorPreserve set to the strength). Clones are cached (every ally ship shares ONE blue clone) and freed only on world swap (disposeTintMaterials, wired to onWorldSwap); a continuously-varying tint (an animated colour) would grow the cache unbounded and warns past 64 entries. The NPR composite then blends the grayscale fill toward that colour per-draw (see Color preservation).
MaterialInstance — runtime material parameter driving
The MaterialInstance trait (Unity .material / Unreal Material Instance Dynamic) gives an entity a private, parameter-overridable view of its material whose params can be driven each frame — by Time, gameplay/store state, or a curve — or simply tweaked per-instance. It's the general, dependable replacement for one-off "drive a uniform from a bespoke system" hacks. Pure data: a list of overrides, each { target, kind, source }. Reference game: space-console's stripe shader; worked demo: games/3d-test/assets/scenes/material-instance-demo.json.
The core problem it solves. A plain ECS system runs with only world — it can't reach an entity's live THREE material (materials live in per-renderer RenderState.ecsObjects, and the editor runs TWO surfaces on one world). The material broker (materialBroker.ts) fixes that: each renderer publishes its RenderState + world; getEntityObjects(world,id) / getEntityMaterials(world,id) fan out over every surface. materialInstanceSystem (pipeline priority SYSTEM_PRIORITY.MATERIAL = 260, ≥ TRANSFORM so it keeps writing while paused) drives the overrides through it.
Two target kinds — pick by what the shader reads:
kind:'uniform'— a custom-shader TSL uniform. The value is written to every drawable object'suserData[target]; the shader's uniform reads it per-draw via.onObjectUpdate(({object}) => object.userData[target])(the three.js instance-uniform pattern — seeWoodNodeMaterial). One SHARED material yields independent per-entity values — no clone, no shader recompile. This is the cheap path and the one to use for custom shaders. A custom shader becomes driveable just by wiring its uniforms this way (seespace-console/stripes). NOTE: a custom shader whosefragmentNodehardcodes output (e.g.nprFragmentOutput(vec4(rgb,1))) ignores standard.color/.opacity/…— drive it with a uniform override, not a prop.kind:'prop'— a standard material property (color/opacity/roughness/metalness/emissive/emissiveIntensity, plus themap*Vector2 sub-propsmapOffsetX/Y+mapRepeatX/Yfor per-entity UV scroll/tiling). Requires a per-entity clone (mutating the shared cached material would hit every entity).materialInstanceClones.tsclones the material — the base is re-resolved from the entity's material GUID each frame (likeTint, viaresolveMaterial), NOT read offmesh.material, which is what makes it correct across both surfaces and across an async.mat.jsonload (one base per entity; never disposes a still-bound clone). Amap*driver clones the base texture once per material (material.clone()shares.mapby reference), flags it onuserData._miOwnsMap, and frees it with the clone.syncMaterial'sisInstancedguard (set for entities with a prop override) suppresses its per-frame "reset to base" so the clone survives;MaterialInstancetakes precedence overTint. Clones are freed at world swap (like Tint clones). Valid prop bases: an explicit.mat.jsonmaterial, or a baked multi-material array (per-slot clones, driven on every slot). A single default-material primitive is NOT a valid base (its material is recreated on canvas resize and owned per surface, so cloning it would leak a material+texture each resize) — give it a.mat.json, or driverend.color/ a custom uniform instead; it's skipped with a one-time dev warning. Custom-fragment shaders that hardcode output also ignore standard props (drive them with a uniform override).kind:'texture'(2D custom materials only) — a per-instance texture-param swap. Instead of asource, it carries aref(a sprite/texture GUID) that overrides aspace:'2d'shader's texture-param manifest default for THIS entity — so two entities sharing one material can bind different extra-sampler textures. It's STATIC (a ref, not a driven value — MaterialInstance sources are scalar-only), resolved + refcounted by the renderer (Scene2D.readTextureOverrides→ the extra-sampler path), and the scalar driver (materialInstanceSystem) ignores it (nosource). No 3D equivalent (a 3D texture param would need a material clone likekind:'prop').
Sources (MaterialParamSource): constant; time (session-relative, timeScale-aware, wrapped to dodge the float32 precision cliff — reproduces the stripe-shader lessons engine-wide, so pause freezes it and no game re-learns them); store (a live value from the read-source registry, getReadValue(key) — the same registry UI readSource bindings use — × scale, with an unscaled default fallback); and curve (samples the particle-Curve shape at a nested non-curve driver, e.g. a time driver with wrap:1 loops the curve once/sec). A malformed curve degrades to 0 rather than throwing; sceneValidation also validates a curve's nested shape.
Authoring. The Inspector renders a dedicated MaterialOverridesField (a 'materialOverrides' FieldType): per-row kind / target / source pickers, with target suggestion chips — standard props for prop, and the material's resolved shader uniform names for uniform. curve sources are shown read-only (author points/driver in the scene JSON).
Timeline keyframing. A material param CAN be keyed on the animation timeline: an override whose source is a constant exposes a nested-path track overrides.<i>.source.value (only constant sources — a time/store/curve source is procedurally driven and would fight the clip). Nested tracks flow through pathValue.ts (getPath/setPath, immutable — clones each node on the path so koota change-detection fires), and materialInstanceSystem re-reads the overrides every frame so a keyed value reaches the material. The track's field is a positional index into the overrides array (overrides.2.source.value), so reordering/removing overrides can leave a track pointing at a different (or absent) entry — setPath drops a write to a stale/out-of-range index rather than corrupting the array. (Unity's material-property tracks are positionally fragile in the same way.)
2D (PixiJS) materials — same trait, same sources. materialInstanceSystem drives the 2D layer too: an entity rendered through a custom 2D material (Renderable2D.material → a space:'2d'.shader.json; see 2D custom materials below) has a live per-entity Pixi Shader, and the SAME evalSource writes its uniform overrides into that shader's matUniforms group. The system checks the 2D layer FIRST (a material Renderable2D has no 3D broker presence, so the two paths are exclusive) and reaches the shader(s) via sprite2DMaterialBroker — the 2D twin of materialBroker, into which each live Scene2DRenderer registers its entityShaders map, so GameView + SceneView both get driven. 2D is uniform-only: PixiJS has no standard-material surface to clone, so a prop override on a 2D entity is a no-op + one-time warn. And because a source yields a single number, only a scalar (float) uniform can be driven — a vec/color uniform (a Float32Array) is skipped + warned (writing a number would NaN the whole vector). Determinism is identical to 3D: same clock keys, no wall-clock, so a time/store/curve-driven 2D shader is as reproducible as a 3D one. Authoring is the same MaterialOverridesField, which surfaces the 2D shader's params as uniform-target suggestion chips.
WebGPU Renderer
The 3D renderer is created by createRenderer(container, preferWebGPU) in scene3DSync.ts, which delegates to makeWebGPURenderer(container):
export async function createRenderer(
container: HTMLDivElement,
preferWebGPU: 'auto' | 'force' = 'auto',
): Promise<WebGPURenderer>- Always a
WebGPURenderer. There is no longer a legacyTHREE.WebGLRendererpath —makeWebGPURendereralways constructs aWebGPURenderer(fromthree/webgpu), and when WebGPU is unavailable it runs the same TSL/node pipeline on top of WebGL2 via the renderer's internalforceWebGLfallback. This is required for TSL post-processing (NPR) to work uniformly. preferWebGPUis a no-op. The parameter is retained for signature compatibility but ignored (void preferWebGPU) — both'auto'and'force'produce aWebGPURenderer.- Detection & backend selection.
getWebGPUSupported()(gpuDetect.ts) decides whether native WebGPU is available. The backend is driven bygetRenderSettings().three.backend(renderSettings.ts), not bypreferWebGPU:'webgl'forces the WebGL2 backend outright;'webgpu'/'auto'(the default) use native WebGPU when the device supports it, else fall back to WebGL2. If a native WebGPUinit()fails,makeWebGPURendererdisposes it and retries once withforceWebGL: true. - Async init.
await renderer.init()runs before the render loop starts.Scene3D.tsxgates everything behind thecreateRenderer(...).then(...)resolution and guards post-init teardown with adisposedflag (if the component unmounts before init resolves, the renderer is disposed immediately). - Tone mapping.
THREE.ACESFilmicToneMapping,toneMappingExposure = 1.2. - Pixel ratio.
setPixelRatio(Math.min(window.devicePixelRatio, 2))— capped at 2. - After init,
setActiveRenderer(r)is called soKTX2Loadercan detect GPU formats (see Materials & Textures).
GPU capability detection — gpuDetect.ts
getWebGPUSupported() is the single CACHED WebGPU probe, shared by BOTH renderers: the 3D WebGPURenderer backend pick (above) and the 2D Canvas2DPool's preference. It probes once via an inlined native check, probeWebGPU() (navigator.gpu.requestAdapter + requestDevice, mirroring what PixiJS's isWebGPUSupported did — inlined so this shared module carries no renderer-SDK dependency), and memoizes the boolean; getWebGPUSupportedSync() returns it (or null if not probed yet). A module-level FORCE_WEBGL constant forces WebGL everywhere for frame-pacing tests. (The related choice of KTX2 texture VARIANT — native-ASTC vs universal UASTC — is a SEPARATE GPU-format probe in textureResolver, not here; see Materials & Textures.)
Per-game preference
GameConfig.preferWebGPU?: 'auto' | 'force' (runtime/config.ts) is now vestigial. Scene3D.tsx still reads it from the active config and passes it to createRenderer, but createRenderer discards it — every game gets a WebGPURenderer (with WebGL2 fallback) regardless. Several games (including space-console) and the starter template still set preferWebGPU: 'force', and its NPR outline post-process still needs the WebGPU node pipeline, but the flag no longer changes behavior:
// games/space-console/runtime/config.ts
export const spaceConsoleConfig: GameConfig = {
name: 'Space Console',
// …
preferWebGPU: 'force', // no-op: kept for historical parity
};The GameConfig.preferWebGPU JSDoc in runtime/config.ts is likewise stale (still describes an 'auto' → legacy WebGLRenderer fallback).
No custom GLSL
Materials are standard Three.js materials (MeshStandardMaterial, GLB-imported materials, etc.). WebGPURenderer auto-converts them to TSL/WGSL — there is no hand-written shader source in the standard render path. The NPR post-process is the one place that authors node graphs, and it does so through TSL (plus one small raw-WGSL wgslFn for FXAA).
Post-Process Stack
All 3D post-processing runs through one composable chain, rendering/postfx/PostFXStack.ts. Effects are not mutually exclusive: NPRPostFX + BloomPostFX + VignettePostFX + DepthOfFieldPostFX + AmbientOcclusionPostFX can all be on at once. (Before the stack landed, Scene3D had two exclusive branches and "NPR wins, bloom is skipped" — that is gone.) WebGPU-only; on a WebGL2 fallback every effect is skipped and the render falls through to a plain renderer.render.
scenePass ── MRT: { output, [normal], [lineColor] } + depth (free)
│
├─▶ [NPR stylize] normal + lineColor + depth → stylized color
├─▶ [NPR particles] scene-injecting: particles drawn over the stylized buffer
├─▶ [AO] consumes depth + normal (see AO section — normal is NOT optional here)
├─▶ [DOF] consumes viewZ
├─▶ [Bloom] color only
├─▶ [Vignette] color only
└─▶ [FXAA] tail AA
│
▼
terminal RenderPipeline (outputColorTransform = true)The two invariants
- I1 — one terminal color transform. Every stage works in linear/working space. The stack's own
RenderPipelineis the sole terminal one and keepsoutputColorTransform = true(tone map + sRGB encode, applied exactly once). The NPR particle stage owns an internal pipeline and must therefore setoutputColorTransform = false. Getting this backwards double-encodes the frame (visibly washed out or crushed). - I2 — one canonical MRT layout. The scene pass's targets are the union of what the enabled stages need (
output, plusnormalfor NPR or AO, pluslineColorfor NPR only), computed once — never a per-effect target set. This matters because under MRT aNodeMaterialwhosefragmentNodewrites only target[0] has its draw silently discarded by WebGPU.
Planning is pure — rendering/postfx/stackPlan.ts
Zero three/TSL imports, so every decision is unit-testable with no GPU (tests/runtime/postfxStackPlan.test.ts). PostFXStack must not re-derive any of it:
| Function | Decides |
|---|---|
planStages(req) | The enabled stages in canonical order (NPR → NPR-particles → AO → DOF → bloom → vignette → FXAA), never the request's key order. |
requiredMrtTargets(req) | The minimal MRT union (I2). |
stackSignature(req) | Edge-trigger key, so a static scene does zero per-frame config work. |
needsRebuild(prev, next) | true iff the stage SET, the MRT layout, or an NPR structural field (superSampleScale, isOrthographic) changed. A param-only edit returns false → a live uniform write, not a shader recompile. |
planFxaaEnabled({...}) | FXAA's three preconditions: requested, not the WebGL2 backend, and superSampleScale === 1. |
Stage shapes
Most stages are pure color-node transforms (color in, color out). Two are not, and both are NPR's: the stylize stage reads extra MRT targets, and the particle stage is a real scene draw, not a filter — see the NPR section below.
Ordering deviations (deliberate — don't "fix" these)
- Vignette runs pre-tonemap. Most engines apply it inside the tonemapper; keeping it linear preserves I1, and a real lens vignette is pre-sensor.
- FXAA runs pre-tonemap, matching what NPR did historically. FXAA's luma heuristics assume gamma space, so this is a known, pre-existing compromise.
NPR Outline Post-Process
The engine ships a stylized cel/outline post-process that runs only on WebGPU. It is off by default and toggled by the NPRPostFX ECS trait. It is two stages of the post-FX stack above, not a standalone pipeline — so it composes with bloom/vignette/DOF.
Stages — postfx/PostFXStack.ts (+ npr/NPRPostProcess.ts)
There is no NPRPostProcess class. It used to own two RenderPipelines and was mutually exclusive with bloom; it is now the 'npr' and 'npr-particles' stages built inside PostFXStack.buildStage. npr/NPRPostProcess.ts survives as the NPR shared vocabulary only: the public nprFragmentOutput / applyNprFragmentOutput helpers games call from custom shaders, the ensureLineColorOnMaterials prototype patch, NPRConfig, and computeNprTexelSize.
Stage 1 — stylize. The stack's geometry pass(scene, camera) carries an MRT (setMRT(mrt({...}))) writing three targets:
output— lit scene color.normal— view-space normal (normalView).lineColor—vec4(materialReference('lineColor','color'), materialReference('nprColorPreserve','float')): the per-material outline color in RGB and the color-preserve amount in alpha.
The pass excludes PARTICLE_LAYER (particles must not be Sobel-outlined or grayscaled) and is supersampled by setResolutionScale(superSampleScale). buildCompositeNode turns those targets into a stylized color node, which flows on to the next stage.
Stage 2 — particles (npr/ParticlePassNode.ts). This one is a real scene draw, not a filter: it renders the particle layer with autoClear = false over a prefilled color + depth buffer, so it needs a concrete texture to prefill from. Rather than teach the generic stack about scene-injecting stages, the stage keeps that shape internally — its own RenderTarget plus an internal RenderPipeline (outputColorTransform = false, per I1) that renders everything upstream into the stylized texture — and hands the chain particlePass.getTextureNode(), a plain texture node every downstream stage filters like any other color. That is what lets bloom glow the stylized frame including its particles.
Edge detection — npr/edgeNodes.ts
Sobel edge detection, built as TSL node graphs over a shared 3×3 stencil:
sobelDepth(depthTextureNode, texelSize)— silhouettes; samples raw perspective depth and linearizes to view-space Z (perspectiveDepthToViewZ) so the threshold is scale-invariant.sobelNormal(normalTextureNode, texelSize)— creases; max magnitude over the X/Y/Z normal channels.sobelLuminance(colorTextureNode, texelSize)— texture/color seams; Sobel on Rec.709luminance.
Composite — npr/compositeNodes.ts
buildCompositeNode({ colorNode, normalNode, lineColorNode, depthTextureNode, uniforms }):
- Runs the three Sobels,
smoothsteps each over[threshold, threshold*2], and combines them withmaxinto a singleedgemask. - Builds the fill: flat white (
vec3(1.0)) or grayscale (lit luminance, gamma-remapped viagrayscaleGammaand lifted viagrayscaleLift), selected by thefillModeuniform. - Blends lines over the fill:
mix(fillKept, lineColor, edge * lineStrength). - A background mask (
step(0.5, length(normal))) keeps the camera'sclearColoroutside the silhouette and writesisForegroundinto the output alpha (transparent background for layered DOM).
nprFragmentOutput(colorRGBA, preserve?) is a helper for custom NodeMaterial shaders rendered into the NPR pass — it wraps a fragment color into an outputStruct that writes all three MRT targets (so WebGPU validation doesn't discard the draw for missing target outputs). Prefer applyNprFragmentOutput(mat, colorRGBA, preserve?) over calling this directly and assigning fragmentNode by hand — it also sets mat.fog = false, which fog-enabled scenes require (see "Fog" above, NPR interaction).
FXAA — npr/fxaaNode.ts
buildFXAANode(...) softens the hard black outlines. It is a self-contained raw-WGSL wgslFn (simplified FXAA 3.11) used because Three.js's built-in FXAANode trips a setLayout/Fn build bug on r183/r184.
It is now the stack's tail 'fxaa' stage, not part of NPR — NPR used to reason about "FXAA is the pipeline output", which is invalid once anything runs downstream of it. Because the wgslFn samples with textureSample, the stage needs a real texture node: it uses the incoming color directly when that is already one (the NPR particle texture, or a supersample RTT) and otherwise resolves the chain through an rtt() first. Its texel size is always derived at display resolution. Legality is decided by planFxaaEnabled — the NPRPostFX.fxaa field still owns the knob, but the stage is dropped on the WebGL2 backend (the wgslFn cannot compile there) and whenever superSampleScale > 1 (SSAA already covers it, at scale² cost).
Supersampling
superSampleScale (1 or 2) renders the MRT pass and the composite RTT at a higher internal resolution (setResolutionScale / setPixelRatio), reducing aliasing at silhouettes and creases before FXAA.
Rebuild rule:
superSampleScaleand the camera projection are structural — they resize every render target / swap the depth reconstructor, soneedsRebuildreports them and the driver mustdispose()+ reconstruct the stack. Every other NPR parameter is a cheap in-place uniform update. An SS-scale change is additionally debounced (npr/ssRebuildDebounce.ts): dragging the slider sweeps a new value almost every frame, and rebuilding per intermediate value thrashes shader compiles. The driver holds the request's scale pinned to the applied value until the target settles, and only then latches the signature — do not simplify this away.
Color preservation
The material property nprColorPreserve (0..1) lets a material keep its true hue through NPR. It's injected into the lineColor MRT target's alpha; the composite lerps the grayscale fill toward the lit scene color by that amount (mix(fill, sceneColor, preserve)). Outlines are still drawn on top at every preserve level.
Both lineColor and nprColorPreserve are auto-patched onto THREE.Material.prototype via Object.defineProperty (defaulting to black / 0), so materialReference(...) resolves for all materials — including GLB-imported ones — without patching every creation site. (The Tint trait sets nprColorPreserve on its tinted clones so the grayscale fill blends toward the team color.)
Control trait — runtime/traits/NPRPostFX.ts
NPRPostFX is a singleton ECS trait (first entity wins), editable in the Inspector. Defaults from the source:
| Field | Default | Meaning |
|---|---|---|
enabled | false | Master toggle; routes Scene3D through the composer when true. |
fillMode | 'grayscale' | 'flat' (white sheet) or 'grayscale' (lit luminance remap). |
depthThreshold | 0.005 | View-space depth Sobel threshold for silhouettes. |
normalThreshold | 0.4 | Normal Sobel threshold for crease edges (0..1). |
colorThreshold | 0.15 | Luminance Sobel threshold for texture/color edges (0..1). |
lineThickness | 1 | Sobel sample radius in pixels (1 or 2). |
lineStrength | 1 | Multiplier on the line mask before darkening the fill (0..1). |
grayscaleGamma | 0.7 | Luminance remap exponent (grayscale mode); <1 lifts midtones. |
grayscaleLift | 0.3 | Black lift in grayscale mode (0..1). |
fxaa | true | FXAA post-AA on the composite output. |
fxaaEdgeThreshold | 0.125 | FXAA relative-contrast threshold (typical 0.05–0.25). |
fxaaEdgeThresholdMin | 0.0312 | FXAA absolute luma floor — pixels below are flat. |
fxaaBlendStrength | 4.0 | FXAA blur strength on detected edges (typical 2–8). |
superSampleScale | 1 | MRT + composite supersample factor (1 = native, 2 = 4× pixels). Rebuilds the pipeline. |
Integration — Scene3D.tsx
There is one post-FX code path; NPR has no branch of its own.
- Each frame
Scene3Dreads theNPRPostFX,BloomPostFX,VignettePostFXandDepthOfFieldPostFXsingletons (first entity with the trait wins) and builds a singlePostFXRequest. NPR also feeds the active camera'sclearColorinto its stage config. - The stack is built lazily on the first frame
planStages(req)is non-empty and the renderer is WebGPU (renderer.isWebGPURenderer === true); otherwiserenderer.render(scene, activeCamera). - Turning every trait off keeps the stack alive but routes around it, so toggling stays cheap.
setConfig()applies cheap uniform updates; atruereturn disposes and rebuilds. A camera-object swap (perspective ↔ ortho) also forces a rebuild.- The request is edge-triggered on
stackSignature, so a static scene does no per-frame config work. - Resizing needs no post-FX call: every resolution-derived uniform (NPR + FXAA texel size, the stylized RT size) is recomputed from the live drawing buffer in each stage's per-frame prologue.
Gotcha: TSL first-compile race (prewarm)
TSL node builders have a racy lazy initialization on the first compile a renderer ever performs. If an MRT/NPR pass happens to be that first compile — e.g. in the lazily-mounted editor Game panel, which mounts after the initial scene swap so the normal pre-swap prewarm hook never fired — WGSL generation can intermittently fail with unresolved type 'OutputType' and the mesh is dropped.
Fix: Scene3D.tsx calls prewarmShadersForWorld(getCurrentWorld(), renderer, camera) on mount, before registering the render loop, so a normal material compiles first and primes the node builder. (prewarmShadersForWorld also mirrors the world's lights and environment so it compiles the correct PBR shader variants, eliminating first-frame stutter on scene swap.)
Related HMR caveat — editing a shader module forces a RELOAD, automatically. TSL node (and wgslFn) instances get baked into compiled WGSL pipelines; hot-reloading a module creates new node identities that the old cached pipeline still references, raising the same unresolved type 'OutputType' error — or, worse, silently keeps rendering the PREVIOUSLY compiled graph so a correct fix looks like it did nothing. A full page reload is the correct (and cheap) price for a stable cache.
The reload is now decided by path on the dev server: isShaderGraphFile (engine/plugins/vite-asset-scanner.ts) matches anything under runtime/rendering/postfx/ or runtime/rendering/npr/, and handleHotUpdate sends modoki:shader-code-changed instead of letting Vite propagate an update. The renderer (engine/app/debug/hmrStaleness.ts) then reloads — via the same unsaved-scene countdown banner the game-code reload uses, so a shader edit can never silently discard scene work. ⚠️ Do NOT re-add import.meta.hot.invalidate() to these modules (they all used to carry it): invalidate() does not force a reload, it propagates to importers and stops at the first one that ACCEPTS — and the only importer is Scene3D.tsx, a React Fast Refresh boundary that self-accepts, so it was silently swallowed. Fast Refresh then re-ran the component but not its []-deps effect, leaving the already-built PostFXStack (and its stale compiled graph) alive. That is exactly how one DOF viewZ fix was concluded "didn't work" three separate times. Since engine/plugins/** is not hot-reloadable, restart the editor once after changing the rule itself.
Bloom Post-Process
A reusable whole-scene HDR bloom, added for demos/particle-demo's dark-VFX showreel but not specific to it — any 3D scene can add the trait. WebGPU-only, off by default, toggled by the BloomPostFX ECS trait. It is a stage of the post-FX stack, so it composes with NPR, vignette and DOF rather than being an alternative to them.
Control trait — runtime/traits/BloomPostFX.ts
Singleton (first entity wins), editable in the Inspector:
| Field | Default | Meaning |
|---|---|---|
enabled | false | Master toggle. |
strength | 0.8 | Glow intensity (typical 0.3–1.5). |
radius | 0.6 | Blur spread (0..1). |
threshold | 0.0 | Only pixels brighter than this bloom; 0 blooms the whole scene (the right value on a near-black void, where bloom itself acts as the key light). |
The stage
There is no BloomPostProcess class — it was replaced by the stack. The 'bloom' stage is a few lines of PostFXStack.buildStage: bloom(color, strength, radius, threshold) (from three/examples/jsm/tsl/display/BloomNode.js) builds the glow node and add(color, bloomPass) composites it. bloom() returns a Node class whose strength/radius/threshold are live uniforms, so setConfig is a uniform write and never a rebuild.
All three tunables update live. Particles ride PARTICLE_LAYER, which the camera already enables, so a whole-scene bloom includes them — this is what makes additive particle effects read as the scene's only light source. Under NPR the particles arrive via the NPR particle stage instead, and bloom still sees them.
- NPR + bloom compose. Bloom operates on the working-space stylized color, so a stylized scene gets a real glow. (This was the exclusivity the post-FX stack existed to remove.)
- WebGL fallback: gated on
isWebGPU; on a WebGL2 fallback the render falls through to the plain path (no bloom, no error). - Same shader-HMR / prewarm-race caveats as NPR (see above) — TSL bakes into WGSL, so the stack is disposed on camera-projection swap and never hot-reloaded in place; editing anything under
postfx/forces a full reload from the dev server.
Vignette & Depth of Field
Two more stack stages, each with its own singleton trait (VignettePostFX {enabled, intensity, smoothness}, DepthOfFieldPostFX{enabled, focusDistance, focalLength, bokehScale}), both WebGPU-only and off by default.
- Vignette uses
vignette()fromthree/examples/jsm/tsl/display/CRT.js, which is a bare TSLFn, not a Node class — so the stage must passuniform()nodes forintensity/smoothness, or the values freeze at graph-build time andsetConfigcan never reach them. - DOF uses
dof(color, viewZ, ...). ⚠️ ItsviewZmust not come fromPassNode.getViewZNode(), which hardcodesperspectiveDepthToViewZwith no ortho branch —postfx/dofViewZ.tspicks the matching reconstructor from the camera's projection instead (tests/runtime/dofViewZ.test.tspins it).
Ambient Occlusion (GTAO)
AmbientOcclusionPostFX {enabled, radius, intensity}, WebGPU-only, off by default. Uses ao(depthNode, normalNode, camera) from three/examples/jsm/tsl/display/GTAONode.js, which returns a GTAONode; its output texture's .r (raw 0..1 occlusion) is lerped toward 1 by intensity (GTAO has no strength knob of its own) and multiplied into the incoming color.
⚠️ Always passes a REAL normal buffer — the "nullable normalNode" cheap path is broken here.ao()'s normalNode argument is documented as nullable (GTAO reconstructs normals from depth when it's null), and that was the original plan. It doesn't work under this renderer: getNormalFromDepth's null-normal fallback compiles textureDimensions(depthTex, 0), but the depth attachment is multisampled, and WGSL's multisampled-texture overload of textureDimensions takes no level argument — confirmed via the browser's native WGSL compiler diagnostic (THREE.[Invalid ShaderModule "fragment_GTAO"], root cause no matching call to 'textureDimensions(texture_depth_multisampled_2d, abstract-int)'), not a wiring mistake. So requiredMrtTargets forces the 'normal' MRT target for AO too — the same target NPR already forces, just triggered by one more gate (never lineColor, which stays NPR-only). When NPR is also enabled they share the same normal texture node; AO alone still costs the extra MRT target.
⚠️ Same silently-discarded-draw hazard as NPR (I2): a custom-shader NodeMaterial combined with AO on the (previously MRT-free) plain path must emit both MRT targets or its draw is dropped. Inert today — no shipped game enables AmbientOcclusionPostFX.
2D Rendering (PixiJS)
The 2d layer draws Renderable2D (and Text2D / SkinnedSprite2D) entities with PixiJS v8, into one or more Canvas2D host entities. Scene2D.tsx owns the pass; render2DUtils.ts holds the shape / pivot / scale math shared with the editor's Canvas2D preview so the two can't drift (guarded by tests/runtime/render2DParity.test.ts).
Canvas2D host + design-resolution scaler
A Canvas2D entity (traits/Canvas2D.ts) marks a UI element that hosts a PixiJS <canvas>; every Renderable2D descendant renders into its NEAREST Canvas2D ancestor (canvas2DRouting.ts findCanvasAncestor — a cycle-guarded walk up EntityAttributes.parentId; an entity that IS a Canvas2D resolves to itself). Content is authored at a design resolution (referenceWidth×referenceHeight, default 1080×1920) and mapped onto the live canvas pixels by canvas2DScaler.ts computeCanvasScale(refW, refH, actualW, actualH, mode):
GOTCHA — a Canvas2D host MUST be a UI node, or its
<canvas>never mounts (silent black). The pooled PixiJS canvas is attached to the DOM byCanvas2DMount, which theUIRendererrenders only for entities that appear in the UI tree — i.e. entities that carryUIElement(+RenderableUI, and normallyUIAnchor). A bare Canvas2D entity (justCanvas2D+EntityAttributes) is NOT a UI node, so noCanvas2DMountis created, no canvas mounts, and everyRenderable2Dunder it draws to nothing — with no error in either viewport. Always give a Canvas2D host the full UI-node trait set:RenderableUI+UIAnchor {anchor:'stretch'}+UIElement {width:100%, height:100%}+Canvas2D(this is exactly what the editor's Create ▸ Canvas2D /canvas2DSpecsproduces — never hand-author a Canvas2D without them). TheRenderable2Dchildren themselves need onlyTransform+Renderable2D(+EntityAttributes), positioned in the host's design-resolution space.
scaleMode | Behaviour |
|---|---|
fitW | Match width exactly (the other axis may crop / letterbox). |
fitH | Match height exactly (default). |
contain | Uniform scale to fit ENTIRELY inside (letterbox the excess axis). |
cover | Uniform scale to COVER the area (crop the overflow axis). |
fill | Non-uniform stretch to fill exactly (no crop, no letterbox). |
none | 1:1 pixels. |
Every mode CENTERS the content (via offsetX/offsetY). fill stretches non-uniformly, so the scaler also returns compensateX/compensateY (= uniformScale / axisScale) which Scene2D multiplies back onto each object's scale so PRIMITIVE SHAPES stay un-stretched even while the container fills. screenToReference2D inverts the mapping for 2D picking (client px → reference space), shared by the DOM SceneView layer and the Pixi pick overlay so both pick identically.
Primitives, sprites, tint
Renderable2D.sprite selects the display kind:
- Primitive keyword —
square/triangle/circle(empty ⇒ circle) → a PixiJSGraphicstinted byRenderable2D.color, vertices fromcomputeShapeGeometry(render2DUtils.ts). - Image ref (GUID / path / URL) → a PixiJS
Sprite; textures load async through the GLOBALAssetscache (KTX2 decoded for the 2D path — see Materials & Textures) and are preloaded before a scene swap so there's no pop-in. A sliced sprite / atlas frame gets a per-slot framed Texture WRAPPER (sub-rect of the shared source); a sprite-sheet frame swap that keeps the same base texture swaps the sub-rect IN PLACE (no texture-unload churn). collidersentinel → draws the entity's OWNCollider2Dshape as a filled (open polyline: stroked) body — for polygon/polyline/concave colliders that have no primitive form.
Shared placement knobs: width/height (half-extents), pivotX/pivotY (0 = edge, 0.5 = center), keepAspect (uniform sprite scale = min(scaleX, scaleY)), flipX/flipY (render-only mirror about the pivot — a sign flip on scale that never touches the transform, mirrors no children, and is invisible to the physics collider), opacity (alpha), and isVisible (per-renderer hide, ANDed with the entity's isActive).
Blend mode. Renderable2D.blendMode (normal | add | multiply | screen, default normal) sets the Pixi compositing mode on both the sprite and primitive paths — add gives an additive glow (on dark backdrops) with zero shader work. Mapped through the shared pixiBlendMode2D guard in render2DUtils.ts (an unknown/legacy value coerces to normal); also applied to the material path below.
2D custom materials (PixiJS shaders)
Renderable2D.material (empty by default) points at a space:'2d' .shader.json — a custom fragment shader that draws the entity instead of the default tint/texture path. It's the 2D twin of the 3D shader:'file' material, built for PixiJS rather than Three, and its uniforms are driven at runtime by MaterialInstance.
- Asset shape. A
.shader.jsonwithspace:'2d'+ aparamsblock, plus sibling<name>.wgsl/<name>.glslbodies (Pixi v8 is WebGPU-preferred, so both backends ship). The body is a fragment MAIN snippet that writesoutColor(a premultiplied vec4; the base high-shader multiplies it byvColor= the mesh tint/alpha). Available in the body:vUV(the texture-space UV — 0..1 for a whole-image sprite, the atlas sub-rect for a slice),uTexture/uSampler(the sampled texture), and the params as a uniform block — WGSLmatUniforms.<param>, GLSL loose<param>. One configured shader = one material (v1); multiple looks = multiple assets. - Builder —
pixiShaderBuilder.tsgeneralizes the MTSDF text shader (mtsdfPixiShader.ts): it composes Pixi's own high-shader bits (localUniformBittransform,textureBitsampler,roundPixelsBit) + ONE generated custom bit that declares the uniform block (a WGSLstruct MatUniformsat@group(3), or GLSL loose uniforms) and splices the authored body — so the engine owns only the fragment maths. It compiles only the active backend's program (resolved by the sharedcanvas2DPool.resolvePixiBackend, honoring thepixi.backendoverride so the program always matches the live renderer), once per asset; each entity mints its OWNShader(its ownUniformGroup) so uniforms are per-entity. Reserved-name guard: a param keyed like a Pixi built-in (uColor/uTexture/uResolution/…) is rejected at build + validation (it would break the WebGL fallback where uniforms are loose globals). - Cache —
spriteMaterialCache.tsresolves a material GUID → compiled program, lazily and deduped (a failed compile is marked so it isn't retried every frame). World-lifecycle: cleared unconditionally on world swap / teardown (a compiled program holds no GPU memory of its own — Pixi caches the underlying programs by source and each live per-entityShaderholds its own reference — so a clear only empties the maps, and clearing on swap is what makes an edited.shader.jsonrecompile on hot-reload). The GUID is a scene resource (type:'shader', no-op acquire — tree-shaker keep; the.wgsl/.glslsiblings are kept by the shader-manifest sweep). - Rendering — Scene2D draws a material entity in a SEPARATE pass (like the skinned/text passes, so it can't destabilize the sprite change-detection) as a
Mesh: a pivot quad (buildMaterialQuad, sized like a primitive) + the per-entityShader, withblendMode/tint/alpha/transform/paint applied. The sprite pass skips a material entity once its program is ready and falls back to the default sprite/tint while it loads (anonReadywake re-renders when the async compile lands, even while the sim is stopped). Each entity'sShaderis registered in a Scene2D-ownedentityShadersmap (published viasprite2DMaterialBrokerfor the driver) and disposed with its slot. - Redraw gate (
MaterialSnap) — a material's uniforms are usually the only thing that moves per frame, and the driver writes them straight into theUniformGroupwith no render-visible signal, so the pass can't tell a changed frame from a static one on its own. Rather than force a GPU pass every running frame, the material pass dirties its canvas only when (a) theMeshwas just (re)built, (b) an external edit/load/swap forced it, (c) the placement/appearance moved vs a per-entityMaterialSnap, or (d) a driver wrote a NEW uniform value this frame:materialInstanceSystemcompares-before-write and, on an actual change, flags the entity throughsprite2DMaterialBroker(a per-frame set it clears at the top of its pass, at ECS priority — before the render passes read it). So an animating material still redraws each frame, but a static-uniform one (no driver, a constant curve, or a stopped clock) costs zero redraws once settled. - Sampling the sprite bitmap. The material Mesh samples the entity's OWN
Renderable2D.spriteasuTexture(resolveMaterialTextureresolves the GUID and loads it through the sharedspriteTextureRefsrefcount, exactly like the sprite pass — retained on build, released indisposeSlot). While the texture loads — or when the entity has no image sprite (a purely procedural shader likegradient-scroll) — it falls back toTexture.WHITE; the resolved url is part of the slot's rebuild signature (matSig), so the Mesh re-mints with the real bitmap the frame it becomes resident. A texture is only bound once itssourceis live (a cached-but-mid-decode/stale texture would otherwise crash the shader onsource.style). Example:games/3d-test/.../shaders/dissolve.{shader.json,wgsl,glsl}(samplesuTexture, burns it away by a hashed-noiseuThreshold), driven by a MaterialInstancetimecurve — demo scenegames/3d-test/.../scenes/2d-material-demo.json. An atlas slice (aresolved.frame) binds a per-slot framed WRAPPER Texture whose uv matrix (uTextureMatrix= the texture'smapCoord) maps the quad's 0..1 UVs into the sub-rect, so the shader samples the right pixels (a whole image borrows the base texture, identity matrix); the wrapper isdestroy(false)d indisposeSlot(source kept for the refcount).matSigcarries the sprite REF so a frame swap on one sheet forces a rebuild.vUVis therefore texture-space (0..1 whole, sub-rect for a slice). - Extra samplers (
textureparams). A shader can declaretexture-typed params — each becomes an ADDITIONAL sampler beyond the entity's ownuTexture. A texture param's VALUE is its manifestdefault(a sprite GUID) OR a per-instanceMaterialInstanceoverride withkind:'texture'+ arefon that target (a STATIC swap — MaterialInstance sources drive only scalar uniforms, so a texture ref isn't animated;readTextureOverridescollects them, the override wins over the default, and the resolved url is inmatSigso an inspector edit rebuilds the Mesh with the new texture). Scene2D resolves each WHOLE-image through the samespriteTextureRefsrefcount + KTX2/WebP variant seam as the sprite (resolveMaterialTexture(ref, wholeOnly)), retains each url on build (stored inslot.materialTexUrls), releases them indisposeSlot, and binds them inmakePixiShaderInstance. An unresolved extra texture bindsTexture.WHITE(WebGPU needs every declared group-3 binding present) andmatSig'sextraSigforces exactly one rebuild when it lands. WGSL binding: the custom bit declares extra textures in@group(3)at binding1+2i(texture) /2+2i(<key>Smpsampler) — binding 0 stays reserved formatUniforms— so a texture paramuFoois sampledtextureSample(uFoo, uFooSmp, vUV)(WGSL) ortexture(uFoo, vUV)(GLSL). Extra textures are whole-image (no atlas sub-rect) and sampled at the sprite-spacevUV. Authoring footgun: never write@group(N)/@binding(N)in a WGSL body COMMENT — Pixi'sextractStructAndGroupsregex only skips a decorator when the char before@is/, so// @group(3) … ;(space after//) is parsed as a real binding and silently fails the whole material. Example:games/3d-test/.../shaders/reveal.{shader.json,wgsl,glsl}(cross-fades the sprite with a Metal texture bound touReveal, mix driven by a MaterialInstance) in2d-material-demo.json. Build: the asset tree-shaker keeps extra-sampler textures in prod —processShaderfollows a 2D shader'stexture-paramdefaultGUIDs andprobeTraitRefsfollowsMaterialInstancekind:'texture'override refs (both were previously shaken out → a 404 in prod). Runtime gap: like every 2D texture, an extra sampler loads LAZILY (one-frame pop-in) — not scene-pre-acquired; the sceneresourcesmanifest lists override refs (viacollectResourceRefsFromEntities) but not the async shader-manifest defaults. Scalar-VALUE overrides remain uniform-only and scalar-only (see the MaterialInstance section); akind:'texture'override is the only non-scalar override kind, and it's 2D-only.
Paint order
paintOrder.ts computePaintOrder is the single stacking source shared with the editor SceneView: a depth-first walk of the hierarchy by EntityAttributes.sortOrder (lower = painted first / furthest back; last-visited on top), assigned to each object's Pixi zIndex (slot containers are sortableChildren, set ONCE at slot creation). Renderable2D.orderInLayer / Text2D.orderInLayer (Unity "Order in Layer") RE-RANKS globally — higher = on top, independent of tree position, with the hierarchy DFS index as the stable tiebreak — so a cut-out character's parts parented to scattered bones can stack by an explicit layer order.
Per-viewport instancing
The pass is a Scene2DRenderer CLASS (not a singleton): a Pixi display object and a <canvas> can each live in only ONE place, so each viewport rendering the same world — the runtime / GameView vs the editor SceneView — owns its OWN renderer, display objects, dirty state, particle state, and Canvas2DPool. A module-level defaultRenderer on the defaultPool backs the free-function exports so runtime + GameView stay byte-identical. The PRIMARY (runtime) renderer owns process-wide registrations — the layout-bounds provider, the prewarm-before-swap hook, and the unloadAllSpriteTextures net; a non-primary (editor) renderer skips all three so it never tears texture accounting out from under GameView. The Assets decoded-image cache + its refcount (spriteTextureRefs) are GLOBAL across renderers — a per-viewport count would Assets.unload() a texture another viewport still shows.
Dirty gating
renderFrame used to re-tessellate + GPU-render every Canvas2D every frame; a two-tier gate fixes that:
- Idle whole-frame skip — while the sim is stopped / paused, 2D only changes via paths that set
_externalDirty(editor edits, async texture loads, canvas resizes, world swaps, play-state changes), so idle + clean ⇒ no ECS scan, no render. - Per-entity change detection — a
RenderSnap/MeshSnap/TextSnapper entity captures the exact inputs that determine its output; only Canvas2D hosts with a CHANGED entity are GPU-rendered (dirtyCanvases→pool.renderAll(dirtyIds)).preserveDrawingBuffer: truekeeps a skipped canvas's last frame on screen across a browser recomposite (scroll, ancestor transform, tab refocus).
Canvas2D Application pool + GPU-context budget
canvas2DPool.ts pools one PixiJS Application (+ <canvas> + root Container) per Canvas2D entity, backgroundAlpha: 0 for transparency over the 3D layer. Each INITIALIZED slot = one live GPU context; browsers cap live WebGL contexts (~8–16) and evict the oldest past that, so the pool tracks a cross-pool live-context count and warns ONCE past a soft limit of 8 (SOFT_CONTEXT_LIMIT — catching a slot leak or an unusually context-heavy scene before the browser silently drops a context), and caps slots at MAX_SLOTS = 6. Real scenes use 1–2 canvases.
A slot has TWO independent claims and is reclaimable only when BOTH drop:
boundBySim— Scene2D's claim: the Canvas2D entity is present in the world (allocate/release).mounted— Canvas2DMount's claim: the slot's<canvas>is in the DOM (mount/unmount).
Reclaiming only when both clear stops mount/unmount churn from leaking slots AND stops slot reuse from destroying the WebGL context behind a still-visible canvas; entityId === null is the canonical "unclaimed" marker. The pool DETACHES children on reclaim but never destroys them — Scene2D owns display-object destruction + texture-refcount release (destroying in both places would double-free). renderAll swallows a transient teardown-race throw (a canvas losing its context mid-swap) silently and only warns after 30 consecutive stuck frames.
2D SDF text (MTSDF)
Text2D (traits/Text2D.ts) renders as a PixiJS mesh using the SAME MSDF/MTSDF font atlas + effect maths as the 3D Text3D, so 2D and 3D text look identical. Three pieces:
- Layout —
text/layoutText.ts(layoutText) is pure + headless: a string + a synchronous glyph source → positioned textured quads in px, Y-down, block-local space (origin = top-left of the text box). Handles hard\nbreaks + greedy word wrap (maxWidth), per-line kerning,align(left/center/right),lineSpacing,letterSpacing, and a fallback advance for a not-yet-generated glyph. Each quad carries its atlaspage, so the geometry builder groups quads by page. It's the single geometry source BOTH text paths feed. - Shader —
text/mtsdfPixiShader.ts(makeMtsdfPixiShader) composes Pixi's own high-shader BITS (localUniformBittransform,textureBitatlas sampler,roundPixelsBit) with ONE custommtsdfBitthat overrides the fragment colour — reusing Pixi's per-backend transform boilerplate and shipping BOTH WGSL and GLSL programs (Pixi v8 is WebGPU-preferred). The fragment maths mirrors the 3D TSL graph 1:1: median (sharp) fill, outline via the median, alpha-SDF glow, offset-sample shadow,screenPxRangeAA viafwidth, composited straight-alpha. Style uniforms (weight/ outline / glow / shadow) update in place (updateMtsdfPixiStyle); a per-glyphaTextColorvertex attribute (for rainbow/fade colour animation) premultiplies onto Pixi's built-invColor. - Per-page meshes + dynamic packing — one Pixi
Meshper atlas PAGE the text touches (a dynamic CJK provider spills glyphs across pages; a baked / single-page font is one mesh), all children of the slotContainerso the anchor pivot + transform apply to the whole block. Geometry rebuilds only when the layout hash changes (text/font/size/wrap/spacing/atlasVersion); the shader updates only on a style-hash change; placement writes only when the transform moves. Atlas textures are FONT-owned (freed on scene teardown), never disposed by the slot. Per-glyph animation recomputes page positions from the base quads each frame while the sim runs (frozen when stopped, like skeletal animation).
Related
- Materials & Textures —
.mat.jsonresolution, the KTX2 texture pipeline + variant selection, MSDF font atlases. - UI System — the
ui(React DOM) layer. - 2D Skinning —
SkinnedSprite2Ddeformable meshes + theBillboard3D/FlatSprite3D2.5D bridge. - Model Import Pipeline — GLB →
.mesh.json/.mat.json, LODs, rigged models. - Architecture — the frame driver, scene-scoped resource refcounting (mesh / material / env caches).