webgpu sponza

This commit is contained in:
Jorijn van der Graaf 2026-05-19 00:27:09 +02:00
commit b5d0f52da0
21 changed files with 1426 additions and 58 deletions

58
examples/Sponza/README.md Normal file
View file

@ -0,0 +1,58 @@
# Sponza example
Loads the Sponza atrium as a `.cmesh` + one albedo `.ctex` and renders
it via ray tracing on both Vulkan (native) and WebGPU (wasm). Same
`main.cpp`, `#ifdef CRAFTER_GRAPHICS_WINDOW_DOM` selects the backend.
## What this example proves
- `.cmesh` and `.ctex` decompression round-trip on both backends
(GPU via `VK_EXT_memory_decompression` on Vulkan, CPU via
`Compression::DecompressCPU` on WebGPU).
- A single texture binding flowing from `Image2D<RGBA8>` through the
RT pipeline's closest-hit on both backends. The closest-hit samples
at the barycentric attribs as UVs — proof-of-binding, not visually
accurate. Per-vertex UV interpolation is the next step.
## Asset fetch
`project.cpp` calls `Crafter::GitFetch(...)` on
[https://github.com/jimmiebergmann/Sponza](https://github.com/jimmiebergmann/Sponza)
(pinned to commit `222338979d32f4f4818466291bdbc29f192b86ba`). The
clone lands in the per-user crafter-build cache; first build pulls
~280 MB once, subsequent builds reuse it.
`cfg.assets` then picks two files out of that clone:
| Source | Compressed output |
|-----------------------------------------|-------------------------|
| `sponza.obj` | `sponza.cmesh` |
| `textures/sponza_arch_diff.tga` | `sponza_arch_diff.ctex` |
Both land flat in the example's bin directory.
## Building
```
crafter build # native Vulkan
crafter build --target=wasm32-wasip1 # WebGPU / wasm
```
## License & attribution
Sponza geometry, materials, and textures are licensed under
[CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
- **Original model:** Frank Meinl, Crytek (2010).
- **OBJ packaging / cleanup:** Morgan McGuire, McGuire Computer
Graphics Archive — https://casual-effects.com/data.
- **GitHub mirror used here:** Jimmie Bergmann's roof-material fixup —
https://github.com/jimmiebergmann/Sponza.
When redistributing builds of this example that bundle the compressed
Sponza outputs (`*.cmesh`, `*.ctex`), the CC BY 3.0 attribution
requirement applies. Quoting the original credit somewhere visible to
end users (about-screen, credits page, etc.) is enough.
The Crafter.Graphics library code itself is LGPL-3.0; the two
licenses are compatible for data + code distribution.

View file

@ -0,0 +1,23 @@
#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: descriptor-heap slot of the albedo texture.
// Set from descriptorHeap.bufferStartElement + the slot allocated for
// the Image2D<RGBA8> on the host side. Sampling uses gl_HitAttributeEXT
// barycentrics as UVs — proof-of-binding rather than UV-correct shading.
// Per-vertex UV interpolation lands when Mesh on Vulkan exposes the
// data-region buffer.
layout(constant_id = 0) const uint16_t albedoSlot = 0us;
layout(descriptor_heap) uniform sampler2D albedo[];
hitAttributeEXT vec2 hitAttrs;
layout(location = 0) rayPayloadInEXT vec3 hitValue;
void main() {
vec2 bary = vec2(hitAttrs.x, hitAttrs.y);
hitValue = texture(albedo[albedoSlot], bary).rgb;
}

View file

@ -0,0 +1,90 @@
// Payload declared here so the WGSL assembler sees it before raygen
// (the assembler concatenates closesthit/anyhit/miss BEFORE raygen).
//
// WGSL forbids cycles in the function call graph, so closesthit_main
// CAN'T call traceRay (that would create closesthit traceRay
// runClosestHit closesthit). The lighting + shadow trace therefore
// happens in raygen; closesthit's job is just to gather surface data
// into the payload.
//
// shadowRay = 0 (primary): closesthit fills albedo/worldPos/normal/hit.
// shadowRay = 1 (shadow): closesthit is skipped (RT_FLAG_SKIP_CLOSEST_HIT),
// miss flips color to white = "lit".
struct Payload {
color: vec3<f32>,
shadowRay: u32,
worldPos: vec3<f32>,
hit: u32,
worldNormal: vec3<f32>,
_pad: f32,
};
// User-bound resources at group(2). Matches the UICustomBinding span the
// host hands to PipelineRTWebGPU::Init.
// binding 0 albedo texture_2d_array, one layer per Sponza material
// binding 1 sampler (linear clamp)
// binding 2 camera storage buffer (read by raygen only)
@group(2) @binding(0) var albedos : texture_2d_array<f32>;
@group(2) @binding(1) var samp : sampler;
// VertexNormalTangentUVPacked is `packed` on the outer struct but each
// inner `Vector<float, N, 4>` is SIMD-aligned to a 16-byte stride. So
// each vertex is 12 u32 words: normal at 0..2, tangent at 4..6, uv at 8..9.
const ATTRIB_STRIDE_U32: u32 = 12u;
const ATTRIB_NORMAL_OFFSET: u32 = 0u;
const ATTRIB_UV_OFFSET: u32 = 8u;
fn fetchUV(meshRec: MeshRecord, vertexIdx: u32) -> vec2<f32> {
let base = meshRec.attribsOffset + vertexIdx * ATTRIB_STRIDE_U32 + ATTRIB_UV_OFFSET;
return vec2<f32>(
bitcast<f32>(vertexAttribs[base + 0u]),
bitcast<f32>(vertexAttribs[base + 1u]),
);
}
fn fetchNormal(meshRec: MeshRecord, vertexIdx: u32) -> vec3<f32> {
let base = meshRec.attribsOffset + vertexIdx * ATTRIB_STRIDE_U32 + ATTRIB_NORMAL_OFFSET;
return vec3<f32>(
bitcast<f32>(vertexAttribs[base + 0u]),
bitcast<f32>(vertexAttribs[base + 1u]),
bitcast<f32>(vertexAttribs[base + 2u]),
);
}
fn closesthit_main(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) {
// Resolve hit triangle 3 vertex indices.
let meshIdx = tlasEntries[hit.instanceId].blasMeshIdx;
let meshRec = meshRecords[meshIdx];
let baseIdx = meshRec.indexOffset + hit.primitiveId * 3u;
let i0 = indices[baseIdx + 0u];
let i1 = indices[baseIdx + 1u];
let i2 = indices[baseIdx + 2u];
let bary = vec3<f32>(1.0 - hit.attribs.x - hit.attribs.y, hit.attribs.x, hit.attribs.y);
// Albedo via barycentric UV interpolation.
let uv0 = fetchUV(meshRec, i0);
let uv1 = fetchUV(meshRec, i1);
let uv2 = fetchUV(meshRec, i2);
let uv = uv0 * bary.x + uv1 * bary.y + uv2 * bary.z;
// OBJ V is bottom-up; sampler is top-down. fract for manual tiling.
let uvTiled = vec2<f32>(fract(uv.x), fract(1.0 - uv.y));
let layer = i32(hit.customIndex);
let albedo = textureSampleLevel(albedos, samp, uvTiled, layer, 0.0).rgb;
// World-space smooth shading normal. Multiply through the
// object-to-world rotation so this stays correct if a future scene
// rotates instances (Sponza itself is all identities).
let n0 = fetchNormal(meshRec, i0);
let n1 = fetchNormal(meshRec, i1);
let n2 = fetchNormal(meshRec, i2);
let nObj = normalize(n0 * bary.x + n1 * bary.y + n2 * bary.z);
let nWorld = normalize(vec3<f32>(
dot(hit.objectToWorldR0.xyz, nObj),
dot(hit.objectToWorldR1.xyz, nObj),
dot(hit.objectToWorldR2.xyz, nObj)));
(*payload).color = albedo;
(*payload).worldPos = ray.origin + ray.direction * hit.t;
(*payload).worldNormal = nWorld;
(*payload).hit = 1u;
}

445
examples/Sponza/main.cpp Normal file
View file

@ -0,0 +1,445 @@
// Sponza on Vulkan + WebGPU. Same example source, two backends — picked
// by CRAFTER_GRAPHICS_WINDOW_DOM. Both paths:
// 1. Load a Sponza .cmesh (positions + indices, optional per-vertex
// data region) and a single albedo .ctex from disk. The source
// assets are fetched once by project.cpp (Crafter.Build::GitFetch)
// from https://github.com/jimmiebergmann/Sponza and compressed
// into the bin dir at build time — they don't live in this repo.
// 2. Build BLAS + TLAS via the existing Mesh / RenderingElement3D
// flow. The on-disk format is identical between backends; only
// the decompression path differs (VK_EXT_memory_decompression
// on Vulkan, CPU GDeflate on WebGPU).
// 3. Upload the albedo as Image2D<RGBA8>, register it in the
// backend descriptor heap, and run the RT pipeline. Closest-hit
// shaders sample the texture at the hit's barycentric coords —
// proof-of-binding rather than UV-correct shading. Per-vertex
// UV interpolation is follow-up work (the attribs heap is in
// place on WebGPU; the Vulkan side needs a sibling data buffer
// exposed off Mesh).
//
// Sponza model: CC BY 3.0 — Frank Meinl (Crytek), packaged by Jimmie
// Bergmann and Morgan McGuire. https://casual-effects.com/data
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h"
#endif
import Crafter.Graphics;
import Crafter.Asset;
import Crafter.Math;
import Crafter.Event;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
struct RGBA8 { std::uint8_t r, g, b, a; };
void RequireAssets(const fs::path& mesh, const fs::path& tex) {
const bool haveMesh = fs::exists(mesh);
const bool haveTex = fs::exists(tex);
if (haveMesh && haveTex) return;
std::println(std::cerr,
"[Sponza] missing asset(s):\n"
" mesh: {} {}\n"
" albedo: {} {}\n"
"The build should have populated these via cfg.assets +\n"
"GitFetch (see examples/Sponza/project.cpp). If you ran\n"
"the binary from outside its bin dir, cd into the bin dir\n"
"first — asset paths are relative to cwd.",
mesh.string(), haveMesh ? "OK" : "MISSING",
tex.string(), haveTex ? "OK" : "MISSING");
std::abort();
}
}
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
int main() {
// Native Vulkan path is single-material for now (see file header) —
// pick up just the first per-material output the build emits. The
// WebGPU branch below uses every mesh + a texture array.
const fs::path meshPath = "mesh_0.cmesh";
const fs::path texPath = "tex_0.ctex";
RequireAssets(meshPath, texPath);
CompressedMeshAsset loadedMesh = LoadCompressedMesh(meshPath);
CompressedTextureAsset loadedTex = LoadCompressedTexture(texPath);
std::println("[Sponza] loaded {} verts, {} idx, {}x{} albedo",
loadedMesh.vertexCount, loadedMesh.indexCount,
loadedTex.sizeX, loadedTex.sizeY);
Device::Initialize();
Window window(1280, 720, "Sponza");
VkCommandBuffer cmd = window.StartInit();
DescriptorHeapVulkan descriptorHeap;
descriptorHeap.Initialize(/*images*/ 2, /*buffers*/ 1, /*samplers*/ 0);
// Two specialization constants: the TLAS slot offset (shared with
// VulkanTriangle pattern) and the albedo slot index for closesthit.
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,
};
// Allocate the albedo slot first so its index is known when we
// compile closesthit.spv.
auto imgSlots = descriptorHeap.AllocateImageSlots(2);
auto bufSlots = descriptorHeap.AllocateBufferSlots(1);
std::uint16_t albedoHeapSlot = static_cast<std::uint16_t>(imgSlots.firstElement + 1);
VkSpecializationMapEntry hitEntry = { .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) };
VkSpecializationInfo hitSpec = {
.mapEntryCount = 1, .pMapEntries = &hitEntry,
.dataSize = sizeof(std::uint16_t), .pData = &albedoHeapSlot,
};
std::array<VulkanShader, 3> 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, &hitSpec },
}};
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,
} }};
std::array<VkRayTracingShaderGroupCreateInfoKHR, 1> hitGroups {{ {
.sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR,
.type = VK_RAY_TRACING_SHADER_GROUP_TYPE_TRIANGLES_HIT_GROUP_KHR,
.generalShader = VK_SHADER_UNUSED_KHR, .closestHitShader = 2,
.anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR,
} }};
PipelineRTVulkan pipeline;
pipeline.Init(cmd, raygenGroups, missGroups, hitGroups, shaderTable);
Mesh sponzaMesh;
sponzaMesh.Build(loadedMesh, cmd);
Image2D<RGBA8> albedo;
albedo.Create(loadedTex.sizeX, loadedTex.sizeY, /*mipLevels*/ 1, cmd,
VK_FORMAT_R8G8B8A8_UNORM,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
albedo.Update(loadedTex, cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
SamplerVulkan<RGBA8> sampler;
static RenderingElement3D renderer;
renderer.instance = {
.transform = {},
.instanceCustomIndex = 0,
.mask = 0xFF,
.instanceShaderBindingTableRecordOffset = 0,
.flags = VK_GEOMETRY_INSTANCE_FORCE_OPAQUE_BIT_KHR,
.accelerationStructureReference = sponzaMesh.blasAddr,
};
MatrixRowMajor<float, 4, 3, 1>::Identity()
.Store(reinterpret_cast<float*>(renderer.instance.transform.matrix));
RenderingElement3D::elements.emplace_back(&renderer);
RenderingElement3D::BuildTLAS(cmd, 0);
RenderingElement3D::BuildTLAS(cmd, 1);
RenderingElement3D::BuildTLAS(cmd, 2);
window.FinishInit();
// Write descriptors: TLAS at bufSlots[0], output image at imgSlots[0],
// albedo (combined image+sampler) at imgSlots[1]. Per-frame replicated.
VkDeviceAddressRangeKHR tlasRanges[Window::numFrames];
VkImageDescriptorInfoEXT outImgInfos[Window::numFrames];
VkDescriptorImageInfo albedoInfo {
.sampler = sampler.textureSampler,
.imageView = albedo.imageView,
.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
};
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 * 3);
destinations.reserve(Window::numFrames * 3);
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,
});
resources.push_back({
.sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT,
.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.data = { .pCombinedImageSampler = &albedoInfo },
});
destinations.push_back({
.address = descriptorHeap.resourceHeap[f].value
+ descriptorHeap.ImageByteOffset(albedoHeapSlot),
.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
int main() {
// ── Read scene manifest (produced by project.cpp's ImportSponzaBundle).
//
// line 1: albedoCount
// line 2: meshCount
// line 3..: per-mesh albedoIdx (-1 means "no albedo")
const fs::path manifestPath = "scene.txt";
if (!fs::exists(manifestPath)) {
std::println(std::cerr,
"[Sponza] missing scene.txt — the build should have produced "
"it (see examples/Sponza/project.cpp). If you ran the binary "
"from outside its bin dir, cd in first.");
std::abort();
}
std::ifstream manifest(manifestPath);
std::uint32_t albedoCount = 0, meshCount = 0;
manifest >> albedoCount >> meshCount;
std::vector<std::int32_t> meshAlbedo(meshCount);
for (std::uint32_t i = 0; i < meshCount; ++i) manifest >> meshAlbedo[i];
std::println("[Sponza] scene: {} albedos, {} meshes", albedoCount, meshCount);
Device::Initialize();
static Window window(1280, 720, "Sponza");
auto cmd = window.StartInit();
DescriptorHeapWebGPU heap;
heap.Initialize(/*images*/ 2, /*buffers*/ 2, /*samplers*/ 2);
std::array<WebGPUShader, 3> 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),
}};
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 },
}};
std::array<RTShaderGroup, 1> hitGroups {{
{ .type = RTShaderGroupType::TrianglesHitGroup, .closestHitShader = 2 },
}};
// Three user bindings at @group(2):
// binding 0 — albedo texture_2d_array (one layer per material)
// binding 1 — sampler (linear clamp)
// binding 2 — Camera storage buffer (host-driven, updated per frame)
std::array<UICustomBinding, 3> bindings {{
{ .group = 2, .binding = 0, .kind = UICustomBindingKind::SampledTextureArray, ._pad = 0, .pushOffset = 0 },
{ .group = 2, .binding = 1, .kind = UICustomBindingKind::Sampler, ._pad = 0, .pushOffset = 0 },
{ .group = 2, .binding = 2, .kind = UICustomBindingKind::Buffer, ._pad = 0, .pushOffset = 0 },
}};
PipelineRTWebGPU pipeline;
pipeline.Init(cmd, raygenGroups, missGroups, hitGroups, sbt, bindings);
// ── Albedo texture array — one rgba8unorm layer per material. ──────
//
// Probe layer 0 for the canonical layer dimensions; project.cpp
// already resized every albedo to the same square so any tex_N.ctex
// would do, layer 0 is just the first one we have.
Image2DArray<RGBA8> albedoArray;
{
CompressedTextureAsset probe = LoadCompressedTexture("tex_0.ctex");
albedoArray.Create(probe.sizeX, probe.sizeY, static_cast<std::uint16_t>(albedoCount));
albedoArray.UpdateLayer(0, probe);
for (std::uint32_t i = 1; i < albedoCount; ++i) {
CompressedTextureAsset tex = LoadCompressedTexture(std::format("tex_{}.ctex", i));
albedoArray.UpdateLayer(static_cast<std::uint16_t>(i), tex);
}
}
auto albedoArraySlot = albedoArray.AllocateSlot(heap);
SamplerSlot samplerSlot = AllocateLinearClampSampler(heap);
// Camera storage buffer — host writes (origin, right, up, forward,
// aspect, tanHalf) every frame from the input-driven free camera
// below. Layout matches the WGSL Camera struct in raygen.wgsl
// (vec3-aligned, std430). 64 bytes total.
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);
WebGPUBuffer<CameraGPU, true> cameraBuf;
cameraBuf.Create(1);
// Handle array fed to RTPass — order matches the bindings declaration.
static std::array<std::uint32_t, 3> userHandles {
heap.imageTable [albedoArraySlot.firstElement],
heap.samplerTable[samplerSlot.firstElement],
cameraBuf.handle,
};
// ── Meshes + scene instances ───────────────────────────────────────
//
// One Mesh + one RenderingElement3D per material group from
// scene.txt. Meshes whose albedoIdx is -1 (the .obj's `usemtl` named
// something without a map_Kd in .mtl) get dropped — they're rare in
// Sponza and we'd have nothing to sample for them anyway.
//
// Vector capacity is reserved up-front: RenderingElement3D::Add
// takes a pointer that's stored in the static elements[] vector, so
// any later vector reallocation would dangle those pointers.
static std::vector<Mesh> meshes;
static std::vector<RenderingElement3D> renderers;
meshes.reserve(meshCount);
renderers.reserve(meshCount);
for (std::uint32_t i = 0; i < meshCount; ++i) {
if (meshAlbedo[i] < 0) continue;
CompressedMeshAsset loaded = LoadCompressedMesh(std::format("mesh_{}.cmesh", i));
meshes.emplace_back();
meshes.back().Build(loaded, cmd);
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] = 0;
tx[1][0] = 0; tx[1][1] = 1; tx[1][2] = 0; tx[1][3] = 0;
tx[2][0] = 0; tx[2][1] = 0; tx[2][2] = 1; tx[2][3] = 0;
// 24-bit instanceCustomIndex carries the albedo array layer that
// closesthit.wgsl reads as `hit.customIndex`.
r.instance.instanceCustomIndex = static_cast<std::uint32_t>(meshAlbedo[i]);
r.instance.mask = 0xFF;
r.instance.instanceShaderBindingTableRecordOffset = 0;
r.instance.flags = kRTGeometryInstanceForceOpaque;
r.instance.accelerationStructureReference = meshes.back().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());
window.passes.push_back(&rtPass);
// ── Free camera: WASD + mouse-delta look ───────────────────────────
//
// Initial pose puts the camera near one end of the atrium at eye
// height, looking +X down the long axis (bbox: X[-1921..1800],
// Y[-126..1429], Z[-1183..1105]). The user can fine-tune from there.
struct CamState {
Vector<float, 3, 4> position{ -1500.0f, 200.0f, 0.0f };
float yaw = 0.0f; // radians, around world +Y
float pitch = 0.0f; // radians, +pitch looks up
} cam;
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);
constexpr float kMoveSpeed = 1200.0f; // Sponza units / second (room is ~3700 wide)
constexpr float kLookSens = 0.05f; // radians per mouse pixel
constexpr 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;
// Keep pitch just shy of straight up/down so the basis vectors
// don't collapse (cross(forward, world_up) would go zero).
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.0f;
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.0f;
cameraBuf.FlushDevice();
});
window.Render();
window.StartUpdate();
window.StartSync();
return 0;
}
#endif

11
examples/Sponza/miss.glsl Normal file
View file

@ -0,0 +1,11 @@
#version 460
#extension GL_EXT_ray_tracing : enable
layout(location = 0) rayPayloadInEXT vec3 hitValue;
void main() {
// Soft sky gradient based on ray direction Y. The actual ray dir
// isn't accessible without an extra payload field; use a flat warm
// tone that matches Sponza's interior lighting.
hitValue = vec3(0.10, 0.08, 0.06);
}

16
examples/Sponza/miss.wgsl Normal file
View file

@ -0,0 +1,16 @@
fn miss_main(ray: RayDesc, payload: ptr<function, Payload>) {
if ((*payload).shadowRay == 1u) {
// Shadow ray escaped to infinity the sun is visible from the
// origin, so the surface there should pick up full direct light.
// raygen reads color.x as the visibility coefficient.
(*payload).color = vec3<f32>(1.0);
return;
}
// Primary miss: cheap two-stop sky gradient. (*payload).hit stays 0
// so raygen knows to skip the lighting path and just use this color.
let t = clamp(ray.direction.y * 0.5 + 0.5, 0.0, 1.0);
let sky = vec3<f32>(0.45, 0.65, 0.95);
let zenith = vec3<f32>(0.95, 0.85, 0.65);
(*payload).color = mix(sky, zenith, t);
}

View file

@ -0,0 +1,92 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// Sponza geometry + albedo: CC BY 3.0, Frank Meinl (Crytek), packaged by
// Jimmie Bergmann (https://github.com/jimmiebergmann/Sponza) and Morgan
// McGuire (https://casual-effects.com/data). The full asset bundle is
// ~280 MB — too large to live in this repo. GitFetch lands it in the
// per-user crafter-build cache on first build and reuses thereafter.
constexpr std::string_view kSponzaGitUrl = "https://github.com/jimmiebergmann/Sponza.git";
constexpr std::string_view kSponzaCommitSHA = "222338979d32f4f4818466291bdbc29f192b86ba";
// Every albedo is normalized to this size so they can live as layers of
// one texture_2d_array on the GPU (WebGPU array textures require
// identical layer dimensions). 1024 matches the majority of Sponza's
// textures; the few outliers (256×1024 chain, 512² thorn, 2048² curtains)
// get bilinear-resized via stb_image_resize2.
constexpr std::uint16_t kAlbedoSize = 1024u;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
bool isWasm = false;
for (std::string_view a : args) {
if (a.starts_with("--target=") && a.find("wasm") != std::string_view::npos) {
isWasm = true;
break;
}
}
std::vector<std::string> graphicsArgs(args.begin(), args.end());
Configuration* graphics = LocalProject({
.projectFile = "../../project.cpp",
.args = graphicsArgs,
});
Configuration cfg;
cfg.path = "./";
cfg.name = "Sponza";
cfg.outputName = "Sponza";
cfg.type = ConfigurationType::Executable;
if (isWasm) {
cfg.target = "wasm32-wasip1";
cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_DOM", ""});
cfg.compileFlags.push_back("-msimd128");
}
ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics };
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
// Fetch Sponza once into the shared crafter-build cache, then process
// it into a per-material bundle under build/sponza-bundle-<hash>/.
// Hashing on (sha, albedoSize) so changing either invalidates the
// bundle without touching the rest of the example's build tree.
fs::path sponzaRoot = GitFetch({
.url = std::string(kSponzaGitUrl),
.commit = std::string(kSponzaCommitSHA),
});
std::string bundleKey = std::format("{}|{}", kSponzaCommitSHA, kAlbedoSize);
auto bundleHash = std::hash<std::string>{}(bundleKey);
fs::path bundleDir = fs::path("build") / std::format("sponza-bundle-{:016x}", bundleHash);
if (auto err = BuildOBJBundle(
sponzaRoot / "sponza.obj",
sponzaRoot / "sponza.mtl",
bundleDir,
kAlbedoSize); !err.empty()) {
std::println(std::cerr, "Sponza bundle error: {}", err);
std::exit(1);
}
// Forward every produced file (.cmesh, .ctex, scene.txt) as a
// passthrough — they're already compressed by Crafter.Asset, no
// further compression needed. cfg.files copies them flat into
// the executable's bin dir.
for (const auto& entry : fs::directory_iterator(bundleDir)) {
if (entry.is_regular_file()) cfg.files.push_back(entry.path());
}
if (isWasm) {
cfg.files.emplace_back(fs::path("raygen.wgsl"));
cfg.files.emplace_back(fs::path("closesthit.wgsl"));
cfg.files.emplace_back(fs::path("miss.wgsl"));
EnableWasiBrowserRuntime(cfg);
} else {
cfg.shaders.emplace_back(fs::path("raygen.glsl"), std::string("main"), ShaderType::RayGen);
cfg.shaders.emplace_back(fs::path("closesthit.glsl"), std::string("main"), ShaderType::ClosestHit);
cfg.shaders.emplace_back(fs::path("miss.glsl"), std::string("main"), ShaderType::Miss);
}
return cfg;
}

View file

@ -0,0 +1,52 @@
#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 —
// shared with closesthit.glsl. The TLAS lives at descriptor_heap slot
// `bufferStart` (it's an SSBO-typed entry), 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;
// Camera positioned to look down the Sponza atrium axis. Sponza-OBJ
// from McGuire's archive is roughly 30 units wide × 13 tall × 18 deep,
// axis-aligned, with the floor near y=0 and the atrium centered on
// origin. -X faces the long end, so we sit inside looking +X.
vec3 origin = vec3(-10.0, 5.0, 0.0);
float aspect = float(resolution.x) / float(resolution.y);
float fov = radians(70.0);
float tanHalf = tan(fov * 0.5);
vec3 direction = normalize(vec3(
ndc.x * aspect * tanHalf,
-ndc.y * tanHalf,
1.0));
// Rotate +Z forward → +X forward (90° about Y).
direction = vec3(direction.z, direction.y, -direction.x);
traceRayEXT(
topLevelAS[bufferStart],
gl_RayFlagsNoneEXT,
0xff,
0, 0, 0,
origin,
0.001,
direction,
10000.0,
0);
imageStore(image[0], ivec2(pixel), vec4(hitValue, 1.0));
}

109
examples/Sponza/raygen.wgsl Normal file
View file

@ -0,0 +1,109 @@
// WebGPU raygen. Camera state comes from the host every frame via a
// storage buffer bound at @group(2) @binding(2); main.cpp drives that
// from WASD + mouse-delta through Crafter::Input.
//
// The shading + shadow trace all happens here because WGSL forbids
// recursive function call graphs closesthit_main can't call traceRay
// (that would loop closesthit traceRay runClosestHit closesthit).
// Raygen is the entry point and not called by anyone, so it can call
// traceRay twice (once primary, once shadow) without forming a cycle.
struct Camera {
origin: vec3<f32>,
pad0: f32,
right: vec3<f32>,
tanHalf: f32,
up: vec3<f32>,
aspect: f32,
forward: vec3<f32>,
pad1: f32,
};
@group(2) @binding(2) var<storage, read> camera : Camera;
// Sun coming through Sponza's open roof. Y is up; this points "down and
// slightly along +X" so the light grazes the colonnades on one side.
const SUN_DIR_TO_LIGHT: vec3<f32> = vec3<f32>(-0.35, 1.00, -0.20);
const SUN_COLOR: vec3<f32> = vec3<f32>( 1.10, 1.00, 0.85);
const AMBIENT_COLOR: vec3<f32> = vec3<f32>( 0.18, 0.20, 0.28);
fn raygen_main(gid: vec3<u32>) {
if (gid.x >= hdr.surfaceW || gid.y >= hdr.surfaceH) { return; }
let pixel = vec2<f32>(f32(gid.x), f32(gid.y));
let resolution = vec2<f32>(f32(hdr.surfaceW), f32(hdr.surfaceH));
let uv = (pixel + vec2<f32>(0.5)) / resolution;
let ndc = uv * 2.0 - vec2<f32>(1.0);
// Pinhole camera reconstructed from the host basis. ndc.x runs left-
// to-right across the screen +right; ndc.y is top-down so we
// negate before applying +up.
let direction = normalize(
camera.right * (ndc.x * camera.aspect * camera.tanHalf) +
camera.up * (-ndc.y * camera.tanHalf) +
camera.forward);
// Primary ray
var payload: Payload;
payload.color = vec3<f32>(0.0);
payload.shadowRay = 0u;
payload.hit = 0u;
traceRay(
0u, 0u, 0xFFu,
0u, 0u, 0u,
camera.origin, 0.001,
direction, 10000.0,
&payload);
var finalColor: vec3<f32>;
if (payload.hit == 1u) {
// Closesthit filled albedo/worldPos/worldNormal. Two-sided
// shading: flip the normal toward the camera if we hit the back
// face Sponza's curtains in particular have inconsistent
// winding, and without this half the surface would go black.
let albedo = payload.color;
let nFacing = select(-payload.worldNormal,
payload.worldNormal,
dot(payload.worldNormal, direction) < 0.0);
let lightDir = normalize(SUN_DIR_TO_LIGHT);
let nDotL = max(0.0, dot(nFacing, lightDir));
// Shadow ray
// Only worth tracing if the surface faces the sun at all.
var visibility = 0.0;
if (nDotL > 0.0) {
// Normal-offset bias on Sponza's units (~3700 wide atrium)
// is hefty; 0.5 keeps the shadow ray clear of the originating
// triangle without producing visible "floating" shadows.
let shadowOrigin = payload.worldPos + nFacing * 0.5;
var shadowPayload: Payload;
shadowPayload.color = vec3<f32>(0.0); // default: blocked
shadowPayload.shadowRay = 1u;
shadowPayload.hit = 0u;
traceRay(
0u,
RT_FLAG_SKIP_CLOSEST_HIT | RT_FLAG_TERMINATE_ON_FIRST_HIT,
0xFFu,
0u, 0u, 0u,
shadowOrigin, 0.001,
lightDir, 10000.0,
&shadowPayload);
visibility = shadowPayload.color.x;
}
let lit = AMBIENT_COLOR + SUN_COLOR * (nDotL * visibility);
finalColor = albedo * lit;
} else {
// Sky color was filled by miss_main.
finalColor = payload.color;
}
// Reinhard tonemap + gamma 2.2 so sun-lit albedos don't clip and
// shadow detail stays readable.
let mapped = finalColor / (finalColor + vec3<f32>(1.0));
let gamma = pow(mapped, vec3<f32>(1.0 / 2.2));
textureStore(outImage,
vec2<i32>(i32(gid.x), i32(gid.y)),
vec4<f32>(gamma, 1.0));
}