Skip to content

Annotation fills drawn onto the terrain: offscreen surfaces + one-draw fills (#841) - #842

Open
haraldsteinlechner wants to merge 7 commits into
developfrom
bugs/841_stencil-fills
Open

haraldsteinlechner wants to merge 7 commits into
developfrom
bugs/841_stencil-fills

Conversation

@haraldsteinlechner

@haraldsteinlechner haraldsteinlechner commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #841: filled annotations z-fought with the terrain, because terrain relief inside the outline cut through the flat fill sheet.

How it works now

  • Fill volumes (FillVolume, PRo3D.Base, pure): each fill is a closed prism around its triangulation, extruded along the outline's fitted-plane normal (this also works on vertical rock faces). New viewer setting Fill Reach (m): auto by default (half the outline size, capped per body), or a fixed number of metres.
  • Surfaces offscreen (OffscreenSurfaces): the main and instrument views render their surface priority groups into a multisampled colour+depth target. A per-sample blit copies both back, so everything downstream depth-tests as before, and the surface depth now also exists as a texture.
  • Fills in one draw (PackedRendering.DeferredFills):
    • All prisms are accumulated into an RGBA32F target against the surface depth: back faces count +1, front faces −1. This is a z-fail count per fill, in colour, and order-independent.
    • A fullscreen composite then draws the fills under the outlines.
    • Cost: 0.12 ms/frame for 10 240 fills (laptop RTX 500 Ada, moving camera), and about 0.05 ms for the offscreen copy.
    • A shared stencil coloured pixels from whichever prism face reached them first, which gave straight cuts in oblique views. Ordering a stencil pass per fill fixes that, but costs about 23 ms at 10k fills.
  • Stencil renderer: kept only as the Snapshots fallback.

Verified

  • Unit tests: FillVolume 16/16 (FsCheck, 2000 cases incl. self-intersecting rings) and PolygonFill 50/50.
  • GPU tests (FillRenderTest, 8/8):
    • a bump inside the outline gets filled;
    • the reach setting limits the fill;
    • the camera can be inside a prism;
    • a far plane cutting a prism neither drops the fill nor paints empty background;
    • a near prism covering a far fill on screen does not change its colour (0 wrong pixels);
    • overlapping fills blend;
    • surfaces from an earlier priority group survive the copy;
    • benchmark at 512 and 10 240 fills.
  • Playwright annotation-fill: a filled Sky polygon drawn by ctrl+click on Dimorphos. 98.9 % of pixels inside the outline are tinted, 0 % outside.
  • Reload probe: opening and closing the render page 6× does not accumulate memory.
  • Manual test in the viewer (user).
  • CI subset (--skip-hera): 732 passed, 0 failed, 4 errored. All four are in ProfileAttributeExtraction (a NullReferenceException in the shared KdTree cache when those tests run in parallel). This branch does not touch that code, and the four pass when run sequentially (7/7), so the race predates this PR.
  • Reviewed by a separate agent. Its findings are fixed or listed below.

Server screenshots (follow-up commit)

  • The offscreen surface targets are now built per render client from aardvark.media's RenderClientValues (size, view, projection), which the render server sets before every frame, live or screenshot. So /rendering/screenshot at any size or aspect ratio renders the surfaces for exactly that image.
  • Surfaces render offscreen over transparent black (premultiplied) and are copied with premultiplied blending. A screenshot's background= is kept, and translucent surfaces blend over the real clear colour.
  • The offscreen sample count stays the viewer's, because the OPC patches are prepared for it. The copy converts to the target's sample count.
  • Verified:
    • annotation-fill.spec compares 3200x900 and 2400x1350 server screenshots with the live view: 0.0% of pixels differ. On the previous commit 60% of the fill differs.
    • background= shows where the sky is (81.6%).
    • FillRenderTest 10/10, including the new clear-colour/translucency test and the copy into 1 and 8 samples.

Known limitations / follow-ups

  • Server screenshots with a samples= other than the viewer's (4) come out without surfaces. The same happens on develop, so this PR does not cause it; PRo3D's own screenshot clients send samples=4.
  • After a server screenshot of another size, the live view stops showing model changes until the camera moves. This is an aardvark.media invalidation issue, and also happens on develop.
  • Snapshots still render surfaces directly and use the stencil fallback, which is slow with many fills.
  • The fill accumulation is single-sampled, so fill edges at terrain silhouettes are not antialiased.
  • Linear outlines are straight 3D chords, not draped on the terrain, so from oblique views they diverge from the fill. This is expected and documented.

Pre-existing issues noticed along the way, unrelated to this PR:

  • the closing edge of Sky-projected polygon outlines is not drawn;
  • GPU memory grows while the camera moves (same on develop, likely OPC LoD caching);
  • an older develop build crashed when the render page was reopened.

Docs: docs/AnnotationFill.md (new) and ai/RENDERING.md.

🤖 Generated with Claude Code

)

Fills were a flat lid between the outline points, which terrain relief
inside the outline cut through. Each fill is now a closed prism around
its triangulation, extruded along the outline's fitted-plane normal; a
z-fail stencil pass marks the terrain inside it and a colour pass tints
exactly those pixels.

- FillVolume (Base): pure prism construction, walls from the boundary
  edges of the triangulation; FillReach: Auto (half the outline size,
  capped per body) or metres
- viewer config: Fill Reach (m), auto by default (new keys, read with
  defaults)
- one packed draw also for the legacy/Snapshots branch, SPICE trafo
  applied on the CPU; the pick pass and the 2D map keep the flat caps
- stencil cleared before the depth-tested render command
- unit tests (closedness, orientation, volume; FsCheck incl.
  self-intersecting rings) and a GPU test (bump inside the outline,
  reach, camera inside the prism)
…841)

The shared stencil of the first version painted a pixel from whichever
prism face reached it first, so a near prism's wall took the terrain of
a fill behind it on screen (straight cuts in oblique views). Ordering a
stencil pass per fill fixes that but costs 23 ms at 10k fills.

DeferredFills draws all prisms in one draw into an RGBA32F target:
faces behind the surface depth add their fill's premultiplied colour,
+1 for back faces, -1 for front faces - the z-fail count per fill, in
colour, order-independent. A fullscreen composite lays it over the
terrain. 10240 fills: 0.16 ms, against 23 ms bit-batched stencil.

OffscreenSurfaces renders the surfaces into MSAA colour+depth textures
and copies both back per sample, so the depth exists as a texture while
everything downstream keeps depth-testing as before. Not wired into the
viewer yet; the stencil renderers stay until it is.

GPU tests run the deferred path: bump, reach, camera inside the prism,
far plane (no background painting), a near prism covering a far fill on
screen (0 wrong pixels), overlapping fills blending; the benchmark
compares 512 and 10240 fills with a moving camera.
)

The main and instrument views render their surface priority groups into
a multisampled colour+depth target of the control's size, and a
per-sample blit copies colour and depth back, so everything downstream
depth-tests as before. The depth texture feeds DeferredFills: all
annotation fills in one draw.

- surfaces stay clickable: their scene graphs are added again through
  OffscreenSurfaces.pickOnly (no render objects, scene events kept)
- offscreen projection = RenderControlConfig.standard.adjustAspect; blit
  and composite map gl_FragCoord through ViewportSize
- Snapshots keep rendering surfaces directly; their fills use the
  stencil renderer
- known gap: server screenshots at another size get the control-sized
  surface textures, scaled

Verified: GPU tests on the deferred path (7/7, 10240 fills 0.15 ms),
the user's fillProblem scene in the viewer (no straight cut), and the
Playwright annotation-fill spec drawing a filled polygon on Dimorphos by
ctrl+click (99% inside tinted, 0% outside).
)

The surface and fill-accumulation tasks were compiled eagerly in the
view function. View code runs again per page load (page routing, one
viewRenderView per ?page=render request), and nothing disposed those
tasks - each load left another copy of the surfaces' GPU data behind
(GPU memory full, 10 GB committed, slow and rare frames).

OffscreenSurfaces.renderTargets compiles the task in an AdaptiveResource:
on first acquire, disposed on last release. Used as textures in the
render control's scene, the client's render task acquires them, and
aardvark.media disposes that per client - so the offscreen passes now
live exactly as long as a page shows them.

Probes: probe-reload (open/close the render page, memory after each)
returns to ~530-650 MB after every close over 6 loads; probe-leak
(continuous camera drag) grows the same on develop and this branch, so
that growth predates the offscreen path. GPU tests 7/7.
… view, cap orientation

- remove the main path's ClearStencil (views draw fills against the
  surface depth); SnapshotSg keeps it for the stencil fallback
- packedFillRender: one batch size again, documented as the Snapshots
  fallback and why it is slow at scale
- instrument view: the offscreen size falls back to the control's CSS
  size (half the instrument resolution) until the page reports one;
  it rendered at 1x1 and stayed black (develop shows the surface)
- FillVolume: no per-triangle re-orientation of the cap. libtess emits
  a consistent winding; a sliver triangle's sign is rounding noise and
  flipping it left the prism open (FsCheck counterexample, now a fixed
  test; properties run 2000 cases)
- FillStencilRenderTest -> FillRenderTest; benchmark arms: terrain
  direct, offscreen+blit, deferred fills, stencil fallback
- probe-instrument.ts; docs: known limitations in ai/RENDERING.md,
  screenshot note in docs/AnnotationFill.md
…e lifetime

- blit copies colour and depth unconditionally: depth is cleared before
  every surface priority group, so earlier groups have depth 1 outside
  the last one; discarding depth-1 samples dropped those surfaces
  (regression test with two groups)
- offscreen size clamped to >= 1 (views report their size only after the
  first resize; an instrument with zero resolution gave a 0 fallback)
- framebuffer signatures created with the lazily compiled task and
  disposed with it, not in view code
- deferred fills off while there is no fill (no full-screen RGBA32F pass)
- fill reach tooltip built from FillReach.maxAuto
- docs: what the blit copies, the sample count actually used, and known
  limitations (screenshot aspect/samples mismatch, shared reported size,
  fixed background, single-sampled fill accumulation, stencil fallback
  rebuilding batches); FillVolume header no longer an orphaned XML doc
…make

The offscreen surface targets were sized from the page's reported size, so a
/rendering/screenshot of another size got stretched control-sized textures under
correctly projected outlines, and the copy painted the viewer's #222222 over the
screenshot's background.

- The views pass DomNode.RenderControl a RenderClientValues -> ISg function;
  ViewerUtils.offscreenScene builds the offscreen targets and the fill
  accumulation from values.size/viewTrafo/projTrafo, which aardvark.media sets
  before every frame it renders, live or screenshot. Grouped surfaces, overlay,
  annotations and fill geometry are still built once and shared.
- DrawingApp.view returns the fill geometry instead of drawing it; views draw it
  against their per-client surface depth, Snapshots with the stencil renderer.
- Surfaces render offscreen over transparent black (premultiplied) and are copied
  with premultiplied blending: the target keeps its clear colour where no surface is.
- The offscreen sample count stays the viewer's (OPC patches are prepared for it);
  the blit converts to the target's.
- The instrument view's size fallback is gone: values.size is the real size.

Tests: FillRenderTest +2 (clear colour/translucency kept; copy into 1 and 8
samples), annotation-fill.spec compares 3200x900 and 2400x1350 server shots with
the live view (fails on the previous build: 60% of the fill differs) and checks
background=. Pre-existing limits found on develop too are in ai/RENDERING.md.

This branch has not been deployed

No deployments
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.

Annotation fills z-fight with the terrain

1 participant