Skip to content

feat(render): per-viewport render targets with a real lifecycle + the viewport pass (M9 e11b) - #471

Merged
IvanMurzak merged 2 commits into
mainfrom
worktree-aa3c8bc617c9
Aug 1, 2026
Merged

feat(render): per-viewport render targets with a real lifecycle + the viewport pass (M9 e11b)#471
IvanMurzak merged 2 commits into
mainfrom
worktree-aa3c8bc617c9

Conversation

@IvanMurzak

@IvanMurzak IvanMurzak commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Gives a viewport something real to show. Two pieces, both in context_render, both GPU-free of any
concrete backend:

  • context/render/viewport_target.h — per-viewport render targets with a real lifecycle. One
    entry owns a colour (RGBA8Unorm, render_attachment + copy_src + texture_binding) and a
    depth (Depth32Float) attachment. create / resize (in place, keeping the handle) / release
    (frees the slot and destroys the textures), plus the per-frame acquire_for(viewport_id, size)
    a viewport panel actually calls, and release_for for a closed viewport.
  • context/render/viewport_pass.hrender_viewport_view(...). One render pass driven by an
    e11a View: the ground grid first, then one box proxy per renderable at its authored transform,
    tinted with Renderable::color. Each draw uploads a {mvp, color, shade} block to its own
    256-byte-aligned slice of one uniform buffer.

Part of the decomposed e11 (design m9-editor D5). Nothing binds this into the Shell yet — that
is e11e.

⚠ "A real project scene" means PROXY GEOMETRY, and that is a property of the DATA

Stated here so the rest of the e11 chain inherits it instead of rediscovering it. Both facts were
re-verified against the tree in this run:

  • The ctx:scene schema carries POSITION ONLY. kSceneSchemaJson
    (src/editor/schema/src/kind_schema.cpp) declares exactly two components: transform, which is
    additionalProperties:false with required:["position"] and has position as its only real
    property — no rotation, no scale — and camera (fov/near/far).
  • Renderable::mesh_id is opaque with NO registry anywhere. The only mapping in the tree is the
    hardcoded lit-golden 0 = ground / 1 = blocker in src/render/lit/src/lit_scene.cpp.

There is no mesh pipeline. Do not go looking for one. So the pass draws a box PROXY per
renderable. It honours the full render-side Transform (position + rotation quaternion + scale),
because that is the L-39 snapshot's own contract and a package populating it may fill all three — but
for anything ctx:scene can express today that degrades exactly to a translation.

A grid line is likewise a thin BOX rather than a line primitive: PrimitiveTopology in rhi.h has
exactly one value, TriangleList.

Why a SIBLING registry rather than extending DynamicTextureRegistry

The task asked for this choice to be justified. Three independent reasons, any one of which settles
it:

  1. Layering — and this one is a hard build error, not a preference. DynamicTextureRegistry
    lives in context_render_ui, which links context_render. A viewport pass is context_render
    code, so reaching that registry would make context_render depend on a library that links
    against it. Exactly the constraint that promoted lit_math.h into context/render/math.h for
    e11a.
  2. Its contract is PERSISTENCE, and M7 a9/a10 rely on it. Its header states each entry is
    allocated once and never reallocates mid-run, and its handles are stored in the L-39 snapshot
    (render_world.h UiPanel::texture). Adding release + reuse there would let a handle captured
    last frame silently resolve to a different panel's texture.
  3. Different resource shape. A panel target is one sampled colour texture; a viewport target is
    colour plus a Depth32Float attachment, because it draws 3D scene geometry that must
    depth-sort. They allocate, resize and release together.

What the new registry adds is precisely what the append-only vector cannot do: release that frees the
slot and the textures, resize in place keeping the handle (the append-only API's only way to
change size is another create_panel_target(), which leaks a handle and a texture per resize
one per frame of an edge drag), and generation-tagged handles ({generation:16 | slot+1:16}) so
slot reuse is safe: releasing bumps the generation, and a handle minted before the release is refused
rather than aliasing onto the recycled entry. That refusal is the property the sibling bought by
never reusing at all.

⚠ Signature note

The task specified render_viewport_view(IDevice&, const View&, const RenderSnapshot&, ITextureView& target). Those four parameters are here in that order with those meanings, but a fifth is
mandatory: ITextureView exposes no extent, and view.h states the rule — "The framed ASPECT
RATIO is never stored on a View. It belongs to the target the view is rendered into, so every entry
point below takes that target's extent and derives it."
Every e11a entry point (projection_matrix,
view_proj, project, pick_ray) takes an Extent2D for that reason. Without it this function
cannot build a projection at all. Everything else is a defaulted ViewportPassConfig.

Testing — 29 plants, 29 RED, each attributed to its own assertion

Two new ctests in the plain render-* family (render-test_viewport_target,
render-test_viewport_pass), auto-run by --preset dev on all three build legs — no ci.yml
change
(pre-push audit check 8 confirms).

The fake backend rasterizes nothing but its reference triangle, so pixels prove nothing here. What
rendertest::FakeDevice does record is every uniform payload in draw order — which is where the
geometry lives — so the tests decode each block and assert the model origin lands where e11a's own
project() puts the authored position. Independent computation, exact value, and one that a pass
drawing everything at the world origin cannot produce. Fixtures use distinct, non-origin positions on
all three axes for the same reason.

Every plant went RED through an assertion (never a build break), with 29 distinct failure
signatures, 5-6 compile lines each and uniform 4.5-5.4s cycles — no stale-binary tells. Run through
the shared harness with --pre-verify-cmd deleting the planted objects (the macOS GNU Make 3.81 row).

# Plant Claim it breaks
T01 release never returns the slot to the free list a released slot is REUSED
T02 release skips destroy_attachments release frees the device textures
T03 release skips the generation bump a stale handle is refused after reuse
T04 a double release pushes the slot twice the slot is freed exactly ONCE
T05 attachments are .release()d, not .reset() resize/release DESTROY, not abandon
T06 resize returns true and changes nothing resize really reallocates at the new extent
T07 resize loses its same-size early-out a per-frame resize does not churn the device
T08 a zero-extent resize reallocates a minimized panel keeps its last valid config
T09 a zero-extent create is accepted a degenerate target is refused
T10 acquire_for creates a fresh target per resize the per-resize handle leak
T11 release leaves the viewport binding target_for never returns a dead handle
T12 the depth attachment is a second colour texture the pair are the right KINDS
P01 proxies drawn at the world origin proxies sit at their AUTHORED position
P02 proxy tint hardcoded the tint is the renderable's own colour
P03 proxy_size ignored the proxy is a box of the configured size
P04 the transform's rotation ignored rotation is applied
P05 the transform's scale ignored scale is applied
P06 axis colours swapped the index-0 line parallel to X IS the X axis
P07 major/minor inverted every major_every-th line is major
P08 one grid line lost per axis 2 * (2 * half_lines + 1) lines
P09 grid lines collapse to the origin each line sits at i * spacing
P10 a line box does not span the grid span and thickness are both exact
P11 the non-finite guard is disabled a NaN/inf transform is SKIPPED, and the right ones survive
P12 the depth attachment is never wired the pass carries depth when given a view
P13 the degenerate-target guard is gone a zero-extent target records nothing
P14 draw_grid=false ignored a Game viewport gets no grid, and still draws its scene
P15 proxies lose the face shade grid flat, proxies shaded
P16 every draw writes at uniform offset 0 each draw reads its OWN 256-aligned slice
P17 the pass is skipped when there is nothing to draw an empty scene still CLEARS
N1 resize frees the old attachments BEFORE allocating the new pair a refused resize keeps the last valid target
N2 the stored generation is not bounded to 16 bits a slot survives generation wraparound
N3 resize's early-out drops the width term a WIDTH-only resize still reallocates
N4 a refused create does not return the slot to the free list a transient refusal burns no slot

Three findings the round produced that reading did not, all now fixed or documented in place:

  • ptr_after != ptr_before is an ABA trap that fails on CORRECT code — freeing the old pair and
    allocating a new one routinely returns the same heap addresses. That assertion reddened on the very
    first run against an implementation that was reallocating exactly as intended. Replaced with the
    device-reported extent/format, which only the new object can have.
  • A plant deleting destroy_attachments(entry) from the allocator scored GREEN: the four
    unique_ptr assignments below already free the old pointees. The call is load-bearing for
    ordering (a view outliving its texture), not for freeing — no test can observe that, so the
    reason is now stated at the call site rather than left to be "simplified" away on a green suite.
  • A fixture scale.y of 1.0 made the "+Y isolates scale" assertion unable to discriminate scale
    at all; the scale plant reddened its X and Z siblings and left Y green. Fixture widened to 3.0.

Test plan

  • Suite 1 — cmake --build --preset dev && ctest --preset dev: 460/460 passed, 0 build
    errors under CONTEXT_WARNINGS_AS_ERRORS=ON
  • Suite 2 — sanitize preset (ASan+UBSan) run locally on the macOS executor; the preset compiles
    this diff, so the claim is supportable rather than deferred
  • Pre-push audit — exit 0, no findings across all ten checks
  • Plant round — re-run and EXTENDED by 03-refine to 33/33 RED, 0 GREEN, distinct signatures, restore verified byte-exact
  • n/a Suite 3 (pytest) / Suite 4 (license gate) — no tools/**, bench/** or dependency change

No CONTEXT_TSAN_BUILD / CONTEXT_ASAN_BUILD widen is added: nothing here asserts a wall-clock
budget, so the define would be dead configuration.

Review pass (03-refine)

One /code-review --fix + one /simplify pass, run as nine report-only reviewers over
origin/main...HEAD (the built-in code-review skill is not model-invocable in this executor, so
the mandated angles ran as dispatched helpers). Findings were applied centrally. Four code
defects were found and fixed; each was found independently by two or three reviewers, and two were
confirmed by a reviewer that compiled and ran a probe.

Fixed

  • A failed resize left a live entry with no attachments. allocate_attachments destroyed the
    old pair before asking the device for the new one, so a device refusal left the entry
    live == true with four null pointers and size == {0,0}contains(id) true while
    color_view(id) returned nullptr, which the accessors' documented contract does not describe and
    which the sanctioned call shape render_viewport_view(..., *registry.color_view(h), ...)
    dereferences. Now built into locals and committed only on full success, so a refusal leaves the
    target byte-untouched — the same recovery posture resize already took for a degenerate extent.
  • Generation overflow silently killed a slot forever. Entry::generation is 32-bit but a handle
    carries 16, and resolve() compared the full counter against the truncated field. After 65536
    release cycles on one slot, create() returned a non-zero handle its own contains() called
    false, release() refused it, the slot and both textures leaked permanently, and live_targets()
    over-counted — with no diagnostic. The stored generation is now bounded to 16 bits, degrading this
    to ordinary wraparound.
  • textures_created() over-reported on a partially failed allocation — the += 2 ran before the
    view-creation null check. Moved after it.
  • A width-only resize was untested, and every other resize in these suites moves the height — so
    dropping the width term from the early-out's comparison left the whole suite green while the target
    kept its old width forever (what dragging a vertical splitter produces). Now pinned.

FakeDevice::set_texture_creation_fails() was added to make the device-refusal branches reachable at
all; the whole family was previously untestable by construction, exactly the gap
set_import_always_fails() already exists to close for the external-texture seam. Six pipeline-internal
references in comments (a plant id, and nouns naming artifacts that live outside this repository) were
rewritten in the code's own terms.

Verified and deliberately NOT fixed here — recorded so they are not lost:

  • Per-frame GPU resource creation. render_viewport_view builds its pipeline (compiling the
    WGSL), its uniform buffer and one bind group per draw on every call — at 60 Hz that is a shader
    recompile per frame. A note now names this at the declaration: a stateless free function has
    nowhere to cache, so whoever introduces the first frame loop owns adding it, along the lines
    WindowCompositor already follows. Fixing it here would restructure the public surface and
    invalidate the per-draw test oracle, which is not a refine-pass change.
  • Further test-coverage gaps the reviewers proved by construction: the bind-group read offset
    (the counterpart of the write offsets already asserted), the Z-parallel grid lines' extent, the
    clear op and clear colour, the depth attachment's identity and clear value, the documented
    grid-before-proxies draw order, and a rotation fixture whose half-turn about Y yields a diagonal
    matrix and so cannot discriminate R*S from S*R. Each needs either a new FakePassLog
    observable or a reshaped fixture.
  • Unguarded inputs: a non-finite camera transform poisons every uniform while the pass reports
    success, and ViewportGridConfig::half_lines is unbounded (>= 2^31 is signed-negation UB).

Gates re-run on the final tree: Suite 1 460/460; Suite 2 (ASan+UBSan, macOS) 460/460;
pre-push audit exit 0, no findings across all ten checks; plant round re-run and EXTENDED to 33 —
33/33 matched their expect | 33 RED | 0 GREEN | restore-failures 0.

One plant (N2) came back GREEN on the first round and was repaired rather than deleted: this pass had
added the 16-bit bound at two sites, so neutering either alone left the claim true. Re-anchored to
the site that actually bounds the counter, and the redundancy is now recorded in-code so a later
reader does not silently re-vacuate it.

Closes #470

IvanMurzak and others added 2 commits July 31, 2026 23:50
… viewport pass (M9 e11b)

Gives a viewport something to render into and a pass to render with, driven by the e11a
Camera/View. Two pieces, both in context_render and both GPU-free of any concrete backend:

* viewport_target.h -- per-viewport render targets owning a colour (RGBA8Unorm) and a depth
  (Depth32Float) attachment, with create / resize-in-place / release, plus the per-frame
  acquire_for(viewport_id, size) a viewport panel calls and release_for for a closed one.

  A SIBLING of context_render_ui's DynamicTextureRegistry, not an extension of it. Three
  independent reasons, any one settling it: (1) layering -- that registry lives in
  context_render_ui, which links context_render, so a context_render pass reaching it would make
  context_render depend on a library that links against it (the same constraint that promoted
  lit_math.h into context/render/math.h for e11a); (2) its documented contract is PERSISTENCE and
  M7 a9/a10 store its handles in the L-39 snapshot, so adding release+reuse there would let a
  stale UiPanel::texture resolve to another panel's texture; (3) a viewport target is a colour
  AND depth pair, allocated, resized and released together.

  Handles are generation-tagged ({generation:16 | slot+1:16}): releasing bumps the generation, so
  a slot is reused while a handle minted before the release is refused rather than aliasing onto
  the recycled entry. That is what makes reuse safe -- the property the append-only sibling bought
  by never reusing, and whose absence made a per-viewport RT leak a handle per resize.

* viewport_pass.h -- render_viewport_view(): one pass drawing the ground grid, then one box PROXY
  per renderable at its authored transform tinted with Renderable::color. Each draw uploads a
  {mvp, color, shade} block to its own 256-byte-aligned slice of one uniform buffer, because a
  queue write is ordered before the pass it feeds and several draws sharing offset 0 would all
  read the last matrix written.

PROXY GEOMETRY IS THE DATA, not a shortcut: the ctx:scene schema (kSceneSchemaJson) declares
transform as additionalProperties:false / required:["position"] -- position only, no rotation, no
scale -- and Renderable::mesh_id is opaque with no registry anywhere (the sole mapping in the tree
is the hardcoded lit-golden 0=ground/1=blocker in lit_scene.cpp). There is no mesh pipeline. The
pass still honours the full render-side Transform, since that is the snapshot's own contract, but
for anything ctx:scene can express that degrades to a translation. A grid line is likewise a thin
box: PrimitiveTopology has exactly one value, TriangleList.

The mandated signature took one extra parameter it cannot work without: ITextureView exposes no
extent, and view.h pins the rule that a View never stores its aspect ratio, so target_size is
required to build a projection at all. The four specified parameters keep their order and meaning.

Tests (R-QA-013): two ctests in the plain render-* family, auto-run by --preset dev on all three
build legs with no ci.yml change. They assert the uploaded matrices against e11a's own project()
rather than pixels, since the fake backend rasterizes only its reference triangle. 29 plants, 29
RED, each attributed to its own assertion, with distinct failure signatures.

Three defects the plant round found that reading did not: a pointer-inequality "it was
reallocated" assertion is an ABA trap that reddens on CORRECT code (freed addresses get reused);
deleting destroy_attachments() from the allocator scored GREEN because the unique_ptr assignments
below already free, so that call is load-bearing for ORDERING only and now says so; and a fixture
scale.y of 1.0 made the "+Y isolates scale" assertion unable to discriminate scale at all.

Suite 1 460/460 and Suite 2 (ASan+UBSan, run locally on the macOS executor -- the preset compiles
this diff) 460/460, 0 build errors under CONTEXT_WARNINGS_AS_ERRORS=ON, pre-push audit exit 0 with
no findings. No sanitizer wall-clock widen is added: nothing here asserts a time budget.

Closes #470
One /code-review --fix pass and one /simplify pass over origin/main...HEAD, run as nine
report-only reviewers. Four code defects, each found independently by two or three of them and
two confirmed by a reviewer that compiled and ran a probe.

* A failed resize left a live entry with NO attachments. allocate_attachments destroyed the old
  pair before asking the device for the new one, so a device refusal left the entry live == true
  with four null pointers and size == {0,0} -- contains(id) true while color_view(id) returned
  nullptr, a state the accessors' contract does not describe and which the sanctioned call shape
  render_viewport_view(..., *registry.color_view(h), ...) dereferences. Now built into locals and
  committed only on full success, so a refusal leaves the target byte-untouched. That is the same
  recovery posture resize already took for a degenerate extent, and it is why the
  destroy_attachments() call moved below the allocation rather than being deleted -- it is still
  load-bearing for ORDERING, so its comment now says which half is which.

* Generation overflow silently killed a slot forever. Entry::generation is 32-bit but a handle
  carries 16, and resolve() compared the full counter against the truncated field, so after 65536
  release cycles on one slot create() returned a non-zero handle its own contains() called false,
  release() refused it, the slot and both textures leaked permanently, and live_targets()
  over-counted -- with no diagnostic anywhere. The stored generation is now bounded to 16 bits,
  degrading this to ordinary wraparound.

* textures_created() over-reported on a partially failed allocation: the += 2 ran before the
  view-creation null check. Moved after it.

* A WIDTH-only resize had no coverage, and every other resize in these suites moves the height --
  so dropping the width term from the early-out's comparison left the whole suite green while the
  target kept its old width forever, which is what dragging a vertical splitter produces.

FakeDevice::set_texture_creation_fails() makes the device-refusal branches reachable at all; that
whole family was untestable by construction, the same gap set_import_always_fails() already exists
to close for the external-texture seam. It defaults off, so no existing fixture changes behaviour.

Six pipeline-internal references in comments -- a plant id, and nouns naming artifacts that live
outside this repository -- rewritten in the code's own terms. A note at render_viewport_view now
records that it builds its pipeline, uniform buffer and per-draw bind groups on EVERY call, so
whoever adds the first frame loop owns caching them; a stateless free function has nowhere to.

Suite 1 460/460 and Suite 2 (ASan+UBSan, macOS) 460/460, 0 build errors under
CONTEXT_WARNINGS_AS_ERRORS=ON, pre-push audit exit 0 with no findings across all ten checks. The
plant round was re-run against this tree and EXTENDED to 33: 33/33 matched their expect, 33 RED,
0 GREEN, restore-failures 0. One plant (N2) came back GREEN first time and was repaired rather
than deleted -- this pass had added the 16-bit bound at two sites, so neutering either alone left
the claim true; it is re-anchored to the site that actually bounds the counter and the redundancy
is recorded in-code so a later reader does not silently re-vacuate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ruZPEQtVrhBkDin91GADa
@IvanMurzak
IvanMurzak merged commit d764a91 into main Aug 1, 2026
42 checks passed
@IvanMurzak
IvanMurzak deleted the worktree-aa3c8bc617c9 branch August 1, 2026 08:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

render: no per-viewport render target and no viewport render pass exist — a viewport has nothing to draw into (M9 e11b)

1 participant