2026-07-22 18:09:06 +02:00
|
|
|
//SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
2026-01-28 18:51:11 +01:00
|
|
|
|
|
|
|
|
module;
|
|
|
|
|
#include <vulkan/vulkan_core.h>
|
2026-03-09 20:10:19 +01:00
|
|
|
module Crafter.Graphics:RenderingElement3D_impl;
|
|
|
|
|
import :RenderingElement3D;
|
2026-01-28 18:51:11 +01:00
|
|
|
import std;
|
|
|
|
|
|
|
|
|
|
using namespace Crafter;
|
|
|
|
|
|
2026-03-09 21:50:24 +01:00
|
|
|
|
2026-03-09 20:10:19 +01:00
|
|
|
std::vector<RenderingElement3D*> RenderingElement3D::elements;
|
2026-01-28 19:16:28 +01:00
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
void RenderingElement3D::Add(RenderingElement3D* e) {
|
|
|
|
|
e->indexInElements = static_cast<std::uint32_t>(elements.size());
|
|
|
|
|
elements.push_back(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RenderingElement3D::Remove(RenderingElement3D* e) {
|
|
|
|
|
// Idempotent: callers like Builder ghost flow toggle elements in/out
|
|
|
|
|
// and may try to remove an already-removed element.
|
|
|
|
|
std::uint32_t idx = e->indexInElements;
|
|
|
|
|
if (idx == std::numeric_limits<std::uint32_t>::max()) return;
|
|
|
|
|
std::uint32_t last = static_cast<std::uint32_t>(elements.size() - 1);
|
|
|
|
|
if (idx != last) {
|
|
|
|
|
elements[idx] = elements[last];
|
|
|
|
|
elements[idx]->indexInElements = idx;
|
|
|
|
|
}
|
|
|
|
|
elements.pop_back();
|
|
|
|
|
e->indexInElements = std::numeric_limits<std::uint32_t>::max();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 15:49:17 +02:00
|
|
|
void RenderingElement3D::BuildTLAS(VkCommandBuffer cmd, std::uint32_t index, RTBuildPreference preference) {
|
feat(vulkan): run the compute/UI path without VK_EXT_descriptor_heap
Device::Initialize required twelve device extensions with no capability
check. Mesa Turnip (Adreno) lacks six of them: VK_EXT_descriptor_heap,
VK_KHR_shader_untyped_pointers, VK_EXT_device_fault and the three ray
tracing extensions. vkCreateDevice failed there, so not even a UI-only
program could start, although the UI path is compute-only and needs
nothing else that Turnip is missing.
The descriptor heap, ray tracing and device fault are now optional
groups, detected at start-up from the extension list and the feature
bits (Device::descriptorHeapSupported, rayTracingSupported,
deviceFaultSupported). Ray tracing needs the heap; on a device without
it the ray tracing entry points throw a message naming the cause.
Without the heap the same bindless slots live in one update-after-bind
descriptor set per frame, owned by DescriptorHeapVulkan: binding 0
storage images, 1 sampled images, 2 samplers, 3 storage buffers. Slot
allocation, the slot handles, UIDispatchHeader and the UIRenderer API
are unchanged; buffer slots simply stop being offset. Window::Render
binds that frame's set where it binds the heap, every compute pipeline
shares the heap object's pipeline layout, and push data travels as push
constants.
Each UI kernel is built twice from one source. ui-shared.glsl declares
its resources through UI_HEAP_* macros that expand to descriptor_heap
or, with CRAFTER_GRAPHICS_DESCRIPTOR_SETS defined, to set/binding.
<name>.comp.spv and <name>.comp.sets.spv ship side by side and
ComputeShader::Load picks one. The heap build of every shader is
byte-identical to before.
Breaking:
- ComputeShader::Load takes the heap as a second argument.
- DescriptorHeapVulkan is no longer copyable or movable.
- Custom UI shaders declare heap resources with the UI_HEAP_* macros
and are registered twice in project.cpp (see the README).
- project.cpp uses Shader::defines and Shader::variant, so it needs a
crafter-build that has them (Crafter.Build master, fe38cc1 or later).
Also:
- maintenance5 is enabled as a feature, not only as an extension:
every VulkanBuffer already relied on VkBufferUsageFlags2CreateInfo.
- Barrier stage masks drop RAY_TRACING_SHADER on devices without ray
tracing, and ImageVulkan::Create resolves its default consumer stage
to COMPUTE_SHADER there.
- Window refuses a swapchain whose image count is not numFrames
instead of writing past images[]. The limitation itself is #160.
- CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP forces the fallback on a driver
that has the heap, so it can be exercised on a desktop GPU.
Verified on an RTX 4090 in both modes under the validation layer, and
on a Fairphone 6 (Turnip, Adreno 810, postmarketOS), where the fallback
is the only path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00
|
|
|
Device::RequireRayTracing();
|
2026-05-05 23:49:29 +02:00
|
|
|
auto& tlas = tlases[index];
|
|
|
|
|
const std::uint32_t primitiveCount = static_cast<std::uint32_t>(elements.size());
|
|
|
|
|
|
2026-06-16 15:49:17 +02:00
|
|
|
// ALLOW_UPDATE is always set — BuildTLAS refits in place on later
|
|
|
|
|
// frames. The FAST_TRACE/FAST_BUILD preference is layered on top; the
|
|
|
|
|
// two preference bits are mutually exclusive, so exactly one is chosen.
|
|
|
|
|
// Both bits are baked into the AS at BUILD time and must be identical on
|
|
|
|
|
// every subsequent UPDATE, so a change in `preference` is treated as a
|
|
|
|
|
// topology change below to force a fresh rebuild with the new flags.
|
|
|
|
|
const VkBuildAccelerationStructureFlagsKHR buildFlags =
|
|
|
|
|
VK_BUILD_ACCELERATION_STRUCTURE_ALLOW_UPDATE_BIT_KHR
|
|
|
|
|
| (preference == RTBuildPreference::FastBuild
|
|
|
|
|
? VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_BUILD_BIT_KHR
|
|
|
|
|
: VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_TRACE_BIT_KHR);
|
|
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
// Refit (UPDATE) is allowed when the count matches the count this AS
|
2026-06-16 15:49:17 +02:00
|
|
|
// was last built for and the build flags are unchanged. A change forces
|
|
|
|
|
// a full rebuild because the AS storage and instance buffer were sized
|
|
|
|
|
// for the old count (and a refit must inherit the original build flags).
|
|
|
|
|
// Refit is dramatically cheaper at scale (millions of instances) — it
|
|
|
|
|
// walks the existing BVH and updates AABBs rather than reconstructing
|
|
|
|
|
// topology.
|
2026-05-05 23:49:29 +02:00
|
|
|
const bool topologyChanged =
|
|
|
|
|
tlas.accelerationStructure == VK_NULL_HANDLE
|
2026-06-16 15:49:17 +02:00
|
|
|
|| primitiveCount != tlas.builtInstanceCount
|
|
|
|
|
|| buildFlags != tlas.builtFlags;
|
2026-05-05 23:49:29 +02:00
|
|
|
|
2026-04-30 23:15:43 +02:00
|
|
|
{
|
|
|
|
|
VkMemoryBarrier asBarrier {
|
|
|
|
|
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
|
|
|
|
|
.srcAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR,
|
|
|
|
|
.dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR
|
|
|
|
|
};
|
|
|
|
|
vkCmdPipelineBarrier(cmd,
|
|
|
|
|
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
|
|
|
|
|
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
|
|
|
|
|
0, 1, &asBarrier, 0, nullptr, 0, nullptr);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
if (topologyChanged) {
|
2026-06-16 18:24:09 +00:00
|
|
|
// Grow the host-visible inputs on a high-water mark rather than
|
|
|
|
|
// resizing to the exact count every topology change. These buffers
|
|
|
|
|
// only need to hold *at least* primitiveCount entries — the AS build
|
|
|
|
|
// reads exactly primitiveCount of them via tlasRangeInfo, and the
|
|
|
|
|
// copy loop below writes [0, primitiveCount) — so an allocation left
|
|
|
|
|
// over from a larger earlier frame is reused as-is. This drops the
|
|
|
|
|
// two host-buffer reallocations on a count decrease (and on an
|
|
|
|
|
// increase that still fits the previous high-water capacity),
|
|
|
|
|
// leaving only the AS storage + scratch rebuild below, which is tied
|
|
|
|
|
// to the AS itself and dominates this path regardless.
|
|
|
|
|
//
|
|
|
|
|
// instanceBuffer and metadataBuffer grow in lockstep (always resized
|
|
|
|
|
// together to the same entry count), so one capacity check covers
|
|
|
|
|
// both. size is only meaningful once buffer is non-null, so the
|
|
|
|
|
// null check must short-circuit before the division.
|
2026-05-05 23:49:29 +02:00
|
|
|
// STORAGE_BUFFER_BIT is required because the application's compute
|
2026-06-16 18:24:09 +00:00
|
|
|
// shaders bind these buffers as storage SSBOs (e.g. to write
|
2026-05-05 23:49:29 +02:00
|
|
|
// per-instance transforms directly into the TLAS instance data).
|
perf(rt): allocate TLAS instance buffer in BAR/VRAM, not system RAM (#65)
The TLAS instance buffer is rebuilt/refit every frame and is co-written by
both the CPU (host-authored fields) and the GPU (the compute shader writes
the transform field in place for transformOwnedByGpu elements). Allocated
HOST_VISIBLE | HOST_COHERENT (system RAM), both GPU accesses crossed PCIe:
the compute write of the transform, and the AS build's read of the instances.
Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference
(BAR/VRAM), keeping the single shared, persistently-mapped buffer — no staging,
no copy, no extra barrier. A whole-buffer staging copy was rejected: it would
clobber the GPU-written transforms in this in-place co-write design. Now the
compute write and AS build stay in local VRAM; the CPU's write-only,
sequential, never-read-back field writes are the ideal write-combined BAR load.
Correctness:
- DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back
to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR.
- HOST_COHERENT is dropped from the required set — the combined type may not be
coherent. The existing FlushDevice(cmd, ...) already establishes host->build
visibility and gates its flush-skip on the *chosen* type's flags (#60), so a
non-coherent BAR type still flushes correctly. The barrier is never removed.
metadataBuffer gets the same upgrade separately (#75).
Extends the TLASHighWaterMark hardware test to assert the chosen memory type
matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request and lands on a
DEVICE_LOCAL type where one is reachable; the existing zero-validation-errors
check covers the dropped HOST_COHERENT path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:37:39 +00:00
|
|
|
//
|
|
|
|
|
// instanceBuffer prefers HOST_VISIBLE | DEVICE_LOCAL (BAR/VRAM) so the
|
|
|
|
|
// two per-frame GPU accesses stay on-chip instead of crossing PCIe: the
|
|
|
|
|
// compute shader writes the transform field in place, and the AS build
|
|
|
|
|
// reads the whole instance. The CPU still writes the host-authored
|
|
|
|
|
// fields below — those are write-only, sequential, never-read-back
|
|
|
|
|
// writes, the ideal write-combined BAR workload. DEVICE_LOCAL is a
|
|
|
|
|
// best-effort preference: GetMemoryType falls back to plain
|
|
|
|
|
// HOST_VISIBLE (the previous behaviour) when no combined type exists
|
|
|
|
|
// (no resizable BAR). HOST_COHERENT is intentionally dropped from the
|
|
|
|
|
// required set — the combined type may not be coherent, and the
|
|
|
|
|
// FlushDevice(cmd, ...) below already establishes host->build
|
|
|
|
|
// visibility, gating its flush-skip on the *chosen* type's flags. The
|
|
|
|
|
// single shared buffer is preserved: a whole-buffer copy would clobber
|
|
|
|
|
// the GPU-written transforms, so staging is deliberately avoided.
|
perf(rt): allocate TLAS metadata buffer in BAR/VRAM, not system RAM (#75)
The TLAS metadata buffer is CPU-written every frame (one userMetadata word
per element in BuildTLAS's copy loop) and read by the ray shaders as a
STORAGE_BUFFER via device address every frame. Allocated
HOST_VISIBLE | HOST_COHERENT (system RAM), every per-frame shader read
traversed PCIe — the same cost the sibling instance buffer paid before #65.
Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference
(BAR/VRAM), keeping the single persistently-mapped buffer. The CPU's writes are
write-only, sequential, never-read-back — the ideal write-combined BAR load —
and the shader reads now hit local VRAM.
Unlike the instance buffer there is no GPU co-write, so the direct upgrade is
unconditional. Staging is still deliberately avoided: this buffer is rewritten
by the CPU every frame, so a per-frame stage+copy+barrier would defeat the
purpose (#75 caveat 2).
Correctness:
- DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back
to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR.
- HOST_COHERENT is dropped from the required set — the combined type may not be
coherent. A FlushDevice() is added after the copy loop to make the host writes
available on a non-coherent type; it self-gates to a no-op on a coherent type
(#60), so the previous behaviour is preserved wherever the type ends up
coherent. The metadata buffer is not an AS build input and has no GPU
co-write, so it needs neither the build-stage barrier the instance buffer
takes nor a HOST->shader command barrier: the queue submit already orders host
writes made available before it against every command in the submission,
exactly as it did when this buffer was HOST_COHERENT.
Extends the TLASHighWaterMark hardware test with a metadata-buffer assertion
block mirroring #65's instance-buffer check: the chosen memory type must equal
what GetMemoryType resolves for the HOST_VISIBLE | prefer-DEVICE_LOCAL request,
and must be DEVICE_LOCAL wherever a host-visible device-local type is reachable.
The zero-validation-errors check covers the dropped HOST_COHERENT path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:50:01 +00:00
|
|
|
//
|
|
|
|
|
// metadataBuffer (#75) gets the same HOST_VISIBLE | prefer-DEVICE_LOCAL
|
|
|
|
|
// upgrade: it is CPU-written every frame (the copy loop below) and read
|
|
|
|
|
// by the ray shaders as a STORAGE_BUFFER every frame, so it has the same
|
|
|
|
|
// per-frame-shader-read-over-PCIe cost. Unlike instanceBuffer there is
|
|
|
|
|
// no GPU co-write, so the direct upgrade is unconditional — but staging
|
|
|
|
|
// is still wrong here: a per-frame-rewritten buffer would pay a stage+
|
|
|
|
|
// copy+barrier every frame, defeating the point. HOST_COHERENT is
|
|
|
|
|
// likewise dropped; the FlushDevice() after the copy loop makes the host
|
|
|
|
|
// writes available before the consuming submit on a non-coherent type
|
|
|
|
|
// (no-op when the chosen type is coherent — the previous behaviour),
|
|
|
|
|
// and the queue submit carries the host-write -> shader-read ordering as
|
|
|
|
|
// it always has.
|
2026-10-19 03:03:16 +02:00
|
|
|
//
|
|
|
|
|
// At least one slot each: a TLAS built before anything is in the scene
|
|
|
|
|
// has zero instances, and a zero-size buffer is invalid (NVIDIA fails
|
|
|
|
|
// the allocation). The empty build reads no instance anyway.
|
2026-06-16 18:24:09 +00:00
|
|
|
if (tlas.instanceBuffer.buffer == VK_NULL_HANDLE
|
|
|
|
|
|| primitiveCount > tlas.instanceBuffer.size / sizeof(VkAccelerationStructureInstanceKHR)) {
|
2026-10-19 03:03:16 +02:00
|
|
|
const std::uint32_t capacity = std::max(primitiveCount, 1u);
|
|
|
|
|
tlas.instanceBuffer.Resize(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, capacity, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
|
|
|
|
tlas.metadataBuffer.Resize(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, capacity, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
2026-06-16 18:24:09 +00:00
|
|
|
}
|
perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#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>
2026-06-17 19:58:45 +00:00
|
|
|
|
|
|
|
|
// A topology change reshuffles which element occupies each slot (a count
|
|
|
|
|
// change, and Remove's swap-and-pop within it) and may have reallocated
|
|
|
|
|
// the host buffers just above — so every slot's prior contents are
|
|
|
|
|
// stale. Clear the per-slot version record (sized to the live count) so
|
|
|
|
|
// the copy loop below re-uploads all primitiveCount slots this frame.
|
|
|
|
|
tlas.uploadedVersion.assign(primitiveCount, 0);
|
2026-05-05 23:49:29 +02:00
|
|
|
}
|
2026-01-28 19:16:28 +01:00
|
|
|
|
perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#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>
2026-06-17 19:58:45 +00:00
|
|
|
// Copy only the slots whose host-authored data changed since this frame's
|
|
|
|
|
// buffers last saw them (#118), tracking the first/last dirty index so the
|
|
|
|
|
// flush below covers just that [first, last] envelope rather than the whole
|
|
|
|
|
// high-water capacity (the envelope may span a few clean slots between
|
|
|
|
|
// sparse dirty ones — flushing a couple of extra entries is far cheaper than
|
|
|
|
|
// the per-slot vkFlushMappedMemoryRanges calls true sub-ranges would need).
|
|
|
|
|
// A slot is dirty when its element advanced past the version
|
|
|
|
|
// recorded for it; an element left at version 0 (never MarkHostDataDirty'd)
|
|
|
|
|
// reads dirty every frame, preserving the pre-#118 copy-every-frame
|
|
|
|
|
// behaviour for callers that don't opt into dirty tracking. Globally-unique
|
|
|
|
|
// versions make this correct under relocation: a slot now holding a
|
|
|
|
|
// different element never matches the version recorded for its previous
|
|
|
|
|
// occupant. The GPU-owned transform is never copied here regardless — the
|
|
|
|
|
// application's compute shader writes it in place earlier in this submission.
|
|
|
|
|
bool anyDirty = false;
|
|
|
|
|
std::uint32_t dirtyFirst = 0;
|
|
|
|
|
std::uint32_t dirtyLast = 0;
|
2026-05-05 23:49:29 +02:00
|
|
|
for(std::uint32_t i = 0; i < primitiveCount; i++) {
|
perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#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>
2026-06-17 19:58:45 +00:00
|
|
|
const std::uint64_t version = elements[i]->hostDataVersion;
|
|
|
|
|
if (version != 0 && version == tlas.uploadedVersion[i]) {
|
|
|
|
|
continue; // slot already holds this element's current host data
|
|
|
|
|
}
|
2026-05-05 23:49:29 +02:00
|
|
|
if (elements[i]->transformOwnedByGpu) {
|
|
|
|
|
// Skip the transform field — the application's compute shader
|
|
|
|
|
// writes it earlier in this submission. Copy everything else.
|
|
|
|
|
auto& dst = tlas.instanceBuffer.value[i];
|
|
|
|
|
const auto& src = elements[i]->instance;
|
|
|
|
|
dst.instanceCustomIndex = src.instanceCustomIndex;
|
|
|
|
|
dst.mask = src.mask;
|
|
|
|
|
dst.instanceShaderBindingTableRecordOffset = src.instanceShaderBindingTableRecordOffset;
|
|
|
|
|
dst.flags = src.flags;
|
|
|
|
|
dst.accelerationStructureReference = src.accelerationStructureReference;
|
|
|
|
|
} else {
|
|
|
|
|
tlas.instanceBuffer.value[i] = elements[i]->instance;
|
|
|
|
|
}
|
|
|
|
|
tlas.metadataBuffer.value[i] = elements[i]->userMetadata;
|
perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#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>
2026-06-17 19:58:45 +00:00
|
|
|
tlas.uploadedVersion[i] = version;
|
|
|
|
|
if (!anyDirty) { dirtyFirst = i; anyDirty = true; }
|
|
|
|
|
dirtyLast = i;
|
2026-01-28 19:16:28 +01:00
|
|
|
}
|
2026-01-29 19:18:47 +01:00
|
|
|
|
perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#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>
2026-06-17 19:58:45 +00:00
|
|
|
if (anyDirty) {
|
|
|
|
|
// Flush (and, for the instance buffer, barrier) only the [dirtyFirst,
|
|
|
|
|
// dirtyLast] span actually rewritten this frame. When nothing is dirty
|
|
|
|
|
// there were no host writes at all, so both flushes — and the
|
|
|
|
|
// instanceBuffer HOST->build barrier — are skipped: the AS build reads
|
|
|
|
|
// memory already made visible by the submit that last wrote it, and any
|
|
|
|
|
// GPU-owned transform write is ordered by the application's own
|
|
|
|
|
// compute->build barrier, not this host barrier.
|
|
|
|
|
const std::uint32_t count = dirtyLast - dirtyFirst + 1;
|
|
|
|
|
constexpr VkDeviceSize instStride = sizeof(VkAccelerationStructureInstanceKHR);
|
|
|
|
|
constexpr VkDeviceSize metaStride = sizeof(std::uint32_t);
|
|
|
|
|
|
|
|
|
|
tlas.instanceBuffer.FlushDevice(
|
|
|
|
|
cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
|
|
|
|
|
static_cast<VkDeviceSize>(dirtyFirst) * instStride,
|
|
|
|
|
static_cast<VkDeviceSize>(count) * instStride);
|
|
|
|
|
|
|
|
|
|
// Make the per-frame metadata host writes available before the consuming
|
|
|
|
|
// submit. metadataBuffer is not an AS build input (the build never reads
|
|
|
|
|
// it) and has no GPU co-write — only the ray shaders read it, in a later
|
|
|
|
|
// pass — so it needs neither the build-stage barrier instanceBuffer takes
|
|
|
|
|
// above nor a HOST->shader command barrier: the queue submit already
|
|
|
|
|
// orders host writes made available before it against every command in
|
|
|
|
|
// the submission, exactly as it did when this buffer was HOST_COHERENT.
|
|
|
|
|
// This ranged host flush is what now makes those writes available on a
|
|
|
|
|
// non-coherent (BAR/VRAM) type; it self-gates to a no-op on a coherent
|
|
|
|
|
// type (#60), preserving the previous behaviour where the type ends up
|
|
|
|
|
// coherent.
|
|
|
|
|
tlas.metadataBuffer.FlushDevice(
|
|
|
|
|
static_cast<VkDeviceSize>(dirtyFirst) * metaStride,
|
|
|
|
|
static_cast<VkDeviceSize>(count) * metaStride);
|
|
|
|
|
}
|
perf(rt): allocate TLAS metadata buffer in BAR/VRAM, not system RAM (#75)
The TLAS metadata buffer is CPU-written every frame (one userMetadata word
per element in BuildTLAS's copy loop) and read by the ray shaders as a
STORAGE_BUFFER via device address every frame. Allocated
HOST_VISIBLE | HOST_COHERENT (system RAM), every per-frame shader read
traversed PCIe — the same cost the sibling instance buffer paid before #65.
Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference
(BAR/VRAM), keeping the single persistently-mapped buffer. The CPU's writes are
write-only, sequential, never-read-back — the ideal write-combined BAR load —
and the shader reads now hit local VRAM.
Unlike the instance buffer there is no GPU co-write, so the direct upgrade is
unconditional. Staging is still deliberately avoided: this buffer is rewritten
by the CPU every frame, so a per-frame stage+copy+barrier would defeat the
purpose (#75 caveat 2).
Correctness:
- DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back
to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR.
- HOST_COHERENT is dropped from the required set — the combined type may not be
coherent. A FlushDevice() is added after the copy loop to make the host writes
available on a non-coherent type; it self-gates to a no-op on a coherent type
(#60), so the previous behaviour is preserved wherever the type ends up
coherent. The metadata buffer is not an AS build input and has no GPU
co-write, so it needs neither the build-stage barrier the instance buffer
takes nor a HOST->shader command barrier: the queue submit already orders host
writes made available before it against every command in the submission,
exactly as it did when this buffer was HOST_COHERENT.
Extends the TLASHighWaterMark hardware test with a metadata-buffer assertion
block mirroring #65's instance-buffer check: the chosen memory type must equal
what GetMemoryType resolves for the HOST_VISIBLE | prefer-DEVICE_LOCAL request,
and must be DEVICE_LOCAL wherever a host-visible device-local type is reachable.
The zero-validation-errors check covers the dropped HOST_COHERENT path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:50:01 +00:00
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
VkAccelerationStructureGeometryInstancesDataKHR instancesData {
|
2026-01-28 19:16:28 +01:00
|
|
|
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_INSTANCES_DATA_KHR,
|
|
|
|
|
.arrayOfPointers = VK_FALSE,
|
2026-05-05 23:49:29 +02:00
|
|
|
.data = {tlas.instanceBuffer.address}
|
2026-01-28 19:16:28 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
VkAccelerationStructureGeometryDataKHR geometryData;
|
|
|
|
|
geometryData.instances = instancesData;
|
|
|
|
|
|
|
|
|
|
VkAccelerationStructureGeometryKHR tlasGeometry {
|
|
|
|
|
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR,
|
|
|
|
|
.geometryType = VK_GEOMETRY_TYPE_INSTANCES_KHR,
|
|
|
|
|
.geometry = geometryData
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
VkAccelerationStructureBuildGeometryInfoKHR tlasBuildGeometryInfo {
|
|
|
|
|
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR,
|
2026-01-28 19:16:28 +01:00
|
|
|
.type = VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR,
|
2026-06-16 15:49:17 +02:00
|
|
|
// ALLOW_UPDATE (required for any subsequent UPDATE-mode refit) plus
|
|
|
|
|
// the caller's FAST_TRACE/FAST_BUILD preference — see buildFlags above.
|
|
|
|
|
.flags = buildFlags,
|
2026-05-05 23:49:29 +02:00
|
|
|
.mode = topologyChanged
|
|
|
|
|
? VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR
|
|
|
|
|
: VK_BUILD_ACCELERATION_STRUCTURE_MODE_UPDATE_KHR,
|
2026-01-28 19:16:28 +01:00
|
|
|
.geometryCount = 1,
|
|
|
|
|
.pGeometries = &tlasGeometry
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
if (topologyChanged) {
|
|
|
|
|
// Query sizes for the fresh build, allocate AS storage + scratch.
|
|
|
|
|
VkAccelerationStructureBuildSizesInfoKHR tlasBuildSizes {
|
|
|
|
|
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR
|
|
|
|
|
};
|
|
|
|
|
Device::vkGetAccelerationStructureBuildSizesKHR(
|
|
|
|
|
Device::device,
|
|
|
|
|
VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR,
|
|
|
|
|
&tlasBuildGeometryInfo,
|
|
|
|
|
&primitiveCount,
|
|
|
|
|
&tlasBuildSizes
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Scratch buffer must hold at least max(buildScratchSize, updateScratchSize).
|
|
|
|
|
// Sizing for buildScratchSize covers both — refit is always smaller.
|
2026-10-19 03:03:16 +02:00
|
|
|
// An empty TLAS may need no scratch at all, but the buffer can't be
|
|
|
|
|
// zero-size.
|
|
|
|
|
tlas.scratchBuffer.Resize(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, std::max<VkDeviceSize>(tlasBuildSizes.buildScratchSize, 1));
|
2026-05-05 23:49:29 +02:00
|
|
|
tlas.buffer.Resize(VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, tlasBuildSizes.accelerationStructureSize);
|
|
|
|
|
|
|
|
|
|
// Destroy the previous AS handle before creating a new one — the
|
|
|
|
|
// pre-refit path leaked here on every frame.
|
|
|
|
|
if (tlas.accelerationStructure != VK_NULL_HANDLE) {
|
|
|
|
|
Device::vkDestroyAccelerationStructureKHR(Device::device, tlas.accelerationStructure, nullptr);
|
|
|
|
|
tlas.accelerationStructure = VK_NULL_HANDLE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VkAccelerationStructureCreateInfoKHR tlasCreateInfo {
|
|
|
|
|
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR,
|
|
|
|
|
.buffer = tlas.buffer.buffer,
|
|
|
|
|
.offset = 0,
|
|
|
|
|
.size = tlasBuildSizes.accelerationStructureSize,
|
|
|
|
|
.type = VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR,
|
|
|
|
|
};
|
|
|
|
|
Device::CheckVkResult(Device::vkCreateAccelerationStructureKHR(Device::device, &tlasCreateInfo, nullptr, &tlas.accelerationStructure));
|
2026-01-28 19:16:28 +01:00
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
VkAccelerationStructureDeviceAddressInfoKHR addrInfo {
|
|
|
|
|
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_DEVICE_ADDRESS_INFO_KHR,
|
|
|
|
|
.accelerationStructure = tlas.accelerationStructure
|
|
|
|
|
};
|
|
|
|
|
tlas.address = Device::vkGetAccelerationStructureDeviceAddressKHR(Device::device, &addrInfo);
|
2026-01-28 19:16:28 +01:00
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
tlas.builtInstanceCount = primitiveCount;
|
2026-06-16 15:49:17 +02:00
|
|
|
tlas.builtFlags = buildFlags;
|
2026-05-05 23:49:29 +02:00
|
|
|
}
|
2026-01-28 19:16:28 +01:00
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
// For UPDATE mode, src == dst (in-place refit). For BUILD, src is
|
|
|
|
|
// VK_NULL_HANDLE and dst is the freshly-created handle.
|
|
|
|
|
tlasBuildGeometryInfo.scratchData.deviceAddress = tlas.scratchBuffer.address;
|
|
|
|
|
tlasBuildGeometryInfo.dstAccelerationStructure = tlas.accelerationStructure;
|
|
|
|
|
tlasBuildGeometryInfo.srcAccelerationStructure =
|
|
|
|
|
topologyChanged ? VK_NULL_HANDLE : tlas.accelerationStructure;
|
2026-01-28 19:16:28 +01:00
|
|
|
|
|
|
|
|
VkAccelerationStructureBuildRangeInfoKHR tlasRangeInfo {
|
|
|
|
|
.primitiveCount = primitiveCount,
|
|
|
|
|
.primitiveOffset = 0,
|
|
|
|
|
.firstVertex = 0,
|
|
|
|
|
.transformOffset = 0
|
|
|
|
|
};
|
|
|
|
|
VkAccelerationStructureBuildRangeInfoKHR* tlasRangeInfoPP = &tlasRangeInfo;
|
2026-03-09 20:10:19 +01:00
|
|
|
Device::vkCmdBuildAccelerationStructuresKHR(cmd, 1, &tlasBuildGeometryInfo, &tlasRangeInfoPP);
|
2026-01-30 00:09:37 +01:00
|
|
|
|
2026-10-19 03:03:16 +02:00
|
|
|
const TlasReadBarrier readBarrier = BuildTlasReadBarrier();
|
|
|
|
|
vkCmdPipelineBarrier(cmd, readBarrier.srcStage, readBarrier.dstStage, 0, 1, &readBarrier.memory, 0, nullptr, 0, nullptr);
|
2026-05-01 23:35:37 +02:00
|
|
|
}
|