test(vulkan-rt): port RTVolume example to native Vulkan (#33)

Regression test for the procedural BLAS path: the same 3x3x3 grid of
unit-box AABBs runs through a PROCEDURAL_HIT_GROUP_KHR group whose GLSL
intersection shader (reportIntersectionEXT) turns each box into a
radius-1 sphere, the any-hit shader punches the spherical-checkerboard
cut-out (visible proof non-opaque geometry runs any-hit), and the
closest-hit shades per-instance tints — the WebGPU example behavior
reproduced natively. Fixed camera in raygen.glsl; the WebGPU/DOM path is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-13 00:17:37 +00:00
commit 40c4184d41
8 changed files with 357 additions and 18 deletions

View file

@ -1,26 +1,36 @@
# RTVolume # RTVolume
WebGPU software ray tracing of **procedural (AABB) geometry** with an Ray tracing of **procedural (AABB) geometry** with an **any-hit** cut-out
**any-hit** cut-out — the two features added for issue #13. on both backends — software WebGPU (issue #13) and native Vulkan hardware
RT (issue #33).
A 3×3×3 grid of unit boxes is registered as an AABB BLAS A 3×3×3 grid of unit boxes is registered as an AABB BLAS
(`Mesh::BuildProcedural`, the WebGPU analog of `VK_GEOMETRY_TYPE_AABBS_KHR`). (`Mesh::BuildProcedural``VK_GEOMETRY_TYPE_AABBS_KHR` on Vulkan, the
The hit group is a `RTShaderGroupType::ProceduralHitGroup` carrying: software AABB-leaf path on WebGPU). The hit group is procedural
(`RTShaderGroupType::ProceduralHitGroup` /
`VK_RAY_TRACING_SHADER_GROUP_TYPE_PROCEDURAL_HIT_GROUP_KHR`) carrying:
- `intersection.wgsl` — analytic raysphere test that turns each box into a - `intersection.wgsl` / `intersection.glsl` — analytic raysphere test that
radius-1 sphere (runs in TRACE, once per box the ray enters); turns each box into a radius-1 sphere (runs once per box the ray enters);
- `anyhit.wgsl` — returns `RT_ANYHIT_IGNORE` for half the cells of a - `anyhit.wgsl` / `anyhit.glsl` — ignores the intersection for half the
spherical checkerboard, so the ray passes through and the background / cells of a spherical checkerboard, so the ray passes through and the
spheres behind show through (the visible proof any-hit runs); background / spheres behind show through (the visible proof any-hit runs);
- `closesthit.wgsl` — normal-based Lambert shading, tinted per instance. - `closesthit.wgsl` / `closesthit.glsl` — normal-based Lambert shading,
tinted per instance.
The geometry is registered **non-opaque** and the instances clear their The geometry is registered **non-opaque** and the instances clear their
force-opaque flag, which is what lets the any-hit shader run. Flip the force-opaque flag, which is what lets the any-hit shader run. Flip the
instance flag to `kRTGeometryInstanceForceOpaque` (or build the mesh with instance flag to `kRTGeometryInstanceForceOpaque` (or build the mesh with
`opaque = true`) to skip any-hit and see solid spheres. `opaque = true`) to skip any-hit and see solid spheres.
WebGPU/DOM only: WebGPU/DOM (free camera, WASD + mouse):
``` ```
crafter-build --target=wasm32-wasip1 -r crafter-build --target=wasm32-wasip1 -r
``` ```
Native Vulkan (fixed camera in raygen.glsl):
```
crafter-build -r
```

View file

@ -0,0 +1,30 @@
#version 460
#extension GL_EXT_ray_tracing : enable
// RTVolume any-hit shader — runs on every candidate sphere hit because
// the geometry is built non-opaque (BuildProcedural opaque=false) and
// the instances don't force-opaque. Punches a spherical checkerboard of
// holes: for half the cells it calls ignoreIntersectionEXT, so the ray
// passes straight through and the background / spheres behind show
// through — the visible proof the any-hit path runs. Mirrors
// anyhit.wgsl on the WebGPU path.
hitAttributeEXT vec2 attribs;
layout(location = 0) rayPayloadInEXT vec3 hitValue;
void main() {
// Object-space hit point on the unit sphere → its normal/direction.
vec3 posObj = gl_ObjectRayOriginEXT + gl_ObjectRayDirectionEXT * gl_HitTEXT;
vec3 n = normalize(posObj);
const float PI = 3.14159265;
float longitude = atan(n.z, n.x); // [-PI, PI]
float latitude = asin(clamp(n.y, -1.0, 1.0)); // [-PI/2, PI/2]
int cu = int(floor((longitude + PI) / PI * 6.0));
int cv = int(floor((latitude + PI * 0.5) / PI * 6.0));
if (((cu + cv) & 1) == 0) {
ignoreIntersectionEXT; // cut-out cell — see through
}
}

View file

@ -0,0 +1,38 @@
#version 460
#extension GL_EXT_ray_tracing : enable
// RTVolume closest-hit — shades the committed procedural sphere hit by
// its surface normal with a fixed sun + ambient, tinted per instance
// (gl_InstanceCustomIndexEXT). Mirrors closesthit.wgsl on the WebGPU
// path.
hitAttributeEXT vec2 attribs;
layout(location = 0) rayPayloadInEXT vec3 hitValue;
const vec3 SUN_DIR_TO_LIGHT = vec3(0.40, 0.85, 0.35);
const vec3 SUN_COLOR = vec3(1.20, 1.10, 0.95);
const vec3 AMBIENT_COLOR = vec3(0.16, 0.18, 0.24);
vec3 instanceAlbedo(uint i) {
uint h = i * 2654435761u;
return vec3(
0.35 + 0.6 * float((h >> 0) & 255u) / 255.0,
0.35 + 0.6 * float((h >> 8) & 255u) / 255.0,
0.35 + 0.6 * float((h >> 16) & 255u) / 255.0);
}
void main() {
// Object-space hit point on the unit sphere is its object-space
// normal; gl_ObjectToWorldEXT's rotation part takes it to world.
vec3 posObj = gl_ObjectRayOriginEXT + gl_ObjectRayDirectionEXT * gl_HitTEXT;
vec3 nObj = normalize(posObj);
vec3 nWorld = normalize(gl_ObjectToWorldEXT * vec4(nObj, 0.0));
vec3 albedo = instanceAlbedo(uint(gl_InstanceCustomIndexEXT));
vec3 viewDir = -gl_WorldRayDirectionEXT;
vec3 nFacing = dot(nWorld, viewDir) > 0.0 ? nWorld : -nWorld;
vec3 sunDir = normalize(SUN_DIR_TO_LIGHT);
float nDotL = max(0.0, dot(nFacing, sunDir));
hitValue = albedo * (AMBIENT_COLOR + SUN_COLOR * nDotL);
}

View file

@ -0,0 +1,32 @@
#version 460
#extension GL_EXT_ray_tracing : enable
// RTVolume intersection shader — runs once per AABB the ray enters
// (VK_RAY_TRACING_SHADER_GROUP_TYPE_PROCEDURAL_HIT_GROUP_KHR). Analytic
// ray-sphere test: the unit box [-1,1]^3 built by Mesh::BuildProcedural
// is treated as the bounding volume of a radius-1 sphere centred at the
// object origin. The object-space ray is NOT normalised (it is
// worldToObject * worldRay), so the reported t is directly comparable to
// the world-space ray parameter — solve the quadratic with the general
// a = dot(d,d) form rather than assuming |d| == 1. Mirrors
// intersection.wgsl on the WebGPU path.
hitAttributeEXT vec2 attribs;
void main() {
vec3 oc = gl_ObjectRayOriginEXT; // sphere centre is the origin
vec3 d = gl_ObjectRayDirectionEXT;
float a = dot(d, d);
float b = 2.0 * dot(oc, d);
float c = dot(oc, oc) - 1.0; // radius 1
float disc = b * b - 4.0 * a * c;
if (disc < 0.0) return;
float sq = sqrt(disc);
float t = (-b - sq) / (2.0 * a); // near root
if (t < gl_RayTminEXT) t = (-b + sq) / (2.0 * a); // fall back to far root
if (t < gl_RayTminEXT || t > gl_RayTmaxEXT) return;
attribs = vec2(0.0);
reportIntersectionEXT(t, 0u);
}

View file

@ -1,22 +1,183 @@
// RTVolume — procedural (AABB) ray tracing on the WebGPU wavefront tracer. // RTVolume — procedural (AABB) ray tracing on both backends. Demonstrates
// Demonstrates the two features this example was written to exercise: // the two features this example was written to exercise:
// //
// * VK_GEOMETRY_TYPE_AABBS_KHR equivalent — a BLAS built from AABBs // * VK_GEOMETRY_TYPE_AABBS_KHR — a BLAS built from AABBs
// (Mesh::BuildProcedural) whose surface is supplied by an intersection // (Mesh::BuildProcedural) whose surface is supplied by an intersection
// shader (here an analytic raysphere test). The boxes are unit cubes // shader (here an analytic raysphere test). The boxes are unit cubes
// [-1,1]^3; the intersection shader turns each into a sphere. // [-1,1]^3; the intersection shader turns each into a sphere.
// //
// * any-hit — the spheres are registered non-opaque, and an any-hit // * any-hit — the spheres are registered non-opaque, and an any-hit
// shader punches a spherical checkerboard of holes by returning // shader punches a spherical checkerboard of holes by ignoring the
// RT_ANYHIT_IGNORE for half the cells. Without any-hit the spheres are // intersection for half the cells. Without any-hit the spheres are
// solid; with it you can see the background (and other spheres) // solid; with it you can see the background (and other spheres)
// through the cut-out cells. // through the cut-out cells.
// //
// A 3×3×3 grid of these procedural spheres is shaded by surface normal + // A 3×3×3 grid of these procedural spheres is shaded by surface normal +
// a fixed sun. WebGPU/DOM only — this is the software RT path. // 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 #ifndef CRAFTER_GRAPHICS_WINDOW_DOM
int main() { return 0; } // native path is hardware RT; out of scope here #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);
Device::Initialize();
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. The intersection shader treats the
// box as the bounding volume of a radius-1 sphere centred at the
// object origin. opaque=false so the any-hit cut-out runs. ─────────
std::array<RTAabb, 1> boxes {{
{ .min = {-1.0f, -1.0f, -1.0f}, .max = {1.0f, 1.0f, 1.0f} },
}};
Mesh sphere;
sphere.BuildProcedural(boxes, /*opaque*/ false, cmd);
// ── 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 #else
import Crafter.Graphics; import Crafter.Graphics;

View file

@ -0,0 +1,12 @@
#version 460
#extension GL_EXT_ray_tracing : enable
// RTVolume miss — vertical sky gradient, also what shows through the
// any-hit cut-out cells. Mirrors miss.wgsl on the WebGPU path.
layout(location = 0) rayPayloadInEXT vec3 hitValue;
void main() {
float t = clamp(gl_WorldRayDirectionEXT.y * 0.5 + 0.5, 0.0, 1.0);
hitValue = mix(vec3(0.05, 0.07, 0.12), vec3(0.45, 0.60, 0.85), t);
}

View file

@ -43,6 +43,12 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.files.emplace_back(fs::path("miss.wgsl")); cfg.files.emplace_back(fs::path("miss.wgsl"));
cfg.files.emplace_back(fs::path("resolve.wgsl")); cfg.files.emplace_back(fs::path("resolve.wgsl"));
EnableWasiBrowserRuntime(cfg); EnableWasiBrowserRuntime(cfg);
} else {
cfg.shaders.emplace_back(fs::path("raygen.glsl"), std::string("main"), ShaderType::RayGen);
cfg.shaders.emplace_back(fs::path("miss.glsl"), std::string("main"), ShaderType::Miss);
cfg.shaders.emplace_back(fs::path("closesthit.glsl"), std::string("main"), ShaderType::ClosestHit);
cfg.shaders.emplace_back(fs::path("anyhit.glsl"), std::string("main"), ShaderType::AnyHit);
cfg.shaders.emplace_back(fs::path("intersection.glsl"), std::string("main"), ShaderType::Intersect);
} }
return cfg; return cfg;
} }

View file

@ -0,0 +1,50 @@
#version 460
#extension GL_EXT_ray_tracing : enable
#extension GL_EXT_shader_image_load_formatted : enable
#extension GL_EXT_shader_explicit_arithmetic_types_int16 : enable
#extension GL_EXT_descriptor_heap : enable
#extension GL_EXT_nonuniform_qualifier : enable
// Specialization constant set from descriptorHeap.bufferStartElement —
// same pattern as the Sponza example. The TLAS lives at descriptor_heap
// slot `bufferStart`, the per-frame output image at heap slot 0.
layout(constant_id = 0) const uint16_t bufferStart = 0us;
layout(descriptor_heap) uniform accelerationStructureEXT topLevelAS[];
layout(descriptor_heap) uniform writeonly image2D image[];
layout(location = 0) rayPayloadEXT vec3 hitValue;
void main() {
uvec2 pixel = gl_LaunchIDEXT.xy;
uvec2 resolution = gl_LaunchSizeEXT.xy;
vec2 uv = (vec2(pixel) + 0.5) / vec2(resolution);
vec2 ndc = uv * 2.0 - 1.0;
// Fixed camera framing the 3x3x3 grid (extent = (kGrid-1)*kSpacing
// = 6): position ext*(1.1, 0.8, 1.6) looking at the grid centre —
// the WebGPU example's initial free-camera pose.
vec3 origin = vec3(6.6, 4.8, 9.6);
vec3 forward = normalize(-origin);
vec3 right = normalize(cross(forward, vec3(0.0, 1.0, 0.0)));
vec3 up = cross(right, forward);
float aspect = float(resolution.x) / float(resolution.y);
float tanHalf = tan(radians(70.0) * 0.5);
vec3 direction = normalize(
right * (ndc.x * aspect * tanHalf) +
up * (-ndc.y * tanHalf) +
forward);
traceRayEXT(
topLevelAS[bufferStart],
gl_RayFlagsNoneEXT,
0xff,
0, 0, 0,
origin,
0.01,
direction,
100000.0,
0);
imageStore(image[0], ivec2(pixel), vec4(hitValue, 1.0));
}