perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#118) #140

Merged
catbot merged 1 commit from claude/issue-118 into master 2026-06-18 15:07:44 +02:00
Member

Summary

RenderingElement3D::BuildTLAS rebuilt the host instance+metadata buffers with an O(n) copy of every 64 B VkAccelerationStructureInstanceKHR + metadata entry every frame, unconditionally (outside the topologyChanged block), then flushed the whole high-water capacity (VK_WHOLE_SIZE) on both buffers. At the millions-of-instances target the copy dominates the CPU frame, and the whole-buffer flush costs on non-coherent BAR/VRAM. This covers both halves of the deliverable (rebuild + flush) — one dirty-set drives both.

Fix

  • Generation counter. RenderingElement3D::hostDataVersion + MarkHostDataDirty() (bumps a global monotonic counter). TlasWithBuffer::uploadedVersion records, per frame, the version last copied into each slot. A slot is copied only when its element advanced past the recorded version. Version 0 ("untracked") reads dirty every frame, so callers that don't opt in keep the exact pre-#118 copy-every-frame behaviour — fully backward compatible. Globally-unique versions make this correct under relocation (Remove's swap-and-pop, and a remove+add that nets the same count on the refit path) without tracking element identity.
  • Ranged flush from the same dirty span. The dirty [first, last] envelope feeds a new VulkanBuffer::FlushDevice(cmd, access, stage, offset, bytes) overload (flush + barrier over just that span) for instanceBuffer, and the existing ranged FlushDevice(offset, bytes) for metadataBuffer. When nothing is dirty, both — and the HOST->build barrier — are skipped. That barrier only ever ordered host writes (VK_PIPELINE_STAGE_HOST_BIT), never the application's compute-written GPU-owned transform, so skipping it changes no GPU-ordering guarantee.

Constraint honoured

transformOwnedByGpu transforms are still never host-copied. The new field/method are mirrored on the WebGPU class for source portability (the WebGPU build re-uploads its small mirror wholesale and ignores the version).

Tests

New TLASInstanceDirtyTracking drives the real RT device (RTX 4090, full validation layers) and reads back the host-mapped buffers to assert:

  • tracked elements upload once, then are skipped until re-marked;
  • mutating without MarkHostDataDirty does not upload (clean slot skipped);
  • untracked elements (version 0) upload every frame (legacy path);
  • relocation on the refit path re-uploads exactly the moved slots;
  • zero validation-layer errors over the ranged flush.

Full suite: 21 passed (crafter-build test). Library also builds clean for wasm32-wasip1 (DOM/WebGPU).

Resolves #118

## Summary `RenderingElement3D::BuildTLAS` rebuilt the host instance+metadata buffers with an O(n) copy of every 64 B `VkAccelerationStructureInstanceKHR` + metadata entry **every frame, unconditionally** (outside the `topologyChanged` block), then flushed the **whole high-water capacity** (`VK_WHOLE_SIZE`) on both buffers. At the millions-of-instances target the copy dominates the CPU frame, and the whole-buffer flush costs on non-coherent BAR/VRAM. This covers both halves of the deliverable (rebuild + flush) — one dirty-set drives both. ## Fix - **Generation counter.** `RenderingElement3D::hostDataVersion` + `MarkHostDataDirty()` (bumps a global monotonic counter). `TlasWithBuffer::uploadedVersion` records, per frame, the version last copied into each slot. A slot is copied only when its element advanced past the recorded version. Version `0` ("untracked") reads dirty every frame, so callers that don't opt in keep the exact pre-#118 copy-every-frame behaviour — **fully backward compatible**. Globally-unique versions make this correct under relocation (Remove's swap-and-pop, and a remove+add that nets the same count on the refit path) **without tracking element identity**. - **Ranged flush from the same dirty span.** The dirty `[first, last]` envelope feeds a new `VulkanBuffer::FlushDevice(cmd, access, stage, offset, bytes)` overload (flush + barrier over just that span) for `instanceBuffer`, and the existing ranged `FlushDevice(offset, bytes)` for `metadataBuffer`. When nothing is dirty, both — and the `HOST->build` barrier — are skipped. That barrier only ever ordered *host* writes (`VK_PIPELINE_STAGE_HOST_BIT`), never the application's compute-written GPU-owned transform, so skipping it changes no GPU-ordering guarantee. ## Constraint honoured `transformOwnedByGpu` transforms are still never host-copied. The new field/method are mirrored on the WebGPU class for source portability (the WebGPU build re-uploads its small mirror wholesale and ignores the version). ## Tests New `TLASInstanceDirtyTracking` drives the real RT device (RTX 4090, full validation layers) and reads back the host-mapped buffers to assert: - tracked elements upload once, then are skipped until re-marked; - mutating without `MarkHostDataDirty` does **not** upload (clean slot skipped); - untracked elements (version 0) upload every frame (legacy path); - relocation on the refit path re-uploads exactly the moved slots; - **zero** validation-layer errors over the ranged flush. Full suite: **21 passed** (`crafter-build test`). Library also builds clean for `wasm32-wasip1` (DOM/WebGPU). Resolves #118
BuildTLAS rebuilt the host instance+metadata buffers with an O(n) copy of
every 64 B VkAccelerationStructureInstanceKHR + metadata entry every frame,
unconditionally, then flushed the whole high-water capacity (VK_WHOLE_SIZE)
on both buffers. At the millions-of-instances target that copy dominates the
CPU frame, and the whole-buffer flush costs on non-coherent BAR/VRAM.

Add a generation counter so only changed host-authored fields are copied, and
feed the same dirty span into the ranged FlushDevice(offset, bytes) overload:

- RenderingElement3D::hostDataVersion + MarkHostDataDirty() (bumps a global
  monotonic counter). TlasWithBuffer::uploadedVersion records, per frame, the
  version last copied into each slot. A slot is copied only when its element
  advanced past the recorded version; version 0 ("untracked") reads dirty every
  frame, so callers that don't opt in keep the prior copy-every-frame behaviour.
  Globally-unique versions make this correct under relocation (Remove's
  swap-and-pop, and remove+add that nets the same count on the refit path)
  without tracking element identity. The reset on every topology change covers
  buffer reallocation and the reshuffled element->slot mapping.
- The dirty [first, last] envelope drives both the copy and the flush: a new
  VulkanBuffer::FlushDevice(cmd, access, stage, offset, bytes) overload flushes
  + barriers just that span for instanceBuffer, and the ranged
  FlushDevice(offset, bytes) for metadataBuffer. When nothing is dirty both are
  skipped — the skipped HOST->build barrier only ever ordered host writes, never
  the application's compute-written GPU-owned transform (that compute->build
  ordering is the caller's, and is unchanged).

Constraint honoured: transformOwnedByGpu transforms are still never host-copied.
The API field/method are mirrored on the WebGPU class for source portability
(the WebGPU build re-uploads its small mirror wholesale and ignores the version).

New test TLASInstanceDirtyTracking drives the real RT device and reads back the
host-mapped buffers to assert: tracked elements upload once then skip until
re-marked, untracked elements always upload, and relocation on the refit path
re-uploads exactly the moved slots — with zero validation-layer errors over the
ranged flush.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
catbot merged commit 2783e47674 into master 2026-06-18 15:07:44 +02:00
catbot deleted branch claude/issue-118 2026-06-18 15:07:44 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics!140
No description provided.