#214

Scene: program() factory, SCENE_PROGRAM protocol and core ABI split

Openmatiasngfopened 3mo ago

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 PipelineAcquisition choke 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 the SceneCamera / SceneModel structs by their memory layout, regardless of what name the author gives the binding. Putting something unrecognized in group(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 core field 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-UNRECOGNIZED

Two 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) binding 0 = SceneCamera { viewProjection: mat4x4f, position: vec4f } — 80 bytes.
  • group(0) binding 1 = SceneModel { matrix: mat4x4f, normalMatrix: mat4x4f } — 128 bytes. normalMatrix is the CPU-computed inverse-transpose of matrix (correct Lambert shading under non-uniform scale).
  • Lighting is removed from group(0). SceneLights (tagged, count-prefixed, capacity 8, 672 bytes) and vgpuSceneLighting move out of core into the vgpu/shading pack (see the sibling "lighting SceneSystem" sub-issue), where they become that pack's own @group(1). @vgpu/wgsl-std/scene splits 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 from location 3 onward. location 8-11 are reserved for instanceMatrix (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

OldNew
mesh(geo, material)mesh(geo, program)
MeshNode.materialMeshNode.program
SCENE_MATERIALSCENE_PROGRAM
SceneMaterialContextSceneProgramContext
SceneMaterial (abstract class)SceneMaterial (interface, symbol-based)
createSceneViewinternal 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 program mesh() uses when none is given, ~150 B) and shaderMaterial (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 the vgpu/shading pack; material→program renamed throughout.
  • Reflector fix landing in #212, mergeable via #213 (CI green) — required for the file-first SceneCamera/SceneModel import 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 for SceneCamera/SceneModel by layout, with a clear creation-time error for unrecognized group(0) members.
  • { fragment } and { shader } authoring paths both compile and produce the documented ABI.
  • location 8-11 reserved and validated against accidental custom-attribute collisions.
  • SCENE_PROGRAM symbol works as a class-based escape hatch; SceneMaterial is only an interface in the type system.
  • All renames in the table above applied across vgpu/scene and its type exports.
  • @vgpu/wgsl-std/scene split into core (camera/model) vs scene/lighting re-exports.

Activity

0

No activity yet.