webgpu triangle

This commit is contained in:
Jorijn van der Graaf 2026-05-18 18:43:30 +02:00
commit 5553ded476
22 changed files with 2107 additions and 42 deletions

View file

@ -60,3 +60,54 @@ export namespace Crafter {
};
}
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import Crafter.Math;
import :WebGPU;
export namespace Crafter {
// Software-RT BLAS node, packed to 32 bytes. Matches the WGSL
// `BVHNode` struct in the RT WGSL prelude (additional/dom-webgpu.js,
// rtWgslPrelude) byte-for-byte.
//
// primCount == 0 → inner node, children at indices
// firstChildOrPrim and firstChildOrPrim+1.
// primCount > 0 → leaf, `primCount` primitives starting at
// primIndex `firstChildOrPrim` in the
// global primRemap heap.
//
// SAH-built BVH2; constructed CPU-side at Build() time, never refit.
struct BVHNode {
float aabbMin[3];
std::uint32_t firstChildOrPrim;
float aabbMax[3];
std::uint32_t primCount;
};
static_assert(sizeof(BVHNode) == 32);
class Mesh {
public:
// BLAS "handle": opaque identity that goes into
// RTInstance::accelerationStructureReference. Set by Build() to a
// stable u32 (widened to u64 for Vulkan-struct layout parity), used
// by the WebGPU TLAS-build compute shader to look up the BLAS root
// AABB and per-mesh heap offsets. Handle 0 is the unassigned
// sentinel; never returned by Build().
std::uint64_t blasAddr = 0;
std::uint32_t triangleCount = 0;
bool opaque = true;
// Build BLAS from raw triangle data. Runs the CPU SAH BVH2 builder
// and forwards vertex/index/BVH/remap arrays to the JS-side mesh
// heap (additional/dom-webgpu.js), which queue.writeBuffers them
// into the global heaps and records the per-mesh offsets keyed by
// the returned handle. The `cmd` parameter is unused on WebGPU —
// kept for API symmetry with the Vulkan signature.
void Build(std::span<Crafter::Vector<float, 3, 3>> vertices,
std::span<std::uint32_t> indices,
WebGPUCommandEncoderRef cmd = 0);
};
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -0,0 +1,51 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// DOM-mode RT pipeline. Mirrors PipelineRTVulkan's surface — Init takes
// the same kind of (raygen, miss, hit) shader-group spans plus an SBT.
// The big difference is implementation: there's no native RT pipeline on
// WebGPU, so Init assembles a single megakernel WGSL by concatenating
// 1. library prelude (types, bindings, ray-flag constants)
// 2. user closesthit / anyhit / miss source files
// 3. library mega-switches dispatched on per-hit hit-group index
// 4. library helpers (rayAabb / rayTriangle / traverseBlas / traverseTlas)
// 5. library traceRay function
// 6. user raygen source files
// 7. @compute entry calling the registered raygen
// and hands the result to wgpuLoadRTPipeline.
//
// The library WGSL itself lives in additional/dom-webgpu.js (rtWgslPrelude
// + rtWgslDispatchTemplate). C++ side only knows the substitution markers.
export module Crafter.Graphics:PipelineRTWebGPU;
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import :RT;
import :WebGPU;
import :ShaderBindingTableWebGPU;
export namespace Crafter {
class PipelineRTWebGPU {
public:
std::uint32_t pipelineHandle = 0;
// Build the megakernel pipeline. Groups carry indices into
// `sbt.shaders`. The library generates one `case` per registered
// group: closest-hit groups dispatch to their closestHitShader's
// entryFn, miss groups to their generalShader's entryFn, etc.
// The `cmd` parameter is unused on WebGPU; kept for API symmetry.
void Init(WebGPUCommandEncoderRef cmd,
std::span<const RTShaderGroup> raygenGroups,
std::span<const RTShaderGroup> missGroups,
std::span<const RTShaderGroup> hitGroups,
const ShaderBindingTableWebGPU& sbt);
PipelineRTWebGPU() = default;
PipelineRTWebGPU(const PipelineRTWebGPU&) = delete;
PipelineRTWebGPU& operator=(const PipelineRTWebGPU&) = delete;
};
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -0,0 +1,83 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License version 3.0 as published by the Free Software Foundation;
*/
// Portable RT types & constants.
//
// Native: aliases the Vulkan struct so existing code that passes
// `RenderingElement3D::instance` directly into vkCmdBuildAccelerationStructuresKHR
// is a no-op layout-wise.
// DOM: provides a POD with the same byte layout + the same field names, so
// user code touching `instance.mask`, `instance.flags`, `instance.transform`
// etc. compiles unchanged.
//
// Flag constants are spelled out as Crafter::kRT* so portable user code can
// avoid referencing VK_* on the DOM target. The values match
// VkGeometryInstanceFlagBitsKHR / VkRayTracingShaderGroupTypeKHR so the
// constants compare-equal on Vulkan if the user wants to mix surfaces.
module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h"
#endif
export module Crafter.Graphics:RT;
import std;
export namespace Crafter {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
using RTTransformMatrix = VkTransformMatrixKHR;
using RTInstance = VkAccelerationStructureInstanceKHR;
#else
// Mirrors VkTransformMatrixKHR: row-major affine 3x4.
struct RTTransformMatrix {
float matrix[3][4];
};
static_assert(sizeof(RTTransformMatrix) == 48);
// Mirrors VkAccelerationStructureInstanceKHR byte-for-byte.
// On WebGPU the `accelerationStructureReference` slot holds the BLAS
// handle returned by MeshWebGPU::blasHandle (a u32 widened to u64).
struct RTInstance {
RTTransformMatrix transform;
std::uint32_t instanceCustomIndex : 24;
std::uint32_t mask : 8;
std::uint32_t instanceShaderBindingTableRecordOffset : 24;
std::uint32_t flags : 8;
std::uint64_t accelerationStructureReference;
};
static_assert(sizeof(RTInstance) == 64);
#endif
// VkGeometryInstanceFlagBitsKHR mirror. Values verbatim so equal on both.
inline constexpr std::uint8_t kRTGeometryInstanceTriangleFacingCullDisable = 0x1;
inline constexpr std::uint8_t kRTGeometryInstanceTriangleFlipFacing = 0x2;
inline constexpr std::uint8_t kRTGeometryInstanceForceOpaque = 0x4;
inline constexpr std::uint8_t kRTGeometryInstanceForceNoOpaque = 0x8;
// Hit-group identification. Matches VkRayTracingShaderGroupTypeKHR for
// the two types we actually support (general + triangles-hit).
enum class RTShaderGroupType : std::uint8_t {
General = 0, // raygen / miss / callable
TrianglesHitGroup = 1,
};
// Cross-backend description of one entry in the shader-group array
// passed to PipelineRT::Init. Mirrors the meaningful subset of
// VkRayTracingShaderGroupCreateInfoKHR: per group, the type and the
// indices (into the SBT's shader array) for general / closestHit /
// anyHit, with kRTShaderUnused == VK_SHADER_UNUSED_KHR for "none".
inline constexpr std::uint32_t kRTShaderUnused = 0xFFFFFFFFu;
struct RTShaderGroup {
RTShaderGroupType type = RTShaderGroupType::General;
std::uint32_t generalShader = kRTShaderUnused;
std::uint32_t closestHitShader = kRTShaderUnused;
std::uint32_t anyHitShader = kRTShaderUnused;
};
}

View file

@ -46,3 +46,42 @@ export namespace Crafter {
};
}
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import :RenderPass;
import :Window;
import :WebGPU;
import :PipelineRTWebGPU;
import :RenderingElement3D;
export namespace Crafter {
// DOM-mode RT pass — dispatches the megakernel pipeline at frame Record
// time. Picks up the current TLAS for the frame and the application's
// raygen-side push data (typically empty in v1; pass via window.passes
// wiring if needed later).
struct RTPass : RenderPass {
PipelineRTWebGPU* pipeline;
// Optional per-dispatch push data forwarded after the standard
// RTDispatchHeader. Null means "no extra data".
const void* pushPtr = nullptr;
std::uint32_t pushBytes = 0;
RTPass(PipelineRTWebGPU* p) : pipeline(p) {}
void Record(WebGPUCommandEncoderRef /*cmd*/, std::uint32_t frameIdx, Window& window) override {
const std::uint32_t gx = (window.width + 7u) / 8u;
const std::uint32_t gy = (window.height + 7u) / 8u;
auto& tlas = RenderingElement3D::tlases[frameIdx];
WebGPU::wgpuDispatchRT(
pipeline->pipelineHandle,
pushPtr,
static_cast<std::int32_t>(pushBytes),
tlas.buffer.handle,
static_cast<std::int32_t>(tlas.builtInstanceCount),
static_cast<std::int32_t>(gx),
static_cast<std::int32_t>(gy));
}
};
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -22,6 +22,7 @@ module;
#include "vulkan/vulkan.h"
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
export module Crafter.Graphics:RenderingElement3D;
import :RT;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import :Mesh;
@ -55,7 +56,7 @@ export namespace Crafter {
class RenderingElement3D {
public:
VkAccelerationStructureInstanceKHR instance;
RTInstance instance;
// Position in `elements`, maintained by Add/Remove for O(1) swap-and-pop.
// Sentinel value = not currently registered.
std::uint32_t indexInElements = std::numeric_limits<std::uint32_t>::max();
@ -87,3 +88,63 @@ export namespace Crafter {
};
}
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import :Mesh;
import :WebGPU;
import :WebGPUBuffer;
import :Window;
export namespace Crafter {
// Per-frame TLAS storage. WebGPU has no real swapchain frame count
// (Window::numFrames = 1 on DOM), so this is effectively a singleton —
// the array form is kept for API symmetry with the Vulkan side so user
// code that indexes `tlases[frameIdx]` ports unchanged.
struct TlasWithBuffer {
// Host-visible instance buffer holding RTInstance entries — same
// layout as Vulkan's VkAccelerationStructureInstanceKHR, so user
// code touching .instance.mask / .flags / .transform.matrix is
// identical across backends. Also bound as a storage SSBO so
// application compute shaders (e.g. physics-tlas-transform.comp.wgsl)
// can write the .transform field directly when
// RenderingElement3D::transformOwnedByGpu is set.
WebGPUBuffer<RTInstance, true> instanceBuffer;
// Per-instance application metadata; parallel to instanceBuffer,
// identical semantics to the Vulkan-side counterpart.
WebGPUBuffer<std::uint32_t, true> metadataBuffer;
// GPU-built TLAS data: one TLASEntry per instance, written each
// BuildTLAS by a compute pass on the JS bridge. Read by traceRay /
// rayQuery as `@group(1) @binding(0) tlas: array<TLASEntry>`.
// TLASEntry layout: 96 bytes — aabbMin (12) + maskHGoffset (4) +
// aabbMax (12) + blasHandle (4) + invTransform 3x4 mat (48) +
// customIndex (4) + _pad (12). Defined in the WGSL traversal
// library; never directly read by C++.
WebGPUBuffer<char, false> buffer;
std::uint32_t builtInstanceCount = 0;
};
class RenderingElement3D {
public:
RTInstance instance{};
std::uint32_t indexInElements = std::numeric_limits<std::uint32_t>::max();
std::uint32_t userMetadata = 0;
// Application compute shader writes the transform field of this
// element's instanceBuffer slot directly — BuildTLAS preserves it.
bool transformOwnedByGpu = false;
static std::vector<RenderingElement3D*> elements;
inline static TlasWithBuffer tlases[Window::numFrames];
// Repopulate the TLAS for frame `index`. WebGPU path always does
// a fresh build (no refit) — the GPU build pass is cheap at the
// ~10100 instance counts the design targets; LBVH-for-TLAS is a
// future optimization for larger scenes.
static void BuildTLAS(WebGPUCommandEncoderRef cmd, std::uint32_t index);
static void Add(RenderingElement3D* e);
static void Remove(RenderingElement3D* e);
};
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -0,0 +1,64 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// DOM-mode shader-binding-table analog. Stores raw WGSL source strings
// plus an explicit entry-function name per shader. PipelineRTWebGPU::Init
// concatenates these into the megakernel WGSL at pipeline-build time.
export module Crafter.Graphics:ShaderBindingTableWebGPU;
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
export namespace Crafter {
enum class WebGPURTStage : std::uint8_t {
Raygen = 0,
Miss = 1,
ClosestHit = 2,
AnyHit = 3,
};
// One WGSL shader source + the function name PipelineRTWebGPU should
// call from the megakernel switch. The source may declare any helper
// functions and (in exactly one raygen file) the `Payload` struct.
//
// Required signatures inside `source` for `entryFn`:
// Raygen: fn <entryFn>(gid: vec3<u32>)
// Miss: fn <entryFn>(ray: RayDesc, payload: ptr<function, Payload>)
// ClosestHit: fn <entryFn>(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>)
// AnyHit: fn <entryFn>(ray: RayDesc, hit: HitInfo, payload: ptr<function, Payload>) -> u32
// returns RT_ANYHIT_ACCEPT / RT_ANYHIT_IGNORE / RT_ANYHIT_END_SEARCH.
//
// `RayDesc`, `HitInfo`, the `RT_*` flag/return constants, the `tlas` /
// BLAS / mesh-record bindings, and the `traceRay` function are all
// injected by the library prelude — see the rtWgslPrelude block in
// additional/dom-webgpu.js.
struct WebGPUShader {
std::string source;
std::string entryFn;
WebGPURTStage stage = WebGPURTStage::Raygen;
WebGPUShader() = default;
WebGPUShader(std::string src, std::string fn, WebGPURTStage s)
: source(std::move(src)), entryFn(std::move(fn)), stage(s) {}
// Construct from a WGSL source file path. Reads via the WASI VFS
// so apps shipping their shaders as static files (see the
// `cfg.files.emplace_back("raygen.wgsl")` pattern in
// examples/VulkanTriangle/project.cpp) get them at runtime.
WebGPUShader(const std::filesystem::path& wgslPath,
std::string fn,
WebGPURTStage s);
};
class ShaderBindingTableWebGPU {
public:
std::vector<WebGPUShader> shaders;
void Init(std::span<const WebGPUShader> shaders_) {
shaders.assign(shaders_.begin(), shaders_.end());
}
};
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -73,13 +73,62 @@ namespace Crafter::WebGPU {
std::uint32_t atlasHandle, std::uint32_t sampHandle);
// ─── custom user-authored compute shaders ───────────────────────────
// rayQueryFlag = 1 swaps group(1) from the UI ping-pong pair to the RT
// data heaps (TLAS, BVH, meshRecs, verts, idx, primRemap, outImage) and
// prepends a WGSL prelude exposing the rayQuery* API. Shaders that set
// this MUST NOT declare their own @group(1) bindings.
__attribute__((import_module("env"), import_name("wgpuLoadCustomShader")))
extern "C" std::uint32_t wgpuLoadCustomShader(const void* wgslPtr, std::int32_t wgslLen,
const void* bindingsPtr, std::int32_t bindingsCount);
const void* bindingsPtr, std::int32_t bindingsCount,
std::int32_t rayQueryFlag);
__attribute__((import_module("env"), import_name("wgpuDispatchCustom")))
extern "C" void wgpuDispatchCustom(std::uint32_t pipelineHandle,
const void* pushPtr, std::int32_t pushBytes,
const void* handlesPtr, std::int32_t handlesCount,
std::int32_t gx, std::int32_t gy, std::int32_t gz);
// ─── software raytracing ───────────────────────────────────────────
//
// Mesh::Build forwards vertex / index / BVH-node / primRemap arrays
// to the JS bridge, which queue.writeBuffers them into the global
// RT mesh heaps (growing if needed) and records the per-mesh offsets
// under a freshly-allocated u32 handle. The handle is what user code
// stores in RTInstance::accelerationStructureReference; the WebGPU
// TLAS-build compute shader resolves it back to root AABB + heap
// offsets at dispatch time. Returns 0 on failure.
__attribute__((import_module("env"), import_name("wgpuRegisterMeshBLAS")))
extern "C" std::uint32_t wgpuRegisterMeshBLAS(
float minX, float minY, float minZ,
float maxX, float maxY, float maxZ,
const void* verticesPtr, std::int32_t vertexCount,
const void* indicesPtr, std::int32_t indexCount,
const void* bvhNodesPtr, std::int32_t bvhNodeCount,
const void* primRemapPtr, std::int32_t primRemapCount);
// RT pipeline build. The library composes WGSL by concatenating the
// traversal library, generated hit-group switches, and the user-
// supplied raygen / miss / closesthit / anyhit bodies. Returns an
// opaque pipeline handle.
__attribute__((import_module("env"), import_name("wgpuLoadRTPipeline")))
extern "C" std::uint32_t wgpuLoadRTPipeline(const void* wgslPtr, std::int32_t wgslLen);
// Dispatch a TraceRays-equivalent pass: the RT pipeline is dispatched
// over a (gx, gy) tile grid; the library writes the push data (camera,
// payload, etc. — opaque) into a uniform ring buffer, attaches the TLAS
// + global mesh heap, and runs one workgroup per 8x8 screen tile.
__attribute__((import_module("env"), import_name("wgpuDispatchRT")))
extern "C" void wgpuDispatchRT(std::uint32_t pipelineHandle,
const void* pushPtr, std::int32_t pushBytes,
std::uint32_t tlasBufHandle,
std::int32_t instanceCount,
std::int32_t gx, std::int32_t gy);
// GPU TLAS-build dispatch. Reads the instance buffer (host-uploaded or
// GPU-written), produces per-instance world-space AABBs + per-instance
// transform matrices in a flat tlasBuf SSBO consumed by traceRay / rayQuery.
__attribute__((import_module("env"), import_name("wgpuBuildTLAS")))
extern "C" void wgpuBuildTLAS(std::uint32_t instanceBufHandle,
std::int32_t instanceCount,
std::uint32_t tlasOutBufHandle);
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -49,6 +49,7 @@ export namespace Crafter {
class WebGPUComputeShader {
public:
std::uint32_t pipelineHandle = 0;
bool rayQueryCapable = false;
std::vector<UICustomBinding> customBindings;
WebGPUComputeShader() = default;
@ -56,22 +57,28 @@ export namespace Crafter {
WebGPUComputeShader& operator=(const WebGPUComputeShader&) = delete;
WebGPUComputeShader(WebGPUComputeShader&& o) noexcept
: pipelineHandle(o.pipelineHandle),
rayQueryCapable(o.rayQueryCapable),
customBindings(std::move(o.customBindings)) {
o.pipelineHandle = 0;
}
// Compile + link a custom compute shader. `wgsl` is the source
// string; the library does NOT add anything to it — the user's
// shader must declare @group(0)/@group(1) bindings matching the
// contract above. `bindings` lists every additional resource
// (groups 2+) that the renderer should bind at dispatch time.
// string; the library does NOT add anything to it (except when
// `rayQuery` is true — then a RT prelude exposing the rayQuery*
// API is prepended). The user's shader must declare
// @group(0)/@group(1) bindings matching the contract above
// (rayQuery shaders MUST NOT redeclare group(1)).
// `bindings` lists every additional resource (groups 2+) that the
// renderer should bind at dispatch time.
void Load(std::string_view wgsl,
std::span<const UICustomBinding> bindings = {});
std::span<const UICustomBinding> bindings = {},
bool rayQuery = false);
// Path-based overload for symmetry with the Vulkan ComputeShader.
// Reads the file from disk (browser VFS) and forwards to Load(wgsl).
void Load(const std::filesystem::path& wgslPath,
std::span<const UICustomBinding> bindings = {});
std::span<const UICustomBinding> bindings = {},
bool rayQuery = false);
};
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -58,6 +58,10 @@ export import :UIComponents;
export import :InputField;
export import :Decompress;
// Portable RT type aliases (provided on both targets — uses Vulkan
// structs natively, plain PODs of the same layout on DOM).
export import :RT;
// DOM-only partitions — empty under native.
export import :Dom;
export import :DomEvents;
@ -66,3 +70,5 @@ export import :WebGPU;
export import :WebGPUBuffer;
export import :DescriptorHeapWebGPU;
export import :WebGPUComputeShader;
export import :ShaderBindingTableWebGPU;
export import :PipelineRTWebGPU;