Materials and the render graph

This guide covers the material family that ships today, how a scene becomes draw calls (render lists, §64–§66), and — honestly — how much of §57's unified material model and §63's render graph exists at the tip. Short version (rewritten 2026-08-29; the previous one predated every landing below and still called PBR, shadows and the render graph "staged"): the §57 Material base is complete, five material classes ship — §59's metallic-roughness StandardMaterial and §60's graph-driven NodeMaterial (RFC 0001) included — the §63 render graph runs linear scene, custom and §70 effect passes, §68 lighting covers a directional light plus up to eight punctual lights with one §69 shadow tier, and two GPU backends (WebGL 2 and WebGPU) consume the same render list.

The shipped materials

Five material classes (four/materials) over a §57-complete Material base, plus one instanced path for particles. This section said "two material classes" and omitted LitMaterial until 2026-08-05, and "three" until 2026-08-29 — StandardMaterial (R-13, 2026-08-08) and NodeMaterial (RFC 0001, 2026-08-28) had landed:

import { SpriteMaterial, UnlitMaterial } from "fourJS/materials";
import { Renderable, Sprite, Texture } from "fourJS/render";
import { planeGeometry } from "fourJS/geometry";

// Flat colour, straight (non-premultiplied) RGBA in 0..1 (§60a, §66):
const slab = new Renderable(
  planeGeometry({ width: 2, height: 1 }),
  new UnlitMaterial({ color: [0.16, 0.18, 0.24, 1] }),
);

// Recolour in place; setColor announces the change:
slab.material.setColor(0.1, 0.52, 0.45, 1);

// A textured quad. The texture is an RGBA8 buffer you own:
const texture = new Texture({ width: 6, height: 12, data: rgbaBytes });
const badge = new Sprite(new SpriteMaterial({ texture, tint: [1, 1, 1, 1] }), {
  width: 0.5,
  height: 1,
  anchor: { x: 0, y: 0 },
  renderLayer: 1, // draw after opaque shapes — sprites blend (§66)
});

The family, member by member:

The base itself carries §57's shared render state on every member — opacity, transparent, blendMode ("normal", "additive", "multiply", "screen"), depthTest, depthWrite, colorWrite, and stencil (R-7, 2026-08-11) — with defaults that reproduce the pre-base frame byte-for-byte.

Particles use ParticleRenderable (see the performance guide); geometry comes from four/geometry's boxGeometry, planeGeometry, and circleGeometry2D.

On WebGL 2, four of the backend's pipelines are registration seams (2026-09-11, the registerSkinningPipeline() shape): call registerShadowPipeline() before a light's castShadow can produce a map, registerEffectPipeline() before a RenderGraph copy / grade / output-transform pass will draw, registerParticlePipeline() before a ParticleRenderable is drawn, and registerStandardPipeline() before a StandardMaterial surface is drawn (an owner decision — §59 is a core family, and the seam still pays for itself in every bundle that shades with LitMaterial alone). Each is one explicit call at application setup, never an import side effect; an unregistered feature is skipped with one development warning naming the call (shadows fall back to unshadowed lighting; effects, particles and standard surfaces to absence — never a Lambert stand-in), and a bundle that never calls one carries none of that pipeline. §60 graph effects go through registerNodeMaterialPipeline() instead. WebGPU compiles all four eagerly.

import {
  registerEffectPipeline,
  registerParticlePipeline,
  registerShadowPipeline,
  registerStandardPipeline,
  registerWebglRenderer,
} from "fourJS/render-webgl";

registerWebglRenderer();
registerShadowPipeline(); // §69: the depth-only caster pass
registerEffectPipeline(); // §70: copy, colour grade, output transform
registerParticlePipeline(); // §36: instanced billboards, trails, R-32 appearance
registerStandardPipeline(); // §59: the metallic-roughness surface

Facts of this tier worth knowing before you fight them:

From scene to draw calls (§64–§66)

There is no retained display list to manage. Each frame the renderer walks the scene into a sorted render list:

import { buildInterpolatedRenderList, buildRenderList } from "fourJS/render";

const list = buildRenderList(scene, []); // live transforms
buildInterpolatedRenderList(scene, poses, alpha, list); // §43 render poses

For a skinned mesh the same call also refreshes the joint palette. Local poses interpolate (position lerp, rotation slerp), then Skeleton.update(skinRoot, worldOf) runs the palette product inverse(skinRootWorld) · boneWorld · inverseBind[i]. Palettes are never matrix-lerped — a 90° joint at alpha = 0.5 is a 45° slerp, not the average of two matrices — and nothing is written back into node.transform (§42). The live-transform path (buildRenderList) calls update(skinRoot) with no provider. See fixed-step simulation for the pose buffer, and tests/integration/interpolated-skin-palettes.test.ts for the two-bone consumer check.

Items sort by §66's keys 1, 2 and 5 — renderLayer, then opaque before transparent (the material's transparent flag, since 2026-08-06), then renderOrder — and because Array.prototype.sort is stable, equal keys fall back to generation (scene-graph) order: later siblings draw on top. §66's other two keys ship as separate, opt-in verbs, for the recorded reason that both permute co-planar draws a 2D scene depends on (render-list.ts): key 3 (pipeline/material grouping) is groupRenderListByPipeline, and key 4 (depth) is sortRenderListByDepth on the per-view derived list (view-list.ts, R-8, 2026-08-09 — which is also where frustum culling landed). This paragraph claimed a default sort "by render layer, then kind (opaque unlit first, then blended sprites, then particles), then material" until 2026-08-05; no such comparator has ever existed, and until 2026-08-29 the correction overshot by claiming the sort was renderLayer/renderOrder "and nothing else". The backend does track the _current_ pipeline and only re-binds a program when the kind changes — a per-run state-change saving on an already-ordered list, not a sort.

Two GPU backends consume the list (this paragraph named only one until 2026-08-29): the WebGL 2 backend (WebglRenderer, four/render-webgl) and the WebGPU backend (WebgpuRenderer, four/render-webgpu), both with cached GPU resources keyed by object identity and version; a particle item becomes exactly one instanced draw whatever its count.

Renderer (§61) is the backend-independent interface; NullRenderer is the headless implementation the determinism suites use. A backend registers itself into @fourjs/render's §62 registry only when the application calls its register*Renderer() — four/application itself imports no backend at runtime, which is what keeps headless bundles free of GL (R-2/A-8).

Honest state of the rest

§featurestate
§57–§59unified material model, PBR§57 base complete (seven members; stencil joined 2026-08-11, R-7) under five shipped classes, §59's metallic-roughness StandardMaterial among them (R-13; normalMap/occlusionMap staged). PhysicalMaterial and §59's physical extensions are not implemented; ShaderMaterial is permanently unshipped (rev 1.11). This row said "not implemented" beyond three classes until 2026-08-29
§60shader & node-material systemshipped (RFC 0001, 2026-08-28): the ShaderGraph IR + analyzeShaderGraph, NodeMaterialBuilder, and NodeMaterial; the GLSL emitter registers via registerNodeMaterialPipeline() and the WGSL emitter via registerWebgpuNodeMaterialPipeline() (WP-R1.9, 2026-08-29). Unlit at this tier (sequenced R-14 → R-17 → R-13) — see custom shaders. This row said "not implemented" until 2026-08-29
§62backendsWebGL 2 shipped; render-webgpu shipped — the R-1 plan is complete (WP-R1.1–R1.9, 2026-08-21…29), behind registerWebgpuRenderer(), with two honest absences (RFC 0003's skinned pipelines, §71 picking); render-canvas and render-svg remain reserved stubs. This row called render-webgpu "scaffold-only" until 2026-08-29 — stale since WP-R1.1 landed 2026-08-21
§63render graphshipped at the linear-pass tier 2026-08-07 (RenderGraph in @fourjs/render: named passes over R-4's target seam, declared inputs + discovered sampled-target validation, enable/disable, per-pass viewports, textual describe()). Transient targets, resource lifetimes, and barriers are staged with dated reasons in the module header. This row said "not implemented; the fixed pipeline is list → sort → draw" until 2026-08-07; the fixed pipeline is still what one pass runs
§65batchingparticles are instanced (one draw per system); sprite and compatible-shape batching shipped opt-in (R-9, 2026-08-09): renderer.batching = createGlBatching() (or createWgpuBatching(), WP-R1.3) merges consecutive draws sharing a pipeline (unlit/sprite) and a material instance into one draw — without the opt-in it stays one draw call per sprite. Instanced meshes for the shaded pipelines are staged (batch.ts). This row said "nothing else is batched" until 2026-08-29
§68–§70lighting, shadows, post-processinglighting: directional + ambient (2026-08-04) plus up to eight punctual point/spot lights (R-17, 2026-08-09) plus one hemisphere (2026-09-10; area/IBL/layers staged, lights.ts). Shadows (§69): one tier shipped — the directional light's depth-only shadow map with 3×3 PCF, on both GPU backends (R-18, 2026-08-09; on WebGL 2 behind registerShadowPipeline() since 2026-09-11). Post-processing (§70): shipped as RenderGraph effect passes — copy, colour grade, the §60a sRGB output transform (R-6/R-15; on WebGL 2 behind registerEffectPipeline() since 2026-09-11), and §60 graph effects (RFC 0001). This row said shadows and post-processing were "not implemented" until 2026-08-29

When the staged remainders land they are required to slot beneath the same Renderer interface and render-list contract, so scene code written against today's tier does not change.

Practical guidance

Cross-references