diff --git a/examples/RTVolume/README.md b/examples/RTVolume/README.md index 2c73e7b..3421ab0 100644 --- a/examples/RTVolume/README.md +++ b/examples/RTVolume/README.md @@ -1,26 +1,36 @@ # RTVolume -WebGPU software ray tracing of **procedural (AABB) geometry** with an -**any-hit** cut-out — the two features added for issue #13. +Ray tracing of **procedural (AABB) geometry** with an **any-hit** cut-out +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 -(`Mesh::BuildProcedural`, the WebGPU analog of `VK_GEOMETRY_TYPE_AABBS_KHR`). -The hit group is a `RTShaderGroupType::ProceduralHitGroup` carrying: +(`Mesh::BuildProcedural` — `VK_GEOMETRY_TYPE_AABBS_KHR` on Vulkan, the +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 ray–sphere test that turns each box into a - radius-1 sphere (runs in TRACE, once per box the ray enters); -- `anyhit.wgsl` — returns `RT_ANYHIT_IGNORE` for half the cells of a - spherical checkerboard, so the ray passes through and the background / - spheres behind show through (the visible proof any-hit runs); -- `closesthit.wgsl` — normal-based Lambert shading, tinted per instance. +- `intersection.wgsl` / `intersection.glsl` — analytic ray–sphere test that + turns each box into a radius-1 sphere (runs once per box the ray enters); +- `anyhit.wgsl` / `anyhit.glsl` — ignores the intersection for half the + cells of a spherical checkerboard, so the ray passes through and the + background / spheres behind show through (the visible proof any-hit runs); +- `closesthit.wgsl` / `closesthit.glsl` — normal-based Lambert shading, + tinted per instance. 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 instance flag to `kRTGeometryInstanceForceOpaque` (or build the mesh with `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 ``` + +Native Vulkan (fixed camera in raygen.glsl): + +``` +crafter-build -r +``` diff --git a/examples/RTVolume/anyhit.glsl b/examples/RTVolume/anyhit.glsl new file mode 100644 index 0000000..4ea4882 --- /dev/null +++ b/examples/RTVolume/anyhit.glsl @@ -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 + } +} diff --git a/examples/RTVolume/closesthit.glsl b/examples/RTVolume/closesthit.glsl new file mode 100644 index 0000000..3ef6d21 --- /dev/null +++ b/examples/RTVolume/closesthit.glsl @@ -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); +} diff --git a/examples/RTVolume/intersection.glsl b/examples/RTVolume/intersection.glsl new file mode 100644 index 0000000..d46a136 --- /dev/null +++ b/examples/RTVolume/intersection.glsl @@ -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); +} diff --git a/examples/RTVolume/main.cpp b/examples/RTVolume/main.cpp index fc3ff69..082494e 100644 --- a/examples/RTVolume/main.cpp +++ b/examples/RTVolume/main.cpp @@ -1,22 +1,183 @@ -// RTVolume — procedural (AABB) ray tracing on the WebGPU wavefront tracer. -// Demonstrates the two features this example was written to exercise: +// RTVolume — procedural (AABB) ray tracing on both backends. Demonstrates +// 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 // 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 returning -// RT_ANYHIT_IGNORE for half the cells. Without any-hit the spheres are +// 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. 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 -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 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 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 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 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 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 renderers; + renderers.reserve(static_cast(instanceCount)); + const float origin0 = -0.5f * static_cast(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(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 resources; + std::vector 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(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; diff --git a/examples/RTVolume/miss.glsl b/examples/RTVolume/miss.glsl new file mode 100644 index 0000000..3723380 --- /dev/null +++ b/examples/RTVolume/miss.glsl @@ -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); +} diff --git a/examples/RTVolume/project.cpp b/examples/RTVolume/project.cpp index 53200c2..0dc138f 100644 --- a/examples/RTVolume/project.cpp +++ b/examples/RTVolume/project.cpp @@ -43,6 +43,12 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.files.emplace_back(fs::path("miss.wgsl")); cfg.files.emplace_back(fs::path("resolve.wgsl")); 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; } diff --git a/examples/RTVolume/raygen.glsl b/examples/RTVolume/raygen.glsl new file mode 100644 index 0000000..0825869 --- /dev/null +++ b/examples/RTVolume/raygen.glsl @@ -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)); +}