#216

Scene: vgpu/shading pack — built-in programs and the lighting SceneSystem

Openmatiasngfopened 3mo ago

Summary

Lights are split across two layers: the node (pure data, lives in vgpu/scene, has parenting value like any other node) and its interpretation (a SceneSystem that lives in the optional vgpu/shading pack). Core vgpu/scene never depends on the pack; the pack depends on core.

See the parent issue for the full boundary/context.

Light nodes stay in core (data only)

import { group, mesh } from "vgpu/scene";
import { pointLight, directionalLight } from "vgpu/scene"; // nodes: no shading logic attached

const world = group();
world.add(pointLight({ position: [2, 3, 1], intensity: 2, color: "#fff" }));
world.add(directionalLight({ rotation: [-Math.PI / 4, 0, 0], intensity: 1 }));

A light node with no SceneSystem registered to interpret it is inert — it carries data and participates in the tree/transform graph like any node, but contributes nothing to rendering.

Interpretation lives in the pack, via SceneSystem

import { lighting, lambertProgram, phongProgram } from "vgpu/shading";

const lightingSystem = lighting(); // SceneSystem
world.add(mesh(box(), lambertProgram({ color: "orange" })));

renderTree(gpu, world, cam, { target: screen, systems: [lightingSystem] });

// or: auto-registration from inside a dynamic program callback
const myProgram = program((attrs, ctx) => {
  ctx.use(lightingSystem); // registers once, ctx.use is idempotent per view/root
  return { shader: myShader, bindings: { lights: lightingSystem.buffer } };
});

SceneSystem contract:

const SCENE_SYSTEM = Symbol.for("vgpu.scene.system");

interface SceneSystem {
  [SCENE_SYSTEM]: true;
  beginFrame(ctx: { visible: SceneNode[]; camera: SceneCamera; frame?: Frame }): void;
  dispose?(): void;
}
  • ctx.use(system) inside a program callback auto-registers the system against the current retained state — no separate setup call required for the common case.
  • beginFrame receives the visible-node list already produced by the walk — the system never re-traverses the tree, and gets subtree-pruning for free (an invisible light's subtree, and thus the light itself, never reaches beginFrame).
  • Systems are disposed together with the retained state they're attached to (root disposal / gpu.dispose() cascade — see the renderTree() sub-issue).
// edge case: a light under an invisible parent is fully pruned — it contributes
// zero light, not just "zero visible geometry":
const rig = group({ visible: false }).add(pointLight({ intensity: 100 }));
world.add(rig); // this light never reaches lightingSystem.beginFrame's `visible` list

ABI split (companion to the core-ABI sub-issue)

SceneLights (tagged, count-prefixed, capacity 8, 672 bytes) and vgpuSceneLighting move out of group(0) entirely and become the vgpu/shading pack's own @group(1). VGPU-SCENE-LIGHTS-CAPACITY (thrown past 8 lights) moves with them. Core's group(0) only ever contains SceneCamera/SceneModel (see the program-protocol sub-issue for that ABI).

Built-in programs kept in core

normalMaterial (the default mesh() falls back to when no program is given, ~150 B) and shaderMaterial (thin wrapper over a raw program) stay in core, not the pack — they have no lighting dependency. Their names intentionally keep the pre-rename "material" spelling; see the program-protocol sub-issue's note about this.

CI fixtures

Two fixtures gate this boundary mechanically:

  • scene-renderer — batteries-included: imports vgpu/shading and its built-in lit programs (lambertProgram/phongProgram/lighting()). This is what finally satisfies the still-pending acceptance item from #204 (bundle-budget CI needs a full-featured baseline to measure tree-shaken bundles against, alongside the minimal ones).
  • scene-view-custom-material — imports only vgpu/scene and a hand-written program(), never vgpu/shading. CI asserts its bundle has zero references to the shading pack — the mechanical gate proving the promise that programs are optional and the pack tree-shakes away completely when unused.
// fixtures/scene-renderer: batteries-included
import { lighting, lambertProgram } from "vgpu/shading";
world.add(mesh(box(), lambertProgram({ color: "red" })));

// fixtures/scene-view-custom-material: MUST NOT import vgpu/shading
import { program } from "vgpu/scene";
world.add(mesh(box(), program(customShader))); // CI fails the build if this pulls in the pack

Explicitly out of scope

Shadows / multipass shadow rendering are not part of this issue or of program. They're a future additive capability of SceneSystem (e.g. a hypothetical future shadows() system that also consumes the visible-node list), tracked in the follow-ups sub-issue, not designed here.

Acceptance

  • Light nodes (pointLight, directionalLight, ...) exist in vgpu/scene with zero shading dependency and zero effect unless a SceneSystem interprets them.
  • lighting() SceneSystem in vgpu/shading, registered via systems: [] option or ctx.use(), consuming the walk's visible-node list with no extra traversal.
  • SceneLights/VGPU-SCENE-LIGHTS-CAPACITY live in the pack's own @group(1), not core's group(0).
  • Invisible light subtree contributes zero light (verified by a GPU test, not just CPU pruning).
  • normalMaterial/shaderMaterial remain in core with no pack dependency.
  • scene-renderer (batteries-included) and scene-view-custom-material (structural exclusion of the pack) CI fixtures both pass, closing the pending #204 acceptance item.
  • Shadows/multipass explicitly documented as out of scope, tracked only as a follow-up idea.

Activity

0

No activity yet.