#215

Scene: renderTree() with kernel-owned retained state

Openmatiasngfopened 3mo ago

Summary

The public entry point for scene rendering is a function, not a class: renderTree(). There is no SceneView object to construct in the MVP. Retained per-scene state (compiled records, pipelines, model buffers) lives inside the kernel, keyed by the root node, and is released through the existing gpu.dispose() cascade (services/ownership from #211).

See the parent issue for the full boundary/context.

The function

renderTree(gpu, world, cam, { target: screen /* , frame? */ });
  • Standalone: opens and submits its own frame.
  • Composable: pass { frame } and it only adds a pass to an already-open frame — it composes with hand-authored passes (pre/post effects) exactly like any other frame.pass(...) call.
import { group, mesh, perspectiveCamera } from "vgpu/scene";
import { frame } from "vgpu"; // free-function core, post-#211

const world = group();
world.add(mesh(box(), rockProgram));
const cam = perspectiveCamera({ fov: 60, position: [0, 2, 5] });

// standalone
renderTree(gpu, world, cam, { target: screen });

// composable with a hand-authored frame + postprocessing
frame(gpu, f => {
  renderTree(gpu, world, cam, { target: screen, frame: f });
  f.pass(screen, postProcessEffect);
});

// two cameras sharing one frame (e.g. main view + minimap)
frame(gpu, f => {
  renderTree(gpu, world, camMain, { target: mainTarget, frame: f });
  renderTree(gpu, world, camMinimap, { target: minimapTarget, frame: f });
});

Retained state ownership

State per MeshNode ({ fingerprint, draw, state }), per-root pipeline cache, and model-matrix buffers are owned by the kernel, keyed by the tree's root node — not by a handle the caller holds. gpu.dispose() cascades and releases everything for every root it tracks.

const world = group();
world.add(mesh(box(), rockProgram));
renderTree(gpu, world, cam, { target: screen }); // first call: kernel allocates retained state for `world`

renderTree(gpu, world, cam, { target: screen }); // subsequent calls: reuses the retained records

gpu.dispose(); // cascades: disposes retained state for every root ever rendered through this gpu

Geometry ownership (recipe vs. borrowed GPU resource)

mesh() accepts SceneGeometry | Geometry | GeometrySlice:

  • A recipe (e.g. box()) is uploaded and owned by the retained state — released when the kernel tears down that root's state.
  • A GPU resource (geometry(gpu, descriptor)) is always borrowed — the caller destroys it, the kernel never does.
const recipe = box();
const world = group().add(mesh(recipe, rockProgram));
renderTree(gpu, world, cam, { target: screen }); // owns the recipe's uploaded buffers

const borrowed = geometry(gpu, descriptor);
const world2 = group().add(mesh(borrowed, rockProgram));
renderTree(gpu, world2, cam, { target: screen });
borrowed.destroy(); // caller-owned: destroying it is the caller's responsibility, not the kernel's

Destroying a borrowed geometry while it is still referenced by retained state must surface the documented validation/device error — it must never be silently freed out from under the tree.

Render: four phases

  1. Walk — DFS over the tree with visibility pruning (an invisible node prunes its entire subtree, lights included); runs onBeforeRender hooks; accumulates the list of visible nodes.
  2. Systems — system.beginFrame({ visible, camera, frame }) for every registered SceneSystem (see the lighting sub-issue), fed the visible-node list from the walk — zero double-traversal, pruning is free.
  3. Bindings — draw.set(...) wires camera + model (group 0, reflected) and the program's own bindings (group 1+) per mesh record.
  4. Pass — draws opaque, then transparent, each in DFS order; no sorting.
program.set({ opacity: 0.5 }); renderTree(gpu, world, cam, { target: screen }); // transparent queue
program.set({ opacity: 1 });   renderTree(gpu, world, cam, { target: screen }); // re-keyed opaque
world.visible = false;         // nothing under `world` draws or contributes light, this call is a no-op pass

sceneView() — explicit handle, future-additive, NOT in the MVP

An explicit handle (compile(), .stats, incremental compilation — the surface #206 targets) is a deliberate future addition, not a prerequisite for renderTree(). The MVP's four internal seams (option bags, the PipelineAcquisition choke point, the { fingerprint, draw, state } record shape, and the documented reserved contract) exist specifically so sceneView() can be layered on later without breaking renderTree()'s signature. See the follow-ups sub-issue and #206 for that surface.

Open questions to resolve during implementation

  • Exact lifecycle/error semantics for using a root after gpu.dispose() (the previous SceneView-shaped design had VGPU-SCENE-VIEW-DISPOSED; with no explicit handle in the MVP, the equivalent guard needs a home — likely a per-root disposed check inside renderTree()).
  • Whether retained state tied to a frame that's still open needs the same "defer until submit/cancel" behavior the old SceneView.dispose() had.

Acceptance

  • renderTree() works standalone (opens/submits its own frame) and composed via { frame }.
  • Retained state is created lazily per root on first renderTree() call and reused afterward.
  • gpu.dispose() cascades and releases retained state for every root.
  • Recipe geometries are owned by retained state; GPU-resource geometries are always borrowed and never destroyed by the kernel.
  • Four-phase render order (walk+hooks+visible / systems.beginFrame / bindings / pass) is observable and documented.
  • Two cameras rendering the same tree into two targets inside one frame() call.
  • Opacity crossing the 0/1 boundary re-keys the queue on the next render.
  • sceneView() is explicitly out of scope for this issue (tracked in the follow-ups sub-issue and #206) — no handle object should be required to call renderTree().

Activity

0

No activity yet.