Summary
vgpu/scene no longer has a concept of "material". Everything that used to be material — shader + pipeline state + bindings — is now a program. This issue is the full contract for program(), its low-level escape hatch (SCENE_PROGRAM), and the core-ABI split that lighting requires.
See the parent issue for the full boundary/context; this issue is the leaf spec for the program protocol.
program() — two overloads
import { program } from "vgpu/scene";
// STATIC (90% of cases): description is known upfront, independent of geometry.
const rockProgram = program(rockShader, { cull: "back" });
rockProgram.set({ roughness: roughnessParams }); // set() is the house verb for wiring buffers/bindings
// DYNAMIC (callback per geometry shape — compilation deferred until first draw):
const skinProgram = program<SkinAttributes>((attributes, ctx) => ({
shader: skinnedShader, // ctx: { gpu, node, use() }
draw: { cull: "back" },
bindings: { bones: bonesOf(ctx.node) }, // ctx.node = per-mesh state channel
}));Callback contract (dynamic overload)
- Runs once per (program × geometry shape), never per frame.
- Memoized by a fingerprint the view derives, never one the author declares: hash of the final WGSL + draw state + queue + an optional variant key. Sub-keying by hand is rejected: it silently produces incorrect pipeline sharing.
- The callback returns a description (purely declarative data). The view is what compiles it,
synchronously at first use. The internal
PipelineAcquisitionchoke point stays intact so a future orchestrator (#206) can make acquisition non-blocking without changing this contract. - The callback must be sync and pure — no side effects, no async work, no randomness that would change the description between shape-equal calls.
// two materials, identical fingerprint => share ONE pipeline, distinct bindings:
const a = program(rockShader, { cull: "back" });
const b = program(rockShader, { cull: "back" });
world.add(mesh(box(), a), mesh(sphere(), b)); // one compiled pipeline, two bind records
// edge case: a child gets a brand-new program under an already-rendered parent.
// The new (program × shape) pair compiles on its own first draw; siblings already
// compiled are unaffected.
child.program = program(customShader);The golden rule: whoever declares a binding, supplies it
group(0)belongs to the view: camera + model data only (lighting is explicitly NOT here, see below). Connected by reflection — the view recognizes theSceneCamera/SceneModelstructs by their memory layout, regardless of what name the author gives the binding. Putting something unrecognized ingroup(0)is a clear error at program-creation time, not a silent mismatch at draw time.group(1)and above belong to the author — declare what you bind, bind what you declare.- The
corefield from earlier design drafts is eliminated: reflection deduces group(0) automatically, there's nothing left for the author to opt into.
// file-first authoring, enabled by the reflector fix (#212/#213):
import { SceneCamera, SceneModel, SceneVarying } from "@vgpu/wgsl-std/scene";
// this compiles: view recognizes SceneCamera/SceneModel by layout, any binding name works
struct Globals { cam: SceneCamera, model: SceneModel }
@group(0) @binding(0) var<uniform> globals: Globals;
// this throws at program-creation time: group(0) member the view can't reflect
struct BadGlobals { cam: SceneCamera, mystery: vec4f }
@group(0) @binding(0) var<uniform> bad: BadGlobals; // ❌ VGPU-SCENE-PROGRAM-GROUP0-UNRECOGNIZEDTwo WGSL authoring paths
// { fragment }: the view prepends a prelude (group(0) + SceneVarying + vertex std).
// You literally cannot break the group(0)/varyings contract with this path.
program({ fragment: fragmentOnlyWGSL });
// { shader }: full module, you own the vertex stage (instancing, skinning, custom varyings).
program({ shader: fullModuleWGSL });ABI (frozen, bytes verified against naga/Dawn on feat/scene-view; amendment pending for the split below)
group(0)binding0=SceneCamera { viewProjection: mat4x4f, position: vec4f }— 80 bytes.group(0)binding1=SceneModel { matrix: mat4x4f, normalMatrix: mat4x4f }— 128 bytes.normalMatrixis the CPU-computed inverse-transpose ofmatrix(correct Lambert shading under non-uniform scale).- Lighting is removed from
group(0).SceneLights(tagged, count-prefixed, capacity 8, 672 bytes) andvgpuSceneLightingmove out of core into thevgpu/shadingpack (see the sibling "lighting SceneSystem" sub-issue), where they become that pack's own@group(1).@vgpu/wgsl-std/scenesplits into core (camera/model only) and scene/lighting (re-exported from the pack). - Vertex contract:
location 0= position,location 1= normal (optional),location 2= uv (optional), custom attributes fromlocation 3onward.location 8-11are reserved forinstanceMatrix(vertex-std convention — see the instancing sub-issue).
SCENE_PROGRAM escape hatch
const SCENE_PROGRAM = Symbol.for("vgpu.scene.program");
// anything carrying the symbol qualifies as a program — no base class required
class MyClassBasedProgram {
[SCENE_PROGRAM] = true;
compile(shape, ctx) { /* ... */ }
}The abstract SceneMaterial class from earlier drafts degrades to an interface: it's no
longer a base class to extend, just a documented shape third-party/class-based implementers can
satisfy by attaching SCENE_PROGRAM.
Renames to apply everywhere
| Old | New |
|---|---|
mesh(geo, material) | mesh(geo, program) |
MeshNode.material | MeshNode.program |
SCENE_MATERIAL | SCENE_PROGRAM |
SceneMaterialContext | SceneProgramContext |
SceneMaterial (abstract class) | SceneMaterial (interface, symbol-based) |
createSceneView | internal only; public entry point is renderTree() (see sibling sub-issue) |
Note: two specific built-in factory names are kept as-is on purpose and are not part of this rename:
normalMaterial(the default programmesh()uses when none is given, ~150 B) andshaderMaterial(raw/manual program). Flag this during implementation review since it reads inconsistently against the "no material" rule — it's intentional per the frozen design, not an oversight.
What already exists on feat/scene-view (unpushed, base main@1905a0c)
710896d+770501b— ABI for@vgpu/wgsl-std/scene(pending: split core/lighting + re-index).c7cc344— material compiler, which this issue splits: prelude/vertex-std/normal/shader logic moves to core; unlit/lambert/lighting move to thevgpu/shadingpack;material→programrenamed throughout.- Reflector fix landing in #212, mergeable via #213 (CI green) — required for the file-first
SceneCamera/SceneModelimport path above.
Acceptance
-
program()static + dynamic overloads, both typed. - Dynamic callback runs once per (program × shape), memoized by a view-derived fingerprint — author cannot override or sub-key it.
-
group(0)reflection forSceneCamera/SceneModelby layout, with a clear creation-time error for unrecognizedgroup(0)members. -
{ fragment }and{ shader }authoring paths both compile and produce the documented ABI. -
location 8-11reserved and validated against accidental custom-attribute collisions. -
SCENE_PROGRAMsymbol works as a class-based escape hatch;SceneMaterialis only an interface in the type system. - All renames in the table above applied across
vgpu/sceneand its type exports. -
@vgpu/wgsl-std/scenesplit into core (camera/model) vs scene/lighting re-exports.
Activity
0No activity yet.