Skip to content

spike: direct-mesh render path (render OCCT triangulation without the ViewportBody repack)#83

Draft
gsdali wants to merge 14 commits into
mainfrom
spike/direct-brep-rendering
Draft

spike: direct-mesh render path (render OCCT triangulation without the ViewportBody repack)#83
gsdali wants to merge 14 commits into
mainfrom
spike/direct-brep-rendering

Conversation

@gsdali

@gsdali gsdali commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Draft / spike — not for merge as-is. This is the investigation + working prototype for rendering a kernel's existing triangulation directly, skipping the interstitial ViewportBody interleave/repack (and NormalSmoothing). Opening for review/visibility; productionizing is scoped at the bottom.

Background

"Directly render B-Rep solids without the interstitial mesh body." A GPU only rasterizes triangles, and OCCT already triangulates the B-rep (BRepMeshPoly_Triangulation, exposed as Mesh.vertexData/.normalData/.indices). So "no mesh" isn't a GPU concept — what's removable is the interstitial repack: today the bridge interleaves OCCT's separate position/normal arrays into ViewportBody.vertexData (+ re-smooths normals + an extra copy). Full design analysis in notes/spike-direct-brep-rendering.md.

What this implements (Option A — layer-clean, no OCCT dep added)

  • ViewportBody — de-interleaved storage meshPositions/meshNormals (stride-3), a usesDirectMesh flag, and a ViewportBody.directMesh(positions:normals:indices:…) factory (which also fills vertices so bounding-box / fit / CPU picking keep working). This is exactly the shape Mesh already hands back.
  • OffscreenRenderer + ViewportRenderer — a second shaded pipeline (directMeshPipeline) whose vertex descriptor reads position from buffer 0 and normal from buffer 2 (de-interleaved). Shaders are unchanged (attributes arrive via [[stage_in]]). ensureBuffers uploads the two arrays to separate MTLBuffers; the opaque shaded pass draws them. Tessellation/meshlet builders and the shadow / pick / depth / overlay passes skip direct bodies (normalBuffer == nil guard) since those bind the stride-12 buffer with a stride-6 descriptor.

Verification

  • DirectMeshRenderingTests: the same sphere built interleaved vs. direct renders interior pixel-identical (only ≤5/255 silhouette AA between two pipeline objects); ViewportRenderer constructs with a direct body (proves directMeshPipeline compiles). 165 tests pass.
  • Live macOS run (Examples/DirectMeshLiveDemo, viewport-only windowed app) under Metal API + GPU validation (assert error mode): both panes (interleaved | direct) drew for 9+ s with zero validation errors / asserts / crashes → the live command stream is valid.

Honest framing

This removes the interstitial repack (interleave + re-smooth + one CPU copy) — a load-time/memory win on big scenes. It does not remove triangulation (OCCT still meshes), and it's not "B-Rep on the GPU" (that's the much harder Option B — trimmed-NURBS GPU tessellation — analysed in the notes). It's also complementary to the v1.1.22 #81 fix: the direct path skips NormalSmoothing entirely.

To productionize

  1. iOS-device pass; GPU-pick / shadow support for direct bodies (they render but don't cast shadows / GPU-pick today — CPU pick works via vertices).
  2. Thin the OCCTSwiftTools.shapeToBodyAndMetadata bridge to feed mesh.vertexData/.normalData and skip the interleave + NormalSmoothing (separate repo).
  3. Decide normal-smoothing policy for the interleaved path (unchanged here).

🤖 Generated with Claude Code

gsdali and others added 14 commits June 22, 2026 18:47
Investigation only (no code changes). Findings in notes/spike-direct-brep-rendering.md:
GPUs only raster triangles, OCCT already triangulates the B-Rep, so "no mesh"
isn't a GPU concept. Two readings: (A) skip the redundant ViewportBody repack and
render from OCCT's existing triangulation — Low/Med, layer-clean; (B) true
trimmed-NURBS GPU tessellation / analytic ray-tracing — research-grade and breaks
the viewport's no-OCCT design. Recommend A if cost/memory is the pain; lean on the
already-shipped PN tessellation if the pain is visual smoothness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erer

Proof of concept for rendering a kernel's existing triangulation without the
interstitial ViewportBody interleave/repack — layer-clean (no OCCT dependency
added to the viewport).

- ViewportBody: de-interleaved meshPositions/meshNormals + usesDirectMesh +
  ViewportBody.directMesh(...) factory (also derives `vertices` for bbox/pick).
  This matches the shape OCCT's Mesh already exposes (vertexData/normalData/indices).
- OffscreenRenderer: a second shaded pipeline reading position@buffer0 +
  normal@buffer2 (de-interleaved); shaders unchanged (attributes via stage_in).
- DirectMeshRenderingTests: same sphere built both ways renders identically —
  interior pixel-identical (baseline noise 0), only ~86/2292 silhouette pixels
  differ by <=5/255 (edge AA). 162 tests pass.

Findings + production TODO in notes/spike-direct-brep-rendering.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mid-extension of Option A into the interactive renderer (directMeshPipeline +
BodyBuffers.normalBuffer + ensureBuffers direct branch + encodeShadedSurface
direct branch + shadow/pick/depth/overlay skip-guards). Not yet built/tested —
checkpoint to pause the spike. Resume: verify build + add a live-render test.
Finishes the interactive-renderer extension left UNVERIFIED in the prior
checkpoint:
- Adds the shadow / pick / depth / overlay skip-guards (normalBuffer == nil) that
  the interrupted sed never applied — those passes bind the stride-12 position
  buffer with the stride-6 descriptor and would misread a direct body, so direct
  bodies are excluded there (main opaque pass renders them via directMeshPipeline).
- Adds a headless test constructing ViewportRenderer with a direct-mesh body:
  init returns nil if any pipeline fails to compile, so success proves the new
  two-buffer directMeshPipeline is a valid GPU object. Pixel correctness of the
  live draw is covered by the OffscreenRenderer differential test (identical
  shaders / descriptor / binding).

swift build clean; 163 tests pass. Live pixel verification on device/sim is the
remaining manual step (the live MTKView draw loop isn't driveable headlessly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Examples/DirectMeshLiveDemo — viewport-only windowed app (no OCCT dep) showing
the same sphere interleaved vs. directMesh side by side through the interactive
ViewportRenderer. Ran under Metal API + GPU validation (assert error mode):
both panes drew for 9+ s with zero validation errors / asserts / crashes,
confirming the live direct-mesh command stream is valid (bindings, strides,
skip-guards). Pixel-correctness is covered by the OffscreenRenderer differential
test; screenshot was blocked by Screen-Recording permission.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend Option A direct-mesh path to the shadow pass in both renderers.
Previously direct bodies were excluded from shadow casting via the
normalBuffer == nil guard (the stride-6 shadow descriptor would misread
the stride-12 direct positions). Add a sibling shadowDirectPipeline using
shadow_vertex with the two-buffer descriptor (position@0 / normal@2) and
route direct bodies through it; the shadow shader reads only position, the
normal binding just satisfies attribute 1.

- OffscreenRenderer + ViewportRenderer: shadowDirectPipeline built at init
- Shadow pass: branch on buffers.normalBuffer to pick direct vs interleaved
- ViewportRenderer hasMesh guard no longer excludes direct bodies
- .gitignore: ignore .build-* (device/xcode/xros) build output dirs

Builds clean; 165 tests green. No direct-body shadow differential test yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Differential shadow test closing the gap on dcbb5c1 (shadowDirectPipeline
was compile-verified only). A sphere caster built two ways (interleaved vs
direct) over a flat ground receiver:

- assert the direct caster's cast shadow matches the interleaved caster's
  (per-channel max diff ≤6, i.e. silhouette AA only) — proves
  shadowDirectPipeline writes the same depth as shadowPipeline
- assert the shadow is materially present (>100 receiver pixels darker than
  a shadows-off control) — so equality can't pass trivially via "no shadow"

Both assertions fail if the direct body is excluded from the shadow pass,
so the test genuinely guards the direct shadow path. Added deinterleave()
and translation() helpers. 166 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add depthOnlyDirectPipeline to ViewportRenderer and route direct-mesh
bodies through it in the depth-only prepass that feeds SSAO and silhouette
detection. Previously direct bodies were excluded via the
normalBuffer == nil guard (the stride-6 depth descriptor would misread the
stride-12 direct positions), so they got no ambient occlusion and no
silhouette outlines.

- depthOnlyDirectPipeline: depth_only_vertex with the two-buffer descriptor
  (position@0 / normal@2); depth shader reads only position
- depth-only pass: hasMesh no longer excludes direct bodies; new
  normalBuffer != nil branch binds vb@0 + nb@2 to depthOnlyDirectPipeline
- The prepass is interactive-only (OffscreenRenderer has no SSAO pass) and
  draw(in:) needs a live drawable, so there is no headless pixel path.
  Test asserts the renderer constructs with SSAO + silhouettes enabled and
  a direct body present (depthOnlyDirectPipeline compiled). Draw-time Metal
  validation is a Phase 4 live-verification item.

167 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GPU pick (wired, live-verify Phase 4):
- pickShadedDirectPipeline (pick_vertex + pick_fragment, two-buffer
  descriptor position@0 / normal@2), so direct bodies are stamped into the
  R32Uint pick texture
- face-pick sub-pass: hasMesh no longer excludes direct bodies; new
  normalBuffer != nil branch binds vb@0 + nb@2 to pickShadedDirectPipeline
- No headless pick-texture readback (OffscreenRenderer has no pick pass;
  draw needs a live drawable), so this is compile-verified via init.

CPU pick (BUG FIX — this was a crash, not a working path):
- SceneRaycast read body.vertexData (stride-6 interleaved) indexed by
  body.indices. A direct-mesh body leaves vertexData EMPTY and carries its
  positions in `vertices`, so the narrowphase indexed an empty array →
  out-of-bounds crash on any raycast against a direct body. (The earlier
  assumption that "CPU pick works via vertices" was wrong — the raycaster
  read the wrong field.)
- Fix: narrowphase reads `vertices` when `vertexData` is empty.
- Test: CPU raycast hits a direct sphere at the same distance/point as its
  interleaved twin — fails (crashes) without the fix.

168 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aycast fix)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route .overlay-layer direct bodies through the direct pipelines in the two
remaining inline overlay passes (these don't go through encodeShadedSurface
/ the main pick loop, so they had their own normalBuffer == nil guards):

- overlay surface draw: directMeshPipeline (shared shaded_vertex/_fragment)
  with vb@0 + nb@2 when normalBuffer is set, else shadedPipeline
- overlay pick loop: pickShadedDirectPipeline with vb@0 + nb@2, else
  pickShadedPipeline
- both hasMesh guards no longer exclude direct bodies
- updated the stale comment that claimed the aux passes keep the guard

This was the last pass still skipping direct bodies; all render/shadow/
depth/pick/overlay passes now handle them. Overlay rendering is interactive-
only (live drawable), so the test asserts the renderer constructs with a
direct .overlay body (pipelines compile/route); overlay draw/pick is a
Phase 4 live-verification item.

169 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… audit fix)

OffscreenRenderer parity audit found one real gap: the transparent surface
pass (translucent bodies, back-to-front) set shadedPipeline ONCE outside the
loop and bound only vb@0 — it never branched on normalBuffer. A translucent
direct-mesh body would bind its stride-12 position buffer against the
stride-6 shaded descriptor → misread vertices (garbage triangles). The
opaque main pass already branched correctly; the transparent pass did not.

Fix: select the pipeline per-body inside the loop — directMeshPipeline
(vb@0 + nb@2) when normalBuffer is set, else shadedPipeline. Both pipelines
share the same alpha-blend config, so compositing is identical.

Headlessly testable (OffscreenRenderer has the transparent pass): differential
test renders a translucent panel interleaved vs direct over an opaque box —
asserts pixel-parity (≤6 AA) and that the box shows through (purple), proving
the direct body went through the alpha-blended pass. Fails (misread) without
the fix.

Audit result: shaded + shadow + transparent passes now all route direct
bodies; OffscreenRenderer has no pick/SSAO/overlay passes by design. 170 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant