Crafter.Graphics/examples/RTVolume/main.cpp
Jorijn van der Graaf 852ae8abe3
fix(descriptor-heap): round the buffer region up, align the reserved range for buffers too, and never size a heap at zero
DescriptorHeapVulkan
- The buffer region started at images * imageDescriptorSize /
  bufferDescriptorSize, rounded down. A descriptor_heap array indexes
  the heap from byte 0 in strides of its descriptor size, so buffer slot
  0 has to sit on a whole buffer descriptor past the images. Where the
  image descriptors were not a whole number of buffer descriptors, the
  first buffer descriptors overwrote the last image descriptors. Where
  they were less than one, the start was bumped to descriptor 1, but the
  heap was still sized as if the buffers began right after the images,
  so the last buffer descriptors went into the implementation's reserved
  range. Every descriptor size is a power of two, so both cases need
  image descriptors smaller than buffer descriptors, which NVIDIA (32
  and 16 bytes) and llvmpipe (64 and 16) do not have. The start now
  rounds up and the heap is sized from it (#163).
- The reserved range sat at a multiple of imageDescriptorAlignment only,
  where vkCmdBindResourceHeapEXT also wants bufferDescriptorAlignment
  (VUID-vkCmdBindResourceHeapEXT-pBindInfo-11435 and -11436). It now
  starts on a multiple of the larger. The buffer descriptors need no more
  than the rounding: each alignment is at most its descriptor size, so a
  whole number of descriptors is aligned.
- bufferStartElement is 16 bits and took the division unchecked, so an
  image region past 65535 buffer descriptors (16384 images on llvmpipe)
  wrapped the buffer region around onto the images. Slot counts whose
  buffer indices do not fit 16 bits, 0xFFFF being the empty slot, now
  throw, naming the counts. A heap without buffer slots has no buffer
  region, which starts at 0 as in descriptor-set mode, so image-only
  heaps have no index to fit.
- On a driver that reserves nothing (llvmpipe), a heap without sampler
  slots was a zero-size buffer, which every Initialize(1, 1, 0) made,
  and Window::Render bound its address each frame. Each heap now holds at
  least one descriptor, as each binding does in descriptor-set mode. That
  grows NVIDIA's sampler heap by one descriptor when no sampler slots are
  asked for; the resource heaps of the slot counts in the tree keep their
  layouts byte for byte.
- ComputeLayout holds the arithmetic, pure over the descriptor-heap
  properties, and Initialize goes through it. GetBufferOffset and
  GetBufferOffsetElement, which had their own copies of the old
  arithmetic and never had a caller, are gone.
- Bind(cmd, frame) binds a frame's heaps with the reserved ranges
  Initialize stored, or in descriptor-set mode the frame's descriptor
  set. Window::Render and the RTVolume example worked the reserved-range
  offset out from the heap's size with the image-alignment mask, and
  call Bind now. The window's per-heap cache of bind structs goes with
  it: keyed on the heap pointer, it kept binding the old buffers after a
  re-Initialize that reallocated them. The heap still ends with its
  reserved range, so code that derives the offset from the size keeps
  getting the right one.

Tests
- DescriptorHeapLayout: the issue's two layouts, the reserved range where
  buffer descriptors are aligned more strictly than image ones, every
  legal combination of descriptor sizes and alignments up to 256 bytes,
  no heap empty, the NVIDIA and llvmpipe layouts, the 16-bit limit and
  image-only heaps. Pure CPU, no device. Against the old arithmetic it
  fails 12 of its 18 checks.

Docs
- README.md's heap bullet and AGENTS.consumer.md say how to bind the
  heap outside the frame loop, and the consumer notes that Initialize
  throws when buffer indices would pass 16 bits. AGENTS.md lists
  ComputeLayout among the functions the pure-logic tests drive.

Closes #163.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:06:44 +01:00

440 lines
21 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// RTVolume — procedural (AABB) ray tracing on both backends. Demonstrates
// the two features this example was written to exercise:
//
// * VK_GEOMETRY_TYPE_AABBS_KHR — a BLAS built from AABBs
// (Mesh::BuildProcedural) whose surface is supplied by an intersection
// shader (here an analytic ray–sphere test). The boxes are unit cubes
// [-1,1]^3; the intersection shader turns each into a sphere.
//
// * any-hit — the spheres are registered non-opaque, and an any-hit
// shader punches a spherical checkerboard of holes by ignoring the
// intersection for half the cells. Without any-hit the spheres are
// solid; with it you can see the background (and other spheres)
// through the cut-out cells.
//
// A 3×3×3 grid of these procedural spheres is shaded by surface normal +
// a fixed sun. The Vulkan path runs the same scene through hardware RT
// (PROCEDURAL_HIT_GROUP_KHR with GLSL intersection / any-hit shaders, a
// fixed camera in raygen.glsl); the WebGPU path is the software wavefront
// tracer with a host-driven free camera.
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h"
import Crafter.Graphics;
import Crafter.Math;
import std;
using namespace Crafter;
namespace {
constexpr int kGrid = 3;
constexpr float kSpacing = 3.0f;
}
int main() {
const int instanceCount = kGrid * kGrid * kGrid;
std::println("[RTVolume] grid {}^3 = {} procedural spheres", kGrid, instanceCount);
// The raygen takes the heap's buffer start as a uint16_t spec constant,
// and aabbs.comp declares its AABB buffer `layout(scalar)`.
Device::Initialize({.vulkan10 = {.shaderInt16 = VK_TRUE}, .vulkan12 = {.scalarBlockLayout = VK_TRUE}});
Window window(1280, 720, "RTVolume");
VkCommandBuffer cmd = window.StartInit();
DescriptorHeapVulkan descriptorHeap;
descriptorHeap.Initialize(/*images*/ 1, /*buffers*/ 1, /*samplers*/ 0);
// Specialization constant: the TLAS slot offset (same pattern as the
// Sponza example). Camera is fixed in raygen.glsl — no user buffers.
VkSpecializationMapEntry raygenEntry = { .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) };
VkSpecializationInfo raygenSpec = {
.mapEntryCount = 1, .pMapEntries = &raygenEntry,
.dataSize = sizeof(std::uint16_t), .pData = &descriptorHeap.bufferStartElement,
};
auto imgSlots = descriptorHeap.AllocateImageSlots(1);
auto bufSlots = descriptorHeap.AllocateBufferSlots(1);
// SBT order fixes the shader indices used by the groups below.
std::array<VulkanShader, 5> shaders {{
{ "raygen.spv", "main", VK_SHADER_STAGE_RAYGEN_BIT_KHR, &raygenSpec },
{ "miss.spv", "main", VK_SHADER_STAGE_MISS_BIT_KHR, nullptr },
{ "closesthit.spv", "main", VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR, nullptr },
{ "anyhit.spv", "main", VK_SHADER_STAGE_ANY_HIT_BIT_KHR, nullptr },
{ "intersection.spv", "main", VK_SHADER_STAGE_INTERSECTION_BIT_KHR, nullptr },
}};
ShaderBindingTableVulkan shaderTable;
shaderTable.Init(shaders);
std::array<VkRayTracingShaderGroupCreateInfoKHR, 1> raygenGroups {{ {
.sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR,
.type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR,
.generalShader = 0, .closestHitShader = VK_SHADER_UNUSED_KHR,
.anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR,
} }};
std::array<VkRayTracingShaderGroupCreateInfoKHR, 1> missGroups {{ {
.sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR,
.type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR,
.generalShader = 1, .closestHitShader = VK_SHADER_UNUSED_KHR,
.anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR,
} }};
// One procedural hit group: closest-hit + any-hit + intersection.
std::array<VkRayTracingShaderGroupCreateInfoKHR, 1> hitGroups {{ {
.sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR,
.type = VK_RAY_TRACING_SHADER_GROUP_TYPE_PROCEDURAL_HIT_GROUP_KHR,
.generalShader = VK_SHADER_UNUSED_KHR, .closestHitShader = 2,
.anyHitShader = 3, .intersectionShader = 4,
} }};
PipelineRTVulkan pipeline;
pipeline.Init(cmd, raygenGroups, missGroups, hitGroups, shaderTable);
// ── One procedural unit-box BLAS, built from a DEVICE buffer the GPU
// writes (issue #37). A compute shader (aabbs.comp.glsl) writes the
// box [-r,r]^3 into a device-local buffer; that buffer is fed straight
// into Mesh::BuildProcedural by device address — no host copy of the
// AABBs. The intersection shader then treats the box as the bounding
// volume of a sphere centred at the object origin. opaque=false so the
// any-hit cut-out runs. ──────────────────────────────────────────────
constexpr std::uint32_t kBoxCount = 1;
VulkanBuffer<RTAabb, false> aabbDevice;
aabbDevice.Resize(
VK_BUFFER_USAGE_2_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR
| VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
| VK_BUFFER_USAGE_STORAGE_BUFFER_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, kBoxCount);
// The producer reaches its output through a buffer_reference (device
// address pushed below). It binds no heap descriptors, but a
// descriptor-heap compute pipeline still expects a heap bound for its
// push-data path — the render loop binds it every frame, but the init
// command buffer hasn't, so bind it here before dispatching.
descriptorHeap.Bind(cmd, window.currentBuffer);
ComputeShader aabbWriter;
aabbWriter.Load("aabbs.comp.spv", descriptorHeap);
struct AabbPush { VkDeviceAddress boxes; std::uint32_t count; float time; } push {
aabbDevice.address, kBoxCount, 0.0f };
aabbWriter.Dispatch(cmd, &push, sizeof(push), (kBoxCount + 63) / 64);
// Order the producing writes before the BLAS build reads the buffer. A
// build reads its geometry as a shader read.
VkMemoryBarrier aabbBarrier {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
0, 1, &aabbBarrier, 0, nullptr, 0, nullptr);
Mesh sphere;
// allowUpdate so the GPU-written boxes can be refit in place each frame
// (the per-frame companion to this build — see the headless
// BLASBuildOptions test for the device-buffer refit path).
sphere.BuildProcedural(aabbDevice.address, kBoxCount, /*opaque*/ false, cmd,
RTBuildOptions{ .allowUpdate = true });
// ── Instance grid. ─────────────────────────────────────────────────
static std::vector<RenderingElement3D> renderers;
renderers.reserve(static_cast<std::size_t>(instanceCount));
const float origin0 = -0.5f * static_cast<float>(kGrid - 1) * kSpacing;
for (int x = 0; x < kGrid; ++x)
for (int y = 0; y < kGrid; ++y)
for (int z = 0; z < kGrid; ++z) {
renderers.emplace_back();
RenderingElement3D& r = renderers.back();
auto& tx = r.instance.transform.matrix;
tx[0][0] = 1; tx[0][1] = 0; tx[0][2] = 0; tx[0][3] = origin0 + float(x) * kSpacing;
tx[1][0] = 0; tx[1][1] = 1; tx[1][2] = 0; tx[1][3] = origin0 + float(y) * kSpacing;
tx[2][0] = 0; tx[2][1] = 0; tx[2][2] = 1; tx[2][3] = origin0 + float(z) * kSpacing;
r.instance.instanceCustomIndex = static_cast<std::uint32_t>(renderers.size() - 1);
r.instance.mask = 0xFF;
r.instance.instanceShaderBindingTableRecordOffset = 0;
// flags = 0: do NOT force opaque, so the any-hit shader runs.
r.instance.flags = 0;
r.instance.accelerationStructureReference = sphere.blasAddr;
RenderingElement3D::Add(&r);
}
for (std::uint32_t f = 0; f < Window::numFrames; ++f) {
RenderingElement3D::BuildTLAS(cmd, f);
}
window.FinishInit();
// Write descriptors: TLAS at bufSlots[0], output image at imgSlots[0].
// Per-frame replicated — same pattern as the Sponza example.
VkDeviceAddressRangeKHR tlasRanges[Window::numFrames];
VkImageDescriptorInfoEXT outImgInfos[Window::numFrames];
for (std::uint32_t f = 0; f < Window::numFrames; ++f) {
tlasRanges[f] = { .address = RenderingElement3D::tlases[f].address };
outImgInfos[f] = {
.sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT,
.pView = &window.imageViews[f],
.layout = VK_IMAGE_LAYOUT_GENERAL,
};
}
std::vector<VkResourceDescriptorInfoEXT> resources;
std::vector<VkHostAddressRangeEXT> destinations;
resources.reserve(Window::numFrames * 2);
destinations.reserve(Window::numFrames * 2);
for (std::uint32_t f = 0; f < Window::numFrames; ++f) {
resources.push_back({
.sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT,
.type = VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR,
.data = { .pAddressRange = &tlasRanges[f] },
});
destinations.push_back({
.address = descriptorHeap.resourceHeap[f].value
+ descriptorHeap.BufferByteOffset(bufSlots.firstElement),
.size = Device::descriptorHeapProperties.bufferDescriptorSize,
});
resources.push_back({
.sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT,
.type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
.data = { .pImage = &outImgInfos[f] },
});
destinations.push_back({
.address = descriptorHeap.resourceHeap[f].value
+ descriptorHeap.ImageByteOffset(imgSlots.firstElement),
.size = Device::descriptorHeapProperties.imageDescriptorSize,
});
}
Device::vkWriteResourceDescriptorsEXT(Device::device,
static_cast<std::uint32_t>(resources.size()),
resources.data(), destinations.data());
for (std::uint32_t f = 0; f < Window::numFrames; ++f) {
descriptorHeap.resourceHeap[f].FlushDevice();
}
window.descriptorHeap = &descriptorHeap;
RTPass rtPass(&pipeline);
window.passes.push_back(&rtPass);
window.Render();
window.StartSync();
return 0;
}
#else
import Crafter.Graphics;
import Crafter.Math;
import Crafter.Event;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
constexpr int kGrid = 3;
constexpr float kSpacing = 3.0f;
struct CameraGPU {
float origin[3]; float pad0;
float right[3]; float tanHalf;
float up[3]; float aspect;
float forward[3]; float pad1;
};
static_assert(sizeof(CameraGPU) == 64);
}
int main() {
const int instanceCount = kGrid * kGrid * kGrid;
std::println("[RTVolume] grid {}^3 = {} procedural spheres", kGrid, instanceCount);
Device::Initialize();
static Window window(1280, 720, "RTVolume");
auto cmd = window.StartInit();
DescriptorHeapWebGPU heap;
heap.Initialize(/*images*/ 1, /*buffers*/ 2, /*samplers*/ 1);
// SBT order fixes the shader indices used by the groups below.
std::array<WebGPUShader, 6> shaders {{
WebGPUShader(fs::path("raygen.wgsl"), "raygen_main", WebGPURTStage::Raygen),
WebGPUShader(fs::path("miss.wgsl"), "miss_main", WebGPURTStage::Miss),
WebGPUShader(fs::path("closesthit.wgsl"), "closesthit_main", WebGPURTStage::ClosestHit),
WebGPUShader(fs::path("anyhit.wgsl"), "anyhit_main", WebGPURTStage::AnyHit),
WebGPUShader(fs::path("intersection.wgsl"), "intersection_main", WebGPURTStage::Intersection),
WebGPUShader(fs::path("resolve.wgsl"), "resolve_main", WebGPURTStage::Resolve),
}};
ShaderBindingTableWebGPU sbt;
sbt.Init(shaders);
std::array<RTShaderGroup, 1> raygenGroups {{ { .type = RTShaderGroupType::General, .generalShader = 0 } }};
std::array<RTShaderGroup, 1> missGroups {{ { .type = RTShaderGroupType::General, .generalShader = 1 } }};
// One procedural hit group: closest-hit + any-hit + intersection.
std::array<RTShaderGroup, 1> hitGroups {{ {
.type = RTShaderGroupType::ProceduralHitGroup,
.closestHitShader = 2,
.anyHitShader = 3,
.intersectionShader = 4,
} }};
std::array<UICustomBinding, 1> bindings {{
{ .group = 3, .binding = 0, .kind = UICustomBindingKind::Buffer, .pushOffset = 0 },
}};
PipelineRTWebGPU pipeline;
pipeline.Init(cmd, raygenGroups, missGroups, hitGroups, sbt, bindings);
// ── One procedural unit-box BLAS, built from a DEVICE buffer the GPU
// writes (issue #37). A compute shader (aabbs.comp.wgsl) writes the
// box [-r,r]^3 into a device storage buffer; that buffer is fed
// straight into Mesh::BuildProcedural by handle — no host copy of the
// AABBs — and RefitProcedural re-consumes it each frame so the boxes
// can breathe. The intersection shader treats the box as the bounding
// volume of a sphere; opaque=false so the any-hit cut-out runs. ──────
constexpr std::uint32_t kBoxCount = 1;
// worldBounds must enclose every box the producer can write (r ≤ 1.15).
constexpr RTAabb kWorldBounds { .min = {-1.2f, -1.2f, -1.2f}, .max = {1.2f, 1.2f, 1.2f} };
static WebGPUBuffer<RTAabb, false> aabbBuf;
aabbBuf.Create(kBoxCount);
// GPU producer: writes the boxes into aabbBuf. No rayQuery → the storage
// buffer binding lives at group 1.
struct AabbParams { std::uint32_t count; float time; std::uint32_t _0, _1; };
std::array<UICustomBinding, 1> aabbBindings {{
{ .group = 1, .binding = 0, .kind = UICustomBindingKind::BufferReadWrite, .pushOffset = 0 },
}};
static PlainComputeShader aabbWriter;
aabbWriter.Load(fs::path("aabbs.comp.wgsl"), sizeof(AabbParams), aabbBindings);
static std::array<std::uint32_t, 1> aabbHandles { aabbBuf.handle };
{
AabbParams params { kBoxCount, 0.0f, 0, 0 };
aabbWriter.Dispatch(&params, sizeof(params), aabbHandles, (kBoxCount + 63u) / 64u);
}
static Mesh sphere;
sphere.BuildProcedural(aabbBuf.handle, kBoxCount, kWorldBounds, /*opaque*/ false, cmd);
// ── Camera buffer + handle array. ─────────────────────────────────
WebGPUBuffer<CameraGPU, true> cameraBuf;
cameraBuf.Create(1);
static std::array<std::uint32_t, 1> userHandles { cameraBuf.handle };
// ── Instance grid. ────────────────────────────────────────────────
static std::vector<RenderingElement3D> renderers;
renderers.reserve(static_cast<std::size_t>(instanceCount));
const float origin0 = -0.5f * static_cast<float>(kGrid - 1) * kSpacing;
for (int x = 0; x < kGrid; ++x)
for (int y = 0; y < kGrid; ++y)
for (int z = 0; z < kGrid; ++z) {
renderers.emplace_back();
RenderingElement3D& r = renderers.back();
auto& tx = r.instance.transform.matrix;
tx[0][0] = 1; tx[0][1] = 0; tx[0][2] = 0; tx[0][3] = origin0 + float(x) * kSpacing;
tx[1][0] = 0; tx[1][1] = 1; tx[1][2] = 0; tx[1][3] = origin0 + float(y) * kSpacing;
tx[2][0] = 0; tx[2][1] = 0; tx[2][2] = 1; tx[2][3] = origin0 + float(z) * kSpacing;
r.instance.instanceCustomIndex = static_cast<std::uint32_t>(renderers.size() - 1);
r.instance.mask = 0xFF;
r.instance.instanceShaderBindingTableRecordOffset = 0;
// flags = 0: do NOT force opaque, so the any-hit shader runs.
r.instance.flags = 0;
r.instance.accelerationStructureReference = sphere.blasAddr;
RenderingElement3D::Add(&r);
}
RenderingElement3D::BuildTLAS(cmd, 0);
window.descriptorHeap = &heap;
window.FinishInit();
RTPass rtPass(&pipeline);
rtPass.handlesPtr = userHandles.data();
rtPass.handlesCount = static_cast<std::uint32_t>(userHandles.size());
rtPass.maxDepth = 1; // primary only
window.passes.push_back(&rtPass);
// ── Free camera framing the grid. ─────────────────────────────────
const float ext = float(kGrid - 1) * kSpacing;
struct CamState {
Vector<float, 3, 4> position;
float yaw;
float pitch;
} cam {
Vector<float, 3, 4>{ ext * 1.1f, ext * 0.8f, ext * 1.6f },
0.0f, 0.0f,
};
{
Vector<float, 3, 4> d { -cam.position.x, -cam.position.y, -cam.position.z };
const float len = std::sqrt(d.x*d.x + d.y*d.y + d.z*d.z);
cam.yaw = std::atan2(d.z, d.x);
cam.pitch = std::asin(d.y / len);
}
Input::Map inputMap;
Input::Action& moveAct = inputMap.AddAction("Move", Input::ActionType::Vector2);
Input::Action& lookAct = inputMap.AddAction("Look", Input::ActionType::Vector2);
moveAct.bindings = { Input::WASDBind{
Key(CrafterKeys::W), Key(CrafterKeys::S), Key(CrafterKeys::A), Key(CrafterKeys::D) } };
lookAct.bindings = { Input::MouseDeltaBind{ 1.0f } };
inputMap.Attach(window);
const float kMoveSpeed = ext * 0.8f + 1.0f;
const float kLookSens = 0.05f;
const float kDt = 1.0f / 60.0f;
EventListener<void> camTick(&window.onBeforeUpdate, [&]() {
inputMap.Tick();
cam.yaw += lookAct.vector2.x * kLookSens;
cam.pitch -= lookAct.vector2.y * kLookSens;
cam.pitch = std::clamp(cam.pitch, -1.55f, 1.55f);
const float cp = std::cos(cam.pitch), sp = std::sin(cam.pitch);
const float cy = std::cos(cam.yaw), sy = std::sin(cam.yaw);
Vector<float, 3, 4> forward { cp * cy, sp, cp * sy };
Vector<float, 3, 4> worldUp { 0.0f, 1.0f, 0.0f };
Vector<float, 3, 4> right { forward.y*worldUp.z - forward.z*worldUp.y,
forward.z*worldUp.x - forward.x*worldUp.z,
forward.x*worldUp.y - forward.y*worldUp.x };
const float rLen = std::sqrt(right.x*right.x + right.y*right.y + right.z*right.z);
right.x /= rLen; right.y /= rLen; right.z /= rLen;
Vector<float, 3, 4> up { right.y*forward.z - right.z*forward.y,
right.z*forward.x - right.x*forward.z,
right.x*forward.y - right.y*forward.x };
const float dx = moveAct.vector2.x * kMoveSpeed * kDt;
const float dy = moveAct.vector2.y * kMoveSpeed * kDt;
cam.position.x += right.x*dx + forward.x*dy;
cam.position.y += right.y*dx + forward.y*dy;
cam.position.z += right.z*dx + forward.z*dy;
CameraGPU& g = cameraBuf.value[0];
g.origin[0]=cam.position.x; g.origin[1]=cam.position.y; g.origin[2]=cam.position.z; g.pad0=0;
g.right[0]=right.x; g.right[1]=right.y; g.right[2]=right.z;
g.up[0]=up.x; g.up[1]=up.y; g.up[2]=up.z;
g.forward[0]=forward.x; g.forward[1]=forward.y; g.forward[2]=forward.z;
g.aspect = float(window.width) / float(window.height);
g.tanHalf = std::tan(70.0f * 3.14159265f / 360.0f);
g.pad1 = 0;
cameraBuf.FlushDevice();
});
// ── Per-frame procedural refit from the device buffer (issue #37). The
// GPU producer rewrites the breathing box into aabbBuf each frame and
// RefitProcedural re-consumes it by handle — no host copy, and the
// BLAS handle (blasAddr) stays stable so the TLAS instances built
// above keep referencing it. worldBounds is constant so the TLAS need
// not rebuild. ───────────────────────────────────────────────────────
EventListener<void> aabbTick(&window.onBeforeUpdate, [&]() {
static float t = 0.0f;
t += kDt;
AabbParams params { kBoxCount, t, 0, 0 };
aabbWriter.Dispatch(&params, sizeof(params), aabbHandles, (kBoxCount + 63u) / 64u);
sphere.RefitProcedural(aabbBuf.handle, kBoxCount, kWorldBounds);
});
window.Render();
window.StartUpdate();
window.StartSync();
return 0;
}
#endif