# Render Lifecycle Contract
Type: Contract.
**Authority.** This document is an implementation contract. The RFC is the canonical
narrative; this contract supersedes the RFC for lifecycle, resource, dirty-state, and
retain-policy rules. Accepted ADRs in `docs/decisions/` override both.
The lifecycle is explicit so first-frame work, resource lifetime, and steady-state
allocation claims can be tested.
## States
`Unprepared`: a new scene, renderer, or render target has not been prepared.
`Prepared`: the renderer has uploaded required resources, compiled pipelines, built batches,
computed bounds, and recorded initial stats for the current scene/resource shape.
`NeedsPrepare`: a structural change invalidated prepared state. Examples include adding or
removing renderable nodes, changing loaded geometry topology, replacing an imported scene,
resizing an instance set beyond prepared capacity, changing the scene origin shift, or
changing a required render target format. Replacing the active environment also enters
`NeedsPrepare`.
## Explicit Prepare Requirement
`Renderer::prepare(&mut scene)` is required before the first render and after any
`NeedsPrepare` transition for resource-free scenes. Scenes with asset-backed nodes call
`Renderer::prepare_with_assets(&mut scene, &assets)` as each handle family lands; the M1
foundation path covers mesh geometry, material, and active environment handles. Plain
`prepare()` returns a structured error when asset-backed nodes or an active environment
would otherwise be ignored. More than one `DirectionalLight::with_shadows(true)` also fails
in `prepare()` with a structured error instead of silently choosing a shadow owner.
`prepare()` may:
- compile or specialize shaders and pipelines;
- upload new buffers, textures, environment maps, and render-target resources;
- allocate the single directional shadow map and record its fixed PCF 3x3 kernel when one
directional light opts into shadows;
- allocate the depth-prepass target and record depth-prepass pass/draw counters for opaque
scene geometry;
- build or rebuild static batches;
- compute bounds and culling/picking acceleration data;
- select hardware tier and backend fallbacks;
- initialize resource lifetime references and render statistics;
- drain deferred GPU destruction work already queued by earlier lifecycle calls.
`prepare()` must not fetch asset bytes. Asset fetching and parsing already happened in
`Assets`.
Asset creation in `Assets` (`create_material`, `load_texture`, `load_geometry`,
`load_model`, `load_scene`, `load_environment`) does not perform GPU upload. It returns a
logical handle backed by CPU-side metadata and decoded bytes. The first `prepare()` after a
handle is referenced from a `Scene` allocates GPU representation in the renderer-owned
resource table.
`prepare()` may write only renderer-managed scene caches: world-transform cache, world-bounds
cache, picking/culling acceleration cache, batching plan, and dirty-flag state. It must not
change application-visible scene topology, transforms, materials, names, tags, or handles.
Prepare error semantics:
- shader-compile and GPU-upload errors roll back to the last successful prepared state, or
`Unprepared` if there was no previous successful prepare;
- GPU out-of-memory is fatal for the current renderer; the caller must reduce scene/asset
budget or rebuild;
- retrying `prepare()` after fixing assets or shaders is supported unless the error was
fatal.
## Render Behavior
`Renderer::render(&scene, camera)` may:
- update dirty transforms, material uniforms, instance buffers, camera state, and surface
state;
- skip scene render passes in `RenderMode::OnChange` when nothing visible changed; a cached
present/blit may still occur when required by the host compositor;
- submit the fixed v1.0 render graph;
- submit the prepared depth pre-pass before the forward/unlit color pass when opaque
geometry is present;
- apply FXAA after the ACES+sRGB output stage without invoking a second tonemap pass;
- record stats for draw calls, triangles, culled objects, frame time, GPU memory, skipped
frames, GPU submissions, GPU culling dispatches, and diagnostics;
- drain deferred GPU destruction work.
`render()` must not:
- fetch or parse assets;
- compile first-use shaders that should have been prepared;
- perform first-time GPU uploads for structural scene resources;
- mutate application-visible scene graph state;
- silently continue after a required capability mismatch.
The WebGL2 compatibility renderer follows this boundary with a per-canvas cache:
`prepare_canvas` performs shader compile/link and buffer allocation before render, and
`prepare_canvas_vertices` uploads the prepared vertex stream before render. `render_canvas`
returns a structured preparation error if the cache is missing or if the scene changed after
prepare. This cache is still a compatibility profile; material texture bindings, shadows,
and full PBR remain separate state-of-art replacement gates.
## Dirty-State Model
Structural changes mark `NeedsPrepare`.
Steady-state changes mark precise dirty state and are visible on the next `render()`:
- transform changes dirty local/world transforms and affected bounds;
- camera changes dirty view/projection and dependent fit/aspect state;
- material parameter changes dirty material uniforms or texture bindings;
- instance transform/tint/visibility changes dirty instance buffers;
- animation mixers dirty animated nodes only while playing;
- skinning palette uploads and morph-target weight uniforms are dirty while their owning
mixer is playing, even if the skinned mesh transform is unchanged;
- resize, DPR, surface recovery, hover, selection, clipping, and render-target changes dirty
the relevant prepared resources.
- `Renderer::set_time(FrameTime)` dirties time-dependent render state. Paused animation
mixers do not dirty state every frame, but `seek()`, `stop()` pose reset, speed changes,
and loop-mode changes dirty the affected nodes once even while paused.
- environment replacement or clearing marks `NeedsPrepare`; exposure and tonemapper changes are
steady-state output updates that dirty `RenderMode::OnChange` but do not require
`prepare()`.
Applications never set manual dirty flags for common mutation APIs. Dirty propagation is an
internal consequence of safe mutation.
`render()` borrows `Scene` immutably. Renderer-managed dirty flags and caches use interior
mutability where needed, and dirty state is not part of the public semantic API. `Scene` is
`Send` but not `Sync`, so no other thread can observe those transient renderer caches during
render.
## Resource Destruction Queue
Public API has no `.dispose()` method.
Asset handles are reference-counted logical handles. Removing nodes releases renderer-held
references. When the final user and scene references are gone, GPU destruction is scheduled
on the render/device thread.
The queue must not silently drop destruction work. v1.0 uses an unbounded queue with
observable `RendererStats::pending_destructions`. The warning watermark is 1024 pending
destruction records. Exceeding it emits diagnostic code
`diagnostic::DESTRUCTION_QUEUE_PRESSURE` and records the maximum queue depth observed in the
gate artifact. `render()`, `prepare()`, `poll_device()`, and `Renderer::drop` all drain the
queue. A `prepare()` that replaces prepared GPU resources may also enqueue fresh
destruction records for those replaced resources; `RendererStats::pending_destructions`
exposes that queue until `poll_device()` or a subsequent render drains it.
Dropping the final handle never blocks application code. If the renderer/device has already
shut down, destruction is a no-op because backend resources were destroyed with it.
Leak gates cover geometry buffers, textures, materials, environments, render targets,
instancing buffers, imported scenes, pipelines, shader modules, bind groups, and prepared
logical handles. Tests assert each exact counter returns to baseline; approximate GPU memory
totals are diagnostic only.
## Retain Policy
`Assets` controls CPU-side retention:
- `RetainPolicy::Never`: release source bytes and decoded metadata after upload when safe;
- `RetainPolicy::OnContextLossOnly`: retain enough data to recreate GPU resources after
context/device loss where supported;
- `RetainPolicy::Always`: retain source bytes and decoded metadata for hot reload,
inspection, and robust recovery.
Default policy is backend-sensitive: native, WebGPU, and WebGL2 use
`OnContextLossOnly`; headless tests may force `Always`. Applications may choose `Never` for
lower native memory use, but automatic device/context-loss recovery is then explicitly
unavailable.
Retain policy is global and prospective for an `Assets` instance. Changing it affects future
evictions and reload eligibility; it does not resurrect source bytes or decoded metadata
that were already released.
Built-in shader sources are always retained by `Renderer`, independent of `RetainPolicy`.
`RetainPolicy` governs application-supplied asset bytes and decoded metadata.
Context recovery clears prepared renderer resources and backend compatibility caches before
the next `prepare()`. On WebGL2, this includes dropping the per-canvas shader/program/buffer
cache so a restored context cannot reuse stale handles. Recovery still requires a retained
`Assets` policy and an explicit reprepare; `render()` must not rebuild missing resources.
## Steady-State Allocation Budget
Budgets are milestone gates:
| `set_transform` | 0 bytes |
| `set_material_param` | 0 bytes |
| `instances_mut().push()` within reserved capacity | 0 bytes |
| static render-on-change skipped frame | 0 bytes and 0 scene-render submissions |
| `mixer.update()` while playing | 0 bytes after mixer warm-up |
| `mixer.seek()` while paused | 0 bytes after mixer warm-up |
| `pick()` query with prepared acceleration data | 0 bytes |
The allocation gate records allocator deltas for 1 warm-up pass plus 1000 measured
operations. A positive allocation in any row fails the owning milestone unless an ADR
narrows the operation or raises the budget with evidence.
## Required Proof
- `render()` before preparation returns `RenderError::NotPrepared`.
- Structural mutations after preparation require another `prepare()`.
- Steady-state mutations render without manual dirty flags.
- Render-on-change skips clean frames.
- Resource counters return to baseline after create/remove/drop cycles.
- Context-loss tests document which retain policy is required for recovery.
- `poll_device()` drains destruction without drawing.
- A second `prepare()` on an unchanged prepared scene is a low-cost no-op.
- Leak tests run at least 100 create/remove/drop cycles, drain with `poll_device()` and one
`prepare()` when needed, then assert all exact `RendererStats` resource counters return to
baseline and `pending_destructions == 0`.
- Tests use the canonical `RendererStats` fields defined in
[public-api.md](public-api.md), not duplicate counter names.