webgpu support

This commit is contained in:
Jorijn van der Graaf 2026-05-18 04:58:52 +02:00
commit dedf6b0467
22 changed files with 1656 additions and 324 deletions

751
additional/dom-webgpu.js Normal file
View file

@ -0,0 +1,751 @@
/*
Crafter.Graphics WebGPU bridge DOM mode UI compute pipeline.
Surface model (high-level, deliberately not 1:1 with WebGPU):
- JS owns the GPUDevice/queue/compute pipelines/bind-group cache.
- C++ owns slot allocation and per-frame logic; it calls into ~15 imports.
- Standard UI shaders are embedded as WGSL strings at the bottom of this
file and compiled once at startup.
Ping-pong output strategy (per Decision 2 in plan):
- Two rgba8unorm storage textures sized to the canvas.
- Each Dispatch reads `prev` (sampled), writes `out` (storage, write-only).
- JS swaps the two between dispatches, so item-order overdraw works.
- At frame end, the current `out` is blitted to the canvas via
copyTextureToTexture (canvas configured as rgba8unorm to match).
This file is loaded as <script type="module">. Top-level await blocks
runtime.js's _start() until adapter + device are resolved, so by the time
main() runs every import here is fully wired.
*/
// ─── env stubs (assigned synchronously, BEFORE any async work) ────────────
// The wasm module's import-link step needs every declared wgpu* import to
// resolve to a Function. If init below throws, the stubs stay in place so
// the wasm still links — and the call site gets a clear error at runtime
// instead of "import object field X is not a Function" at link time.
window.crafter_webbuild_env = window.crafter_webbuild_env || {};
window.crafter_webbuild_env.table = window.crafter_webbuild_env.table
|| new WebAssembly.Table({ initial: 4, element: "anyfunc" });
let initError = null;
function stub(name) {
return (...args) => {
const msg = `[crafter-wgpu] ${name}() called but WebGPU init failed: ${initError?.message ?? "(no error captured)"}`;
console.error(msg);
throw new Error(msg);
};
}
{
const e = window.crafter_webbuild_env;
for (const n of [
"wgpuGetCanvasWidth", "wgpuGetCanvasHeight", "wgpuSurfaceWidth", "wgpuSurfaceHeight",
"wgpuInit", "wgpuCreateBuffer", "wgpuWriteBuffer", "wgpuDestroyBuffer",
"wgpuCreateAtlasTexture", "wgpuWriteAtlasRegion", "wgpuDestroyTexture",
"wgpuCreateLinearClampSampler", "wgpuFrameBegin", "wgpuFrameEnd",
"wgpuDispatchQuads", "wgpuDispatchCircles", "wgpuDispatchImages", "wgpuDispatchText",
]) {
// Read-write ints don't need a stub-throw; return 0 for the size queries.
e[n] = n.endsWith("Width") || n.endsWith("Height") ? () => 0 : stub(n);
}
}
// ─── canvas + device init (runs before _start) ───────────────────────────
// Wrapped in an async IIFE assigned to window.crafter_webbuild_env_ready so
// the runtime.js shim can `await` it explicitly before calling _start().
// Sibling <script type="module"> top-level awaits are NOT reliably
// serialized in Firefox (verified 2026-05), so we can't depend on this
// file's TLA to block runtime.js by itself.
window.crafter_webbuild_env_ready = (async () => {
try {
if (!navigator.gpu) {
document.body.innerHTML = "<p style=\"font-family:sans-serif;padding:24px\">"
+ "WebGPU not available in this browser. Try Chrome 121+ / Firefox 141+ / Safari 26+.</p>";
initError = new Error("WebGPU unavailable");
throw initError;
}
const canvas = document.createElement("canvas");
canvas.id = "crafter-canvas";
canvas.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;display:block;";
document.body.style.margin = "0";
document.body.appendChild(canvas);
function syncCanvasSize() {
// Match canvas pixel size to its CSS pixel size 1:1 so MouseEvent
// clientX/clientY (CSS pixels) and the wasm-side window.width/.height
// share the same coordinate space. (HiDPI sharpness is a v2 concern
// — would need DPR on the GPU side AND a scaling step in the C++
// Window/Event glue so layout/hit-testing/dispatch counts stay
// consistent.)
const w = window.innerWidth;
const h = window.innerHeight;
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
return { w, h };
}
syncCanvasSize();
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
initError = new Error("navigator.gpu.requestAdapter() returned null (no compatible adapter)");
console.error("[crafter-wgpu]", initError.message);
throw initError;
}
const device = await adapter.requestDevice();
const queue = device.queue;
const ctx = canvas.getContext("webgpu");
const canvasFormat = "rgba8unorm"; // match storage textures, skip swizzle blit
ctx.configure({ device, format: canvasFormat, alphaMode: "opaque",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST });
device.lost.then((info) => {
console.error("[crafter-wgpu] device lost:", info.message);
state.gpuLost = true;
});
// ─── handle tables ─────────────────────────────────────────────────────
const buffers = new Map(); // handle → GPUBuffer
const textures = new Map(); // handle → GPUTexture
const textureViews = new Map(); // handle → GPUTextureView (mirrors textures key for the view)
const samplers = new Map(); // handle → GPUSampler
let nextHandle = 1;
function newHandle() { return nextHandle++; }
// ─── ping-pong storage textures ────────────────────────────────────────
const state = {
pingTex: null, pingView: null,
pongTex: null, pongView: null,
outIsPing: true, // current "out" target
width: 0, height: 0,
encoder: null,
pass: null,
headerRing: null, // GPUBuffer; uniform header writes ring through this
headerRingSize: 0,
headerRingOffset: 0,
bindGroupCache: new Map(), // key → GPUBindGroup
gpuLost: false,
};
function recreatePingPong(w, h) {
const usage = GPUTextureUsage.STORAGE_BINDING
| GPUTextureUsage.TEXTURE_BINDING
| GPUTextureUsage.COPY_SRC
| GPUTextureUsage.COPY_DST; // COPY_DST so we can clear it
if (state.pingTex) state.pingTex.destroy();
if (state.pongTex) state.pongTex.destroy();
state.pingTex = device.createTexture({ size: [w, h], format: "rgba8unorm", usage });
state.pongTex = device.createTexture({ size: [w, h], format: "rgba8unorm", usage });
state.pingView = state.pingTex.createView();
state.pongView = state.pongTex.createView();
state.width = w; state.height = h;
state.outIsPing = true;
state.bindGroupCache.clear();
}
function ensureSized() {
const { w, h } = syncCanvasSize();
if (w !== state.width || h !== state.height) {
recreatePingPong(w, h);
// Notify the wasm side that the surface size changed so it can
// fire onResize through Window. The wasm export is added by
// Crafter.Graphics-Window.cpp.
const onResize = wasmExports && wasmExports.__crafterDom_resize;
if (onResize) onResize(1, w, h);
}
}
// Header ring buffer: 256-byte-aligned slots holding UIDispatchHeader (48
// bytes of meaningful data, padded to 256). Wraps at frame boundary.
const HEADER_ALIGN = 256;
const HEADER_RING_SLOTS = 64;
state.headerRingSize = HEADER_ALIGN * HEADER_RING_SLOTS;
state.headerRing = device.createBuffer({
size: state.headerRingSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
// ─── pipelines ─────────────────────────────────────────────────────────
const wgslShared = String.raw`
struct UIDispatchHeader {
outImage: u32,
itemBuffer: u32,
surfaceW: u32,
surfaceH: u32,
clipX: f32,
clipY: f32,
clipW: f32,
clipH: f32,
itemCount: u32,
frameIdx: u32,
flags: u32,
_pad: u32,
};
@group(0) @binding(0) var<uniform> hdr : UIDispatchHeader;
@group(1) @binding(0) var outTex : texture_storage_2d<rgba8unorm, write>;
@group(1) @binding(1) var prevTex : texture_2d<f32>;
fn uiResolvePixel(coord: vec2<u32>) -> bool {
if (coord.x >= hdr.surfaceW || coord.y >= hdr.surfaceH) { return false; }
let fx = f32(coord.x); let fy = f32(coord.y);
if (fx < hdr.clipX || fy < hdr.clipY) { return false; }
if (fx >= hdr.clipX + hdr.clipW) { return false; }
if (fy >= hdr.clipY + hdr.clipH) { return false; }
return true;
}
fn uiBlendOver(dst: vec4<f32>, src: vec4<f32>) -> vec4<f32> {
let a = clamp(src.a, 0.0, 1.0);
let rgb = mix(dst.rgb, src.rgb, vec3<f32>(a));
let outA = a + dst.a * (1.0 - a);
return vec4<f32>(rgb, outA);
}
fn uiSdRoundRect(p: vec2<f32>, halfSize: vec2<f32>, r4: vec4<f32>) -> f32 {
var r: vec4<f32> = r4;
// Pick radius for the quadrant p is in. r order: (TL, TR, BR, BL).
let rx = select(r.x, r.z, p.x > 0.0);
let ry = select(r.w, r.y, p.x > 0.0);
let radius = select(ry, rx, p.y > 0.0);
let q = abs(p) - halfSize + vec2<f32>(radius);
return min(max(q.x, q.y), 0.0) + length(max(q, vec2<f32>(0.0))) - radius;
}
`;
const wgslQuads = wgslShared + String.raw`
struct QuadItem {
rect: vec4<f32>,
color: vec4<f32>,
corners: vec4<f32>,
outline: vec4<f32>,
};
@group(2) @binding(0) var<storage, read> items : array<QuadItem>;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (!uiResolvePixel(gid.xy)) { return; }
let coord = vec2<i32>(i32(gid.x), i32(gid.y));
var dst = textureLoad(prevTex, coord, 0);
let sp = vec2<f32>(f32(gid.x) + 0.5, f32(gid.y) + 0.5);
for (var i: u32 = 0u; i < hdr.itemCount; i = i + 1u) {
let it = items[i];
let lo = it.rect.xy;
let hi = it.rect.xy + it.rect.zw;
if (sp.x < lo.x || sp.y < lo.y || sp.x >= hi.x || sp.y >= hi.y) { continue; }
let halfSize = it.rect.zw * 0.5;
let p = sp - (it.rect.xy + halfSize);
let d = uiSdRoundRect(p, halfSize, it.corners);
let bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && it.outline.x <= 0.0) { continue; }
var src = vec4<f32>(it.color.rgb, it.color.a * bodyA);
if (it.outline.x > 0.0) {
let t = abs(d + it.outline.x * 0.5) - it.outline.x * 0.5;
let outlineA = clamp(0.5 - t, 0.0, 1.0);
src = vec4<f32>(mix(src.rgb, it.outline.yzw, vec3<f32>(outlineA)),
max(src.a, outlineA));
}
if (src.a <= 0.0) { continue; }
dst = uiBlendOver(dst, src);
}
textureStore(outTex, coord, dst);
}
`;
const wgslCircles = wgslShared + String.raw`
struct CircleItem {
centerRadius: vec4<f32>,
color: vec4<f32>,
outline: vec4<f32>,
};
@group(2) @binding(0) var<storage, read> items : array<CircleItem>;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (!uiResolvePixel(gid.xy)) { return; }
let coord = vec2<i32>(i32(gid.x), i32(gid.y));
var dst = textureLoad(prevTex, coord, 0);
let sp = vec2<f32>(f32(gid.x) + 0.5, f32(gid.y) + 0.5);
for (var i: u32 = 0u; i < hdr.itemCount; i = i + 1u) {
let it = items[i];
let center = it.centerRadius.xy;
let radius = it.centerRadius.z;
let d = length(sp - center) - radius;
let bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && it.outline.x <= 0.0) { continue; }
var src = vec4<f32>(it.color.rgb, it.color.a * bodyA);
if (it.outline.x > 0.0) {
let t = abs(d + it.outline.x * 0.5) - it.outline.x * 0.5;
let outlineA = clamp(0.5 - t, 0.0, 1.0);
src = vec4<f32>(mix(src.rgb, it.outline.yzw, vec3<f32>(outlineA)),
max(src.a, outlineA));
}
if (src.a <= 0.0) { continue; }
dst = uiBlendOver(dst, src);
}
textureStore(outTex, coord, dst);
}
`;
const wgslImages = wgslShared + String.raw`
struct ImageItem {
rect: vec4<f32>,
uv: vec4<f32>,
tint: vec4<f32>,
slots: vec4<u32>,
};
@group(2) @binding(0) var<storage, read> items : array<ImageItem>;
@group(3) @binding(0) var imgTex : texture_2d<f32>;
@group(3) @binding(1) var imgSampler : sampler;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (!uiResolvePixel(gid.xy)) { return; }
let coord = vec2<i32>(i32(gid.x), i32(gid.y));
var dst = textureLoad(prevTex, coord, 0);
let sp = vec2<f32>(f32(gid.x) + 0.5, f32(gid.y) + 0.5);
for (var i: u32 = 0u; i < hdr.itemCount; i = i + 1u) {
let it = items[i];
let lo = it.rect.xy;
let hi = it.rect.xy + it.rect.zw;
if (sp.x < lo.x || sp.y < lo.y || sp.x >= hi.x || sp.y >= hi.y) { continue; }
let t = (sp - lo) / it.rect.zw;
let uv = vec2<f32>(mix(it.uv.x, it.uv.z, t.x), mix(it.uv.y, it.uv.w, t.y));
let sample = textureSampleLevel(imgTex, imgSampler, uv, 0.0);
let src = sample * it.tint;
if (src.a <= 0.0) { continue; }
dst = uiBlendOver(dst, src);
}
textureStore(outTex, coord, dst);
}
`;
const wgslText = wgslShared + String.raw`
struct GlyphItem {
rect: vec4<f32>,
uv: vec4<f32>,
color: vec4<f32>,
};
@group(2) @binding(0) var<storage, read> items : array<GlyphItem>;
@group(3) @binding(0) var atlasTex : texture_2d<f32>;
@group(3) @binding(1) var atlasSampler : sampler;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (!uiResolvePixel(gid.xy)) { return; }
let coord = vec2<i32>(i32(gid.x), i32(gid.y));
var dst = textureLoad(prevTex, coord, 0);
let sp = vec2<f32>(f32(gid.x) + 0.5, f32(gid.y) + 0.5);
for (var i: u32 = 0u; i < hdr.itemCount; i = i + 1u) {
let it = items[i];
let lo = it.rect.xy;
let hi = it.rect.xy + it.rect.zw;
if (sp.x < lo.x || sp.y < lo.y || sp.x >= hi.x || sp.y >= hi.y) { continue; }
let t = (sp - lo) / it.rect.zw;
let uv = vec2<f32>(mix(it.uv.x, it.uv.z, t.x), mix(it.uv.y, it.uv.w, t.y));
// stb_truetype SDF: pixel value ~128 is the edge. Treat alpha as
// the smoothed step around that midpoint.
let sdf = textureSampleLevel(atlasTex, atlasSampler, uv, 0.0).r;
let alpha = clamp((sdf - 0.5) * 8.0 + 0.5, 0.0, 1.0);
if (alpha <= 0.0) { continue; }
let src = vec4<f32>(it.color.rgb, it.color.a * alpha);
dst = uiBlendOver(dst, src);
}
textureStore(outTex, coord, dst);
}
`;
function makePipeline(label, wgsl, hasGroup3) {
const mod = device.createShaderModule({ label, code: wgsl });
// Layout: group 0 uniform header, group 1 (out storage + prev sampled),
// group 2 storage items SSBO, optional group 3 (texture + sampler).
const bgl0 = device.createBindGroupLayout({ entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: 48 } },
]});
const bgl1 = device.createBindGroupLayout({ entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, storageTexture: { format: "rgba8unorm", access: "write-only", viewDimension: "2d" } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: "float", viewDimension: "2d" } },
]});
const bgl2 = device.createBindGroupLayout({ entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
]});
const layouts = [bgl0, bgl1, bgl2];
let bgl3 = null;
if (hasGroup3) {
bgl3 = device.createBindGroupLayout({ entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: "float", viewDimension: "2d" } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, sampler: { type: "filtering" } },
]});
layouts.push(bgl3);
}
const pl = device.createPipelineLayout({ bindGroupLayouts: layouts });
const pipeline = device.createComputePipeline({
layout: pl,
compute: { module: mod, entryPoint: "main" },
});
return { pipeline, bgl0, bgl1, bgl2, bgl3 };
}
const pipeQuads = makePipeline("ui-quads", wgslQuads, false);
const pipeCircles = makePipeline("ui-circles", wgslCircles, false);
const pipeImages = makePipeline("ui-images", wgslImages, true);
const pipeText = makePipeline("ui-text", wgslText, true);
// Bind groups for group 0 (header uniform with dynamic offset) — one per
// pipeline, references the same ring buffer.
function makeHdrBG(pipe) {
return device.createBindGroup({
layout: pipe.bgl0,
entries: [{ binding: 0, resource: { buffer: state.headerRing, offset: 0, size: 48 } }],
});
}
const hdrBG = {
quads: makeHdrBG(pipeQuads),
circles: makeHdrBG(pipeCircles),
images: makeHdrBG(pipeImages),
text: makeHdrBG(pipeText),
};
// Group 1 changes between dispatches because `out` and `prev` swap on the
// ping-pong. Cache by current out-is-ping bool + pipeline (each pipeline
// has its own bgl1 instance even if the layout entries are identical).
function getGroup1BG(pipe) {
const key = `${pipe.bgl1.label || ""}/${state.outIsPing ? 1 : 0}/${state.width}x${state.height}`;
let bg = state.bindGroupCache.get(key);
if (bg) return bg;
const outView = state.outIsPing ? state.pingView : state.pongView;
const prevView = state.outIsPing ? state.pongView : state.pingView;
bg = device.createBindGroup({
layout: pipe.bgl1,
entries: [
{ binding: 0, resource: outView },
{ binding: 1, resource: prevView },
],
});
state.bindGroupCache.set(key, bg);
return bg;
}
function getGroup2BG(pipe, itemsHandle) {
const key = `items/${pipe === pipeQuads ? "q" : pipe === pipeCircles ? "c" : pipe === pipeImages ? "i" : "t"}/${itemsHandle}`;
let bg = state.bindGroupCache.get(key);
if (bg) return bg;
const buf = buffers.get(itemsHandle);
if (!buf) throw new Error(`getGroup2BG: unknown items buffer ${itemsHandle}`);
bg = device.createBindGroup({
layout: pipe.bgl2,
entries: [{ binding: 0, resource: { buffer: buf } }],
});
state.bindGroupCache.set(key, bg);
return bg;
}
function getGroup3BG(pipe, texHandle, sampHandle) {
const key = `t3/${texHandle}/${sampHandle}/${pipe === pipeImages ? "i" : "x"}`;
let bg = state.bindGroupCache.get(key);
if (bg) return bg;
const tex = textureViews.get(texHandle);
const sam = samplers.get(sampHandle);
if (!tex || !sam) throw new Error(`getGroup3BG: unknown view ${texHandle} / sampler ${sampHandle}`);
bg = device.createBindGroup({
layout: pipe.bgl3,
entries: [
{ binding: 0, resource: tex },
{ binding: 1, resource: sam },
],
});
state.bindGroupCache.set(key, bg);
return bg;
}
// ─── wasm import surface ───────────────────────────────────────────────
let wasmExports = null;
// Crafter.Build's runtime.js exposes the wasi instance on
// window.crafter_wasi after instantiation. We grab the exports lazily so
// every import-side function works regardless of call order. memU8 /
// memF32 / memU32 always re-derive the typed-array view because the
// wasm memory's backing ArrayBuffer is detached and replaced whenever
// the wasm grows its memory; caching a typed array would alias to
// freed memory after a grow.
function getExports() {
if (wasmExports) return wasmExports;
const wasi = window.crafter_wasi;
if (!wasi || !wasi.instance) {
throw new Error("[crafter-wgpu] wasm exports not available yet (called too early)");
}
wasmExports = wasi.instance.exports;
return wasmExports;
}
function memU8() { return new Uint8Array(getExports().memory.buffer); }
function memF32() { return new Float32Array(getExports().memory.buffer); }
function memU32() { return new Uint32Array(getExports().memory.buffer); }
// Stubs were assigned at the top of this file; we now overwrite them with
// real implementations now that init has succeeded.
const env = window.crafter_webbuild_env;
env.wgpuGetCanvasWidth = () => canvas.width;
env.wgpuGetCanvasHeight = () => canvas.height;
env.wgpuCreateBuffer = (byteSize) => {
const h = newHandle();
const buf = device.createBuffer({
size: Math.max(16, byteSize),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
});
buffers.set(h, buf);
return h;
};
env.wgpuWriteBuffer = (handle, srcPtr, byteSize) => {
state.writeBufferCount = (state.writeBufferCount || 0) + 1;
state.lastWriteHandle = handle;
state.lastWriteSize = byteSize;
const buf = buffers.get(handle);
if (!buf) return;
// writeBuffer requires a multiple of 4 bytes and an aligned offset.
const aligned = (byteSize + 3) & ~3;
queue.writeBuffer(buf, 0, memU8().buffer, srcPtr, aligned);
};
env.wgpuDestroyBuffer = (handle) => {
const buf = buffers.get(handle);
if (buf) { buf.destroy(); buffers.delete(handle); }
// Invalidate any cached bind group that referenced this handle.
for (const k of state.bindGroupCache.keys()) {
if (k.startsWith("items/") && k.endsWith("/" + handle)) {
state.bindGroupCache.delete(k);
}
}
};
env.wgpuCreateAtlasTexture = (w, h) => {
const handle = newHandle();
const tex = device.createTexture({
size: [w, h],
format: "r8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
textures.set(handle, tex);
textureViews.set(handle, tex.createView());
return handle;
};
env.wgpuWriteAtlasRegion = (handle, srcPtr, srcW, srcH, srcBytesPerRow, dstX, dstY, copyW, copyH) => {
const tex = textures.get(handle);
if (!tex) return;
// For r8unorm, 1 byte per pixel; writeTexture requires bytesPerRow >= 256
// OR == width if width*1 % 256 === 0 — for arbitrary widths we need to
// re-pack into a 256-aligned staging buffer.
const alignedBPR = Math.max(256, (srcBytesPerRow + 255) & ~255);
if (alignedBPR === srcBytesPerRow) {
const bytes = memU8().subarray(srcPtr + dstY * srcBytesPerRow + dstX,
srcPtr + (dstY + copyH) * srcBytesPerRow);
queue.writeTexture(
{ texture: tex, origin: { x: dstX, y: dstY } },
bytes,
{ bytesPerRow: srcBytesPerRow, rowsPerImage: copyH },
{ width: copyW, height: copyH }
);
} else {
// Repack copyW × copyH starting at (dstX, dstY) in the source.
const staging = new Uint8Array(alignedBPR * copyH);
const src = memU8();
for (let y = 0; y < copyH; y++) {
const srcRow = (dstY + y) * srcBytesPerRow + dstX;
staging.set(src.subarray(srcPtr + srcRow, srcPtr + srcRow + copyW),
y * alignedBPR);
}
queue.writeTexture(
{ texture: tex, origin: { x: dstX, y: dstY } },
staging,
{ bytesPerRow: alignedBPR, rowsPerImage: copyH },
{ width: copyW, height: copyH }
);
}
};
env.wgpuDestroyTexture = (handle) => {
const tex = textures.get(handle);
if (tex) { tex.destroy(); textures.delete(handle); textureViews.delete(handle); }
};
env.wgpuCreateLinearClampSampler = () => {
const handle = newHandle();
samplers.set(handle, device.createSampler({
magFilter: "linear", minFilter: "linear",
addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge",
}));
return handle;
};
// ─── per-frame ──────────────────────────────────────────────────────────
env.wgpuFrameBegin = () => {
state.frameBeginCount = (state.frameBeginCount || 0) + 1;
if (state.gpuLost) return;
ensureSized();
state.encoder = device.createCommandEncoder();
state.outIsPing = true; // reset so each frame starts on the same target
state.headerRingOffset = 0;
// DON'T clearBuffer the header ring here. queue.writeBuffer ops from
// writeHeader() are enqueued BEFORE this command buffer's submit,
// so an encoded clearBuffer would wipe them — the dispatches would
// then read all-zero headers and uiResolvePixel would reject every
// pixel (surfaceW=0).
clearStorageTexture(state.encoder, state.outIsPing ? state.pingTex : state.pongTex,
state.width, state.height);
state.pass = state.encoder.beginComputePass();
};
let zeroBuffer = null;
let zeroBufferSize = 0;
function clearStorageTexture(encoder, tex, w, h) {
const bpr = (w * 4 + 255) & ~255;
const need = bpr * h;
if (!zeroBuffer || zeroBufferSize < need) {
if (zeroBuffer) zeroBuffer.destroy();
zeroBuffer = device.createBuffer({ size: need, usage: GPUBufferUsage.COPY_SRC, mappedAtCreation: true });
new Uint8Array(zeroBuffer.getMappedRange()).fill(0);
zeroBuffer.unmap();
zeroBufferSize = need;
}
encoder.copyBufferToTexture(
{ buffer: zeroBuffer, bytesPerRow: bpr, rowsPerImage: h },
{ texture: tex },
{ width: w, height: h, depthOrArrayLayers: 1 }
);
}
env.wgpuFrameEnd = () => {
state.frameEndCount = (state.frameEndCount || 0) + 1;
if (state.gpuLost || !state.encoder) return;
state.pass.end();
state.pass = null;
// Blit last-written ping-pong texture → canvas. After N dispatches,
// state.outIsPing points at the NEXT write target, so the latest
// content lives in the OPPOSITE texture.
const finalTex = state.outIsPing ? state.pongTex : state.pingTex;
const canvasTex = ctx.getCurrentTexture();
state.encoder.copyTextureToTexture(
{ texture: finalTex },
{ texture: canvasTex },
{ width: state.width, height: state.height, depthOrArrayLayers: 1 }
);
queue.submit([state.encoder.finish()]);
state.encoder = null;
};
// Write a 48-byte UIDispatchHeader into the ring buffer at the current
// offset (which is incremented and 256-aligned). Returns the dynamic
// offset to pass to setBindGroup.
function writeHeader(headerPtr) {
const offset = state.headerRingOffset;
if (offset + HEADER_ALIGN > state.headerRingSize) {
// Ring is small enough that overrun in one frame means too many
// dispatches. Soft-wrap; correctness already requires the ring
// be large enough.
state.headerRingOffset = 0;
}
queue.writeBuffer(state.headerRing, state.headerRingOffset,
memU8().buffer, headerPtr, 48);
state.headerRingOffset += HEADER_ALIGN;
return offset;
}
function dispatchStandard(pipe, hdrBindGroup, headerPtr, gx, gy, itemsHandle, group3) {
if (!state.pass) return;
const off = writeHeader(headerPtr);
state.pass.setPipeline(pipe.pipeline);
state.pass.setBindGroup(0, hdrBindGroup, [off]);
state.pass.setBindGroup(1, getGroup1BG(pipe));
state.pass.setBindGroup(2, getGroup2BG(pipe, itemsHandle));
if (group3) state.pass.setBindGroup(3, group3);
state.pass.dispatchWorkgroups(gx, gy, 1);
// Flip ping-pong: the texture we just wrote becomes next dispatch's prev.
state.outIsPing = !state.outIsPing;
}
env.wgpuDispatchQuads = (itemsHandle, headerPtr, gx, gy) => {
state.dispatchQuadsCount = (state.dispatchQuadsCount || 0) + 1;
dispatchStandard(pipeQuads, hdrBG.quads, headerPtr, gx, gy, itemsHandle, null);
};
env.wgpuDispatchCircles = (itemsHandle, headerPtr, gx, gy) => {
dispatchStandard(pipeCircles, hdrBG.circles, headerPtr, gx, gy, itemsHandle, null);
};
env.wgpuDispatchImages = (itemsHandle, headerPtr, gx, gy, texHandle, sampHandle) => {
const g3 = getGroup3BG(pipeImages, texHandle, sampHandle);
dispatchStandard(pipeImages, hdrBG.images, headerPtr, gx, gy, itemsHandle, g3);
};
env.wgpuDispatchText = (itemsHandle, headerPtr, gx, gy, atlasHandle, sampHandle) => {
const g3 = getGroup3BG(pipeText, atlasHandle, sampHandle);
dispatchStandard(pipeText, hdrBG.text, headerPtr, gx, gy, itemsHandle, g3);
};
// Debug accessor for browser-console diagnostics.
window.crafter_wgpu_state = state;
window.crafter_wgpu_device = device;
window.crafter_wgpu_canvasCtx = ctx;
window.crafter_wgpu_debug = () => ({
width: state.width, height: state.height,
outIsPing: state.outIsPing,
encoderActive: !!state.encoder,
passActive: !!state.pass,
bgCacheSize: state.bindGroupCache.size,
bufferHandles: buffers.size,
textureHandles: textures.size,
samplerHandles: samplers.size,
headerRingOffset: state.headerRingOffset,
frameBeginCount: state.frameBeginCount || 0,
frameEndCount: state.frameEndCount || 0,
dispatchQuadsCount: state.dispatchQuadsCount || 0,
writeBufferCount: state.writeBufferCount || 0,
lastWriteHandle: state.lastWriteHandle,
lastWriteSize: state.lastWriteSize,
});
// Read back the first QuadItem from a registered buffer to verify the
// GPU sees what the CPU wrote.
window.crafter_wgpu_readBuffer = async (handle, byteSize = 64) => {
const buf = buffers.get(handle);
if (!buf) return "no buffer for handle " + handle;
const read = device.createBuffer({ size: 256, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
const enc = device.createCommandEncoder();
enc.copyBufferToBuffer(buf, 0, read, 0, byteSize);
device.queue.submit([enc.finish()]);
await read.mapAsync(GPUMapMode.READ);
const data = new Float32Array(read.getMappedRange().slice(0, byteSize));
read.unmap();
return [...data];
};
// Surface size getters (the wasm side may query these on Resize events).
env.wgpuSurfaceWidth = () => state.width || canvas.width;
env.wgpuSurfaceHeight = () => state.height || canvas.height;
// One-shot init: forces ping-pong allocation at current canvas size so
// any Buffer/Texture creation before the first frame works against a
// concrete size. Called by Crafter::Device::Initialize on the wasm side.
env.wgpuInit = () => {
const { w, h } = syncCanvasSize();
recreatePingPong(w, h);
};
// Resize listener — wires up to the same `resize` event dom-env.js
// listens to. We trigger sizing on next frame begin; no work here.
window.addEventListener("resize", () => { /* ensureSized in wgpuFrameBegin */ });
console.log("[crafter-wgpu] init complete; env handlers wired");
} catch (e) {
// Capture any throw so the stub error messages name the real cause
// instead of "(no error captured)". Re-throw so runtime.js's catch
// also logs it.
initError = e instanceof Error ? e : new Error(String(e));
console.error("[crafter-wgpu] init failed:", initError);
throw initError;
}
})(); // end window.crafter_webbuild_env_ready

View file

@ -1,2 +0,0 @@
#!/usr/bin/env sh
caddy file-server --listen :8080 --root bin/HelloDom-wasm32-wasip1-native-native-df37fe0fe124fe57

View file

@ -4,7 +4,9 @@
// (Tier 2 standard shader, dispatched directly). Hit-testing for the button // (Tier 2 standard shader, dispatched directly). Hit-testing for the button
// and slider is the user's responsibility — see the onMouseMove listener. // and slider is the user's responsibility — see the onMouseMove listener.
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h" #include "vulkan/vulkan.h"
#endif
import Crafter.Graphics; import Crafter.Graphics;
import Crafter.Event; import Crafter.Event;
@ -13,11 +15,15 @@ using namespace Crafter;
int main() { int main() {
Device::Initialize(); Device::Initialize();
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
static Window window(1280, 720, "Hello UI");
#else
Window window(1280, 720, "Hello UI"); Window window(1280, 720, "Hello UI");
#endif
VkCommandBuffer init = window.StartInit(); auto init = window.StartInit();
DescriptorHeapVulkan heap; GraphicsDescriptorHeap heap;
heap.Initialize(/*images*/ 16, /*buffers*/ 16, /*samplers*/ 4); heap.Initialize(/*images*/ 16, /*buffers*/ 16, /*samplers*/ 4);
window.descriptorHeap = &heap; window.descriptorHeap = &heap;
@ -33,9 +39,10 @@ int main() {
// User-owned per-shader buffers. Mapped, written each frame, dispatched // User-owned per-shader buffers. Mapped, written each frame, dispatched
// by the user. Capacity is up to the user; resize means re-Register. // by the user. Capacity is up to the user; resize means re-Register.
VulkanBuffer<QuadItem, true> quadsBuf; GraphicsBuffer<QuadItem, true> quadsBuf;
VulkanBuffer<CircleItem, true> circlesBuf; GraphicsBuffer<CircleItem, true> circlesBuf;
VulkanBuffer<GlyphItem, true> glyphsBuf; GraphicsBuffer<GlyphItem, true> glyphsBuf;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
quadsBuf.Create( quadsBuf.Create(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 256); VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 256);
@ -45,6 +52,11 @@ int main() {
glyphsBuf.Create( glyphsBuf.Create(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 4096); VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 4096);
#else
quadsBuf.Create(256);
circlesBuf.Create(64);
glyphsBuf.Create(4096);
#endif
auto quadsSlot = ui.RegisterBuffer(quadsBuf); auto quadsSlot = ui.RegisterBuffer(quadsBuf);
auto circlesSlot = ui.RegisterBuffer(circlesBuf); auto circlesSlot = ui.RegisterBuffer(circlesBuf);
@ -102,7 +114,7 @@ int main() {
}; };
EventListener<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) { EventListener<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) {
VkCommandBuffer cmd = a.cmd; auto cmd = a.cmd;
// Update demo progress. // Update demo progress.
progress = std::fmod(progress + 0.005f, 1.0f); progress = std::fmod(progress + 0.005f, 1.0f);
@ -160,15 +172,27 @@ int main() {
// Flush + dispatch. The library inserts the inter-dispatch barriers. // Flush + dispatch. The library inserts the inter-dispatch barriers.
if (qc > 0) { if (qc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
#else
quadsBuf.FlushDevice();
#endif
ui.DispatchQuads(cmd, quadsSlot, qc); ui.DispatchQuads(cmd, quadsSlot, qc);
} }
if (cc > 0) { if (cc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
circlesBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); circlesBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
#else
circlesBuf.FlushDevice();
#endif
ui.DispatchCircles(cmd, circlesSlot, cc); ui.DispatchCircles(cmd, circlesSlot, cc);
} }
if (gc > 0) { if (gc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
glyphsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); glyphsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
#else
glyphsBuf.FlushDevice();
#endif
ui.DispatchText(cmd, glyphsSlot, gc); ui.DispatchText(cmd, glyphsSlot, gc);
} }
}); });

View file

@ -4,6 +4,14 @@ namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) { 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;
}
}
Configuration* graphics = LocalProject({ Configuration* graphics = LocalProject({
.projectFile = "../../project.cpp", .projectFile = "../../project.cpp",
.args = std::vector<std::string>(args.begin(), args.end()), .args = std::vector<std::string>(args.begin(), args.end()),
@ -13,6 +21,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.path = "./"; cfg.path = "./";
cfg.name = "HelloUI"; cfg.name = "HelloUI";
cfg.outputName = "HelloUI"; cfg.outputName = "HelloUI";
cfg.type = ConfigurationType::Executable;
if (isWasm) {
cfg.target = "wasm32-wasip1";
cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_DOM", ""});
}
ApplyStandardArgs(cfg, args); ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics }; cfg.dependencies = { graphics };
@ -21,5 +34,9 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.files.push_back("font.ttf"); cfg.files.push_back("font.ttf");
if (isWasm) {
EnableWasiBrowserRuntime(cfg);
}
return cfg; return cfg;
} }

View file

@ -2,47 +2,53 @@
Crafter®.Graphics Crafter®.Graphics
Copyright (C) 2026 Catcrafts® Copyright (C) 2026 Catcrafts®
catcrafts.net 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;
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/ */
module; module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h" #include "vulkan/vulkan.h"
#endif
#include "../lib/stb_truetype.h" #include "../lib/stb_truetype.h"
module Crafter.Graphics:FontAtlas_impl; module Crafter.Graphics:FontAtlas_impl;
import :FontAtlas; import :FontAtlas;
import :Font; import :Font;
import :GraphicsTypes;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import :ImageVulkan; import :ImageVulkan;
import :Device; import :Device;
#else
import :WebGPU;
#endif
import std; import std;
using namespace Crafter; using namespace Crafter;
void FontAtlas::Initialize(VkCommandBuffer cmd) { std::uint8_t* FontAtlas::PixelPtr() noexcept {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
return image.buffer.value;
#else
return staging.data();
#endif
}
void FontAtlas::Initialize(GraphicsCommandBuffer cmd) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
image.Create( image.Create(
kAtlasSize, kAtlasSize, /*mipLevels*/ 1, cmd, kAtlasSize, kAtlasSize, /*mipLevels*/ 1, cmd,
VK_FORMAT_R8_UNORM, VK_FORMAT_R8_UNORM,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
); );
// Staging buffer is mapped; clear it so empty atlas regions sample as
// distance < onedge (i.e. fully outside any glyph).
std::memset(image.buffer.value, 0, kAtlasSize * kAtlasSize); std::memset(image.buffer.value, 0, kAtlasSize * kAtlasSize);
dirty = true; dirty = true;
#else
(void)cmd;
staging.assign(kAtlasSize * kAtlasSize, 0);
textureHandle = WebGPU::wgpuCreateAtlasTexture(kAtlasSize, kAtlasSize);
dirty = true;
#endif
} }
bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) { bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) {
// Try existing shelves first — same height heuristic keeps fragmentation low.
for (Shelf& s : shelves_) { for (Shelf& s : shelves_) {
if (h <= s.height && s.cursorX + w <= kAtlasSize) { if (h <= s.height && s.cursorX + w <= kAtlasSize) {
outX = s.cursorX; outX = s.cursorX;
@ -51,7 +57,6 @@ bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) {
return true; return true;
} }
} }
// New shelf below current ones.
if (nextShelfY_ + h > kAtlasSize) return false; if (nextShelfY_ + h > kAtlasSize) return false;
Shelf s{}; Shelf s{};
s.y = nextShelfY_; s.y = nextShelfY_;
@ -70,7 +75,6 @@ bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize); float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize);
// Advance is always present, even for empty glyphs (e.g. space).
int advanceUnits = 0, lsb = 0; int advanceUnits = 0, lsb = 0;
stbtt_GetCodepointHMetrics(&font.font, static_cast<int>(codepoint), &advanceUnits, &lsb); stbtt_GetCodepointHMetrics(&font.font, static_cast<int>(codepoint), &advanceUnits, &lsb);
@ -90,12 +94,12 @@ bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
int px = 0, py = 0; int px = 0, py = 0;
if (!ShelfPlace(sw, sh, px, py)) { if (!ShelfPlace(sw, sh, px, py)) {
stbtt_FreeSDF(sdf, nullptr); stbtt_FreeSDF(sdf, nullptr);
return false; // V1: silently drop overflow; V2: grow atlas return false;
} }
// Blit row-by-row into the mapped staging buffer. std::uint8_t* dst = PixelPtr();
for (int row = 0; row < sh; ++row) { for (int row = 0; row < sh; ++row) {
std::memcpy( std::memcpy(
image.buffer.value + (py + row) * kAtlasSize + px, dst + (py + row) * kAtlasSize + px,
sdf + row * sw, sdf + row * sw,
static_cast<std::size_t>(sw) static_cast<std::size_t>(sw)
); );
@ -110,8 +114,6 @@ bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
g.v1 = static_cast<float>(py + sh) / kAtlasSize; g.v1 = static_cast<float>(py + sh) / kAtlasSize;
dirty = true; dirty = true;
} }
// For empty glyphs (whitespace) we still cache the entry — the size-0
// fields tell the emitter to skip the quad but advance the cursor.
cache_.emplace(key, g); cache_.emplace(key, g);
return true; return true;
@ -122,8 +124,18 @@ const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
return it == cache_.end() ? nullptr : &it->second; return it == cache_.end() ? nullptr : &it->second;
} }
void FontAtlas::Update(VkCommandBuffer cmd) { void FontAtlas::Update(GraphicsCommandBuffer cmd) {
if (!dirty) return; if (!dirty) return;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
image.Update(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); image.Update(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
#else
(void)cmd;
// Full-atlas upload. Future: track dirty region.
WebGPU::wgpuWriteAtlasRegion(
textureHandle, staging.data(),
kAtlasSize, kAtlasSize, kAtlasSize,
0, 0, kAtlasSize, kAtlasSize
);
#endif
dirty = false; dirty = false;
} }

View file

@ -0,0 +1,86 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// Backend-agnostic UIRenderer methods. FillHeader and ShapeText do not
// touch any GPU API; they only read window dimensions and (for ShapeText)
// the CPU-side font atlas. Split out so both Vulkan and WebGPU impls
// share the same source.
module Crafter.Graphics:UI_shared_impl;
import :UI;
import :Window;
import :Font;
import :FontAtlas;
import :GraphicsTypes;
import std;
using namespace Crafter;
UIDispatchHeader UIRenderer::FillHeader(std::uint32_t itemBufferSlot,
std::uint32_t itemCount,
std::array<float,4> clipRectPx,
std::uint32_t flags) const noexcept {
UIDispatchHeader h{};
h.outImage = outImageSlot_;
h.itemBuffer = itemBufferSlot;
h.surfaceWidth = window_->width;
h.surfaceHeight = window_->height;
h.clipX = clipRectPx[0];
h.clipY = clipRectPx[1];
h.clipW = clipRectPx[2];
h.clipH = clipRectPx[3];
h.itemCount = itemCount;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
h.frameIdx = window_->currentBuffer;
#else
h.frameIdx = 0;
#endif
h.flags = flags;
h._pad = 0;
return h;
}
std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
float x, float baselineY,
std::string_view utf8,
std::array<float,4> color,
GlyphItem* out, std::uint32_t outCapacity,
float* outAdvance) {
if (fontAtlas == nullptr) {
std::println("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)");
std::abort();
}
const float scale = pxSize / FontAtlas::kBaseSize;
float cursor = x;
std::uint32_t written = 0;
std::size_t i = 0;
while (i < utf8.size() && written < outCapacity) {
std::uint32_t cp = DecodeUtf8(utf8, i);
if (cp == 0) break;
if (cp == '\n') { continue; }
fontAtlas->Ensure(font, cp);
const Glyph* g = fontAtlas->Lookup(font, cp);
if (g == nullptr) continue;
if (g->w > 0 && g->h > 0) {
GlyphItem& gi = out[written++];
gi.x = cursor + g->xoff * scale;
gi.y = baselineY + g->yoff * scale;
gi.w = g->w * scale;
gi.h = g->h * scale;
gi.u0 = g->u0; gi.v0 = g->v0;
gi.u1 = g->u1; gi.v1 = g->v1;
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
}
cursor += g->advance * scale;
}
if (outAdvance) *outAdvance = cursor - x;
return written;
}

View file

@ -0,0 +1,140 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// WebGPU UIRenderer implementation — DOM mode parallel to
// Crafter.Graphics-UI.cpp. Compute pipelines and bind groups live JS-side
// in additional/dom-webgpu.js; this file just translates UIRenderer's
// public method calls into wgpu* import calls.
module Crafter.Graphics:UI_webgpu_impl;
import :UI;
import :Window;
import :Font;
import :FontAtlas;
import :WebGPU;
import :WebGPUBuffer;
import :DescriptorHeapWebGPU;
import :GraphicsTypes;
import std;
using namespace Crafter;
void UIRenderer::Initialize(Window& window, GraphicsDescriptorHeap& heap, GraphicsCommandBuffer initCmd,
std::filesystem::path /*quadsSpv*/,
std::filesystem::path /*circlesSpv*/,
std::filesystem::path /*imagesSpv*/,
std::filesystem::path /*textSpv*/) {
(void)initCmd;
window_ = &window;
heap_ = &heap;
// The JS bridge owns the compute pipelines (4 of them, precompiled at
// page load). The C++ side has nothing to load. We do reserve one
// image slot for the swapchain output (kept symmetrical with Vulkan)
// and register the font atlas if one was set.
auto outRange = heap_->AllocateImageSlots(1);
outImageSlot_ = ImageSlot{heap_, outRange.firstElement};
// No JS texture handle stored at this slot — the canvas's
// getCurrentTexture() is per-frame and the JS bridge resolves it
// internally. The slot value is only meaningful through
// UIDispatchHeader.outImage, which the WGSL shaders ignore.
if (fontAtlas != nullptr) {
auto atlasImg = heap_->AllocateImageSlots(1);
fontAtlasImageSlot_ = ImageSlot{heap_, atlasImg.firstElement};
heap_->imageTable[atlasImg.firstElement] = fontAtlas->textureHandle;
fontAtlasSamplerSlot_ = RegisterLinearClampSampler();
}
resizeSub_.SetEvent(&window.onResize, [this]() {
// Storage textures + bind groups are recreated JS-side at next
// wgpuFrameBegin (via the ensureSized path). Nothing to do here.
});
}
void UIRenderer::Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) {
if (fontAtlas != nullptr && fontAtlas->dirty) {
fontAtlas->Update(cmd);
}
onBuild.Invoke({cmd, frameIdx});
if (fontAtlas != nullptr && fontAtlas->dirty) {
fontAtlas->Update(cmd);
}
(void)window;
}
namespace {
inline std::uint32_t TilesFor(std::uint32_t dim) { return (dim + 7u) / 8u; }
}
void UIRenderer::DispatchQuads(GraphicsCommandBuffer /*cmd*/, std::uint32_t bufferSlot,
std::uint32_t itemCount,
std::array<float,4> clipRectPx) {
if (itemCount == 0) return;
UIDispatchHeader hdr = FillHeader(bufferSlot, itemCount, clipRectPx);
auto handle = heap_->bufferTable[bufferSlot];
WebGPU::wgpuDispatchQuads(handle, &hdr,
static_cast<std::int32_t>(TilesFor(window_->width)),
static_cast<std::int32_t>(TilesFor(window_->height)));
}
void UIRenderer::DispatchCircles(GraphicsCommandBuffer /*cmd*/, std::uint32_t bufferSlot,
std::uint32_t itemCount,
std::array<float,4> clipRectPx) {
if (itemCount == 0) return;
UIDispatchHeader hdr = FillHeader(bufferSlot, itemCount, clipRectPx);
auto handle = heap_->bufferTable[bufferSlot];
WebGPU::wgpuDispatchCircles(handle, &hdr,
static_cast<std::int32_t>(TilesFor(window_->width)),
static_cast<std::int32_t>(TilesFor(window_->height)));
}
void UIRenderer::DispatchImages(GraphicsCommandBuffer /*cmd*/, std::uint32_t bufferSlot,
std::uint32_t itemCount,
std::array<float,4> clipRectPx) {
if (itemCount == 0) return;
UIDispatchHeader hdr = FillHeader(bufferSlot, itemCount, clipRectPx);
auto handle = heap_->bufferTable[bufferSlot];
// For DispatchImages, the WGSL expects a texture + sampler in group 3.
// The library v1 doesn't expose user-image registration on DOM (out of
// scope per plan). If the user calls DispatchImages without a registered
// image, fall back to using the font atlas binding — the user's items
// should reference texSlot/sampSlot but on DOM those are ignored. For
// now, route through the font atlas texture if available; otherwise
// skip the dispatch.
if (fontAtlasImageSlot_) {
auto texHandle = heap_->imageTable[fontAtlasImageSlot_];
auto sampHandle = heap_->samplerTable[fontAtlasSamplerSlot_];
WebGPU::wgpuDispatchImages(handle, &hdr,
static_cast<std::int32_t>(TilesFor(window_->width)),
static_cast<std::int32_t>(TilesFor(window_->height)),
texHandle, sampHandle);
}
}
void UIRenderer::DispatchText(GraphicsCommandBuffer /*cmd*/, std::uint32_t bufferSlot,
std::uint32_t itemCount,
std::array<float,4> clipRectPx) {
if (itemCount == 0) return;
if (!fontAtlasImageSlot_) {
std::println("UIRenderer::DispatchText: no FontAtlas registered (set fontAtlas before Initialize)");
std::abort();
}
UIDispatchHeader hdr = FillHeader(bufferSlot, itemCount, clipRectPx);
auto bufHandle = heap_->bufferTable[bufferSlot];
auto texHandle = heap_->imageTable[fontAtlasImageSlot_];
auto sampHandle = heap_->samplerTable[fontAtlasSamplerSlot_];
WebGPU::wgpuDispatchText(bufHandle, &hdr,
static_cast<std::int32_t>(TilesFor(window_->width)),
static_cast<std::int32_t>(TilesFor(window_->height)),
texHandle, sampHandle);
}
SamplerSlot UIRenderer::RegisterLinearClampSampler() {
auto range = heap_->AllocateSamplerSlots(1);
heap_->samplerTable[range.firstElement] = WebGPU::wgpuCreateLinearClampSampler();
return SamplerSlot{heap_, range.firstElement};
}

View file

@ -28,13 +28,14 @@ import :ImageVulkan;
import :VulkanBuffer; import :VulkanBuffer;
import :FontAtlas; import :FontAtlas;
import :Font; import :Font;
import :GraphicsTypes;
import std; import std;
using namespace Crafter; using namespace Crafter;
// ─── Initialize ───────────────────────────────────────────────────────── // ─── Initialize ─────────────────────────────────────────────────────────
void UIRenderer::Initialize(Window& window, DescriptorHeapVulkan& heap, VkCommandBuffer initCmd, void UIRenderer::Initialize(Window& window, GraphicsDescriptorHeap& heap, GraphicsCommandBuffer initCmd,
std::filesystem::path quadsSpv, std::filesystem::path quadsSpv,
std::filesystem::path circlesSpv, std::filesystem::path circlesSpv,
std::filesystem::path imagesSpv, std::filesystem::path imagesSpv,
@ -81,7 +82,7 @@ void UIRenderer::Initialize(Window& window, DescriptorHeapVulkan& heap, VkComman
// ─── per-frame Record ─────────────────────────────────────────────────── // ─── per-frame Record ───────────────────────────────────────────────────
void UIRenderer::Record(VkCommandBuffer cmd, std::uint32_t frameIdx, Window& window) { void UIRenderer::Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) {
// Reset per-frame state. // Reset per-frame state.
firstDispatchThisFrame_ = true; firstDispatchThisFrame_ = true;
@ -102,28 +103,6 @@ void UIRenderer::Record(VkCommandBuffer cmd, std::uint32_t frameIdx, Window& win
(void)window; (void)window;
} }
// ─── header builder ─────────────────────────────────────────────────────
UIDispatchHeader UIRenderer::FillHeader(std::uint32_t itemBufferSlot,
std::uint32_t itemCount,
std::array<float,4> clipRectPx,
std::uint32_t flags) const noexcept {
UIDispatchHeader h{};
h.outImage = outImageSlot_;
h.itemBuffer = itemBufferSlot;
h.surfaceWidth = window_->width;
h.surfaceHeight = window_->height;
h.clipX = clipRectPx[0];
h.clipY = clipRectPx[1];
h.clipW = clipRectPx[2];
h.clipH = clipRectPx[3];
h.itemCount = itemCount;
h.frameIdx = window_->currentBuffer;
h.flags = flags;
h._pad = 0;
return h;
}
// ─── group-count helper ──────────────────────────────────────────────── // ─── group-count helper ────────────────────────────────────────────────
namespace { namespace {
@ -142,7 +121,7 @@ namespace {
// without inter-workgroup races on imageLoad/imageStore — the bug that the // without inter-workgroup races on imageLoad/imageStore — the bug that the
// per-item dispatch model had. // per-item dispatch model had.
void UIRenderer::DispatchQuads(VkCommandBuffer cmd, std::uint32_t bufferSlot, void UIRenderer::DispatchQuads(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot,
std::uint32_t itemCount, std::uint32_t itemCount,
std::array<float,4> clipRectPx) { std::array<float,4> clipRectPx) {
if (itemCount == 0) return; if (itemCount == 0) return;
@ -151,7 +130,7 @@ void UIRenderer::DispatchQuads(VkCommandBuffer cmd, std::uint32_t bufferSlot,
TilesFor(window_->width), TilesFor(window_->height), 1u); TilesFor(window_->width), TilesFor(window_->height), 1u);
} }
void UIRenderer::DispatchCircles(VkCommandBuffer cmd, std::uint32_t bufferSlot, void UIRenderer::DispatchCircles(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot,
std::uint32_t itemCount, std::uint32_t itemCount,
std::array<float,4> clipRectPx) { std::array<float,4> clipRectPx) {
if (itemCount == 0) return; if (itemCount == 0) return;
@ -160,7 +139,7 @@ void UIRenderer::DispatchCircles(VkCommandBuffer cmd, std::uint32_t bufferSlot,
TilesFor(window_->width), TilesFor(window_->height), 1u); TilesFor(window_->width), TilesFor(window_->height), 1u);
} }
void UIRenderer::DispatchImages(VkCommandBuffer cmd, std::uint32_t bufferSlot, void UIRenderer::DispatchImages(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot,
std::uint32_t itemCount, std::uint32_t itemCount,
std::array<float,4> clipRectPx) { std::array<float,4> clipRectPx) {
if (itemCount == 0) return; if (itemCount == 0) return;
@ -169,7 +148,7 @@ void UIRenderer::DispatchImages(VkCommandBuffer cmd, std::uint32_t bufferSlot,
TilesFor(window_->width), TilesFor(window_->height), 1u); TilesFor(window_->width), TilesFor(window_->height), 1u);
} }
void UIRenderer::DispatchText(VkCommandBuffer cmd, std::uint32_t bufferSlot, void UIRenderer::DispatchText(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot,
std::uint32_t itemCount, std::uint32_t itemCount,
std::array<float,4> clipRectPx) { std::array<float,4> clipRectPx) {
if (itemCount == 0) return; if (itemCount == 0) return;
@ -199,7 +178,7 @@ void UIRenderer::DispatchText(VkCommandBuffer cmd, std::uint32_t bufferSlot,
// ─── generic Dispatch (with barrier) ──────────────────────────────────── // ─── generic Dispatch (with barrier) ────────────────────────────────────
void UIRenderer::Dispatch(VkCommandBuffer cmd, const ComputeShader& shader, void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const ComputeShader& shader,
const void* push, std::uint32_t pushBytes, const void* push, std::uint32_t pushBytes,
std::uint32_t gx, std::uint32_t gy, std::uint32_t gz) { std::uint32_t gx, std::uint32_t gy, std::uint32_t gz) {
if (!firstDispatchThisFrame_) { if (!firstDispatchThisFrame_) {
@ -356,46 +335,3 @@ SamplerSlot UIRenderer::RegisterLinearClampSampler() {
return RegisterSampler(s); return RegisterSampler(s);
} }
// ─── ShapeText ─────────────────────────────────────────────────────────
std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
float x, float baselineY,
std::string_view utf8,
std::array<float,4> color,
GlyphItem* out, std::uint32_t outCapacity,
float* outAdvance) {
if (fontAtlas == nullptr) {
throw std::runtime_error("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)");
}
const float scale = pxSize / FontAtlas::kBaseSize;
float cursor = x;
std::uint32_t written = 0;
std::size_t i = 0;
while (i < utf8.size() && written < outCapacity) {
std::uint32_t cp = DecodeUtf8(utf8, i);
if (cp == 0) break;
if (cp == '\n') { /* single-line shaper — ignore */ continue; }
fontAtlas->Ensure(font, cp);
const Glyph* g = fontAtlas->Lookup(font, cp);
if (g == nullptr) continue;
// Empty glyph (whitespace) — advance only.
if (g->w > 0 && g->h > 0) {
GlyphItem& gi = out[written++];
gi.x = cursor + g->xoff * scale;
gi.y = baselineY + g->yoff * scale;
gi.w = g->w * scale;
gi.h = g->h * scale;
gi.u0 = g->u0; gi.v0 = g->v0;
gi.u1 = g->u1; gi.v1 = g->v1;
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
}
cursor += g->advance * scale;
}
if (outAdvance) *outAdvance = cursor - x;
return written;
}

View file

@ -69,6 +69,10 @@ import :Gamepad;
import :VulkanTransition; import :VulkanTransition;
import :DescriptorHeapVulkan; import :DescriptorHeapVulkan;
import :RenderPass; import :RenderPass;
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import :WebGPU;
import :DescriptorHeapWebGPU;
#endif
import std; import std;
using namespace Crafter; using namespace Crafter;
@ -1323,23 +1327,22 @@ Window::Window(std::uint32_t w, std::uint32_t h, const std::string_view title)
Window::Window(std::uint32_t w, std::uint32_t h) : width(w), height(h) { Window::Window(std::uint32_t w, std::uint32_t h) : width(w), height(h) {
if (g_domWindow != nullptr) { if (g_domWindow != nullptr) {
// Only one Window per page in V1. Subsequent constructions are
// a programming error — log loudly and clobber the previous
// pointer so the new Window's events at least fire.
// (stderr isn't reachable via `import std;` on wasi-sdk yet; just log
// to cout. The browser console pipes both to the same place.)
std::println("Crafter::Window: only one DOM Window per page; " std::println("Crafter::Window: only one DOM Window per page; "
"overwriting the previous instance."); "overwriting the previous instance.");
} }
g_domWindow = this; g_domWindow = this;
// Use the browser-reported viewport size as the initial dimensions // Browser owns the real surface size. The width/height passed in are
// unless the caller asked for something specific. Browser owns the // advisory only — useful as a native-side hint, ignored on DOM. We
// real size; w/h passed in are advisory. // always sync to innerWidth/innerHeight so:
if (w == 0 || h == 0) { // - window.width/.height match the canvas's CSS pixel size,
// - MouseEvent.clientX/.clientY (CSS pixels) compare correctly
// against any layout done with window.width/.height,
// - the dispatch group count from window.width/8 covers the
// canvas exactly.
(void)w; (void)h;
width = static_cast<std::uint32_t>(Crafter::DomEnv::domGetInnerWidth()); width = static_cast<std::uint32_t>(Crafter::DomEnv::domGetInnerWidth());
height = static_cast<std::uint32_t>(Crafter::DomEnv::domGetInnerHeight()); height = static_cast<std::uint32_t>(Crafter::DomEnv::domGetInnerHeight());
}
// The handle passed to attach is just a non-zero token the JS side // The handle passed to attach is just a non-zero token the JS side
// includes back in every dispatcher call. We don't use it on the // includes back in every dispatcher call. We don't use it on the
@ -1394,10 +1397,18 @@ void Window::SetDefaultCursor() {
} }
void Window::StartSync() { void Window::StartSync() {
// Hand the loop to rAF. Returns immediately; the wasm `_start` // Hand the loop to rAF, then exit the wasm via _Exit so wasi-libc
// (main) finishes, and the runtime keeps the module alive while // skips __wasm_call_dtors. If we let main return normally, _start
// the JS-side rAF chain ticks `__crafterDom_frame`. // calls __wasm_call_dtors → static destructors fire (including
// Window's own), then __wasi_proc_exit → wasm trap. Subsequent rAF
// calls into the wasm would then trap too, killing rendering.
// _Exit jumps straight to __wasi_proc_exit, which our runtime.js
// catches via a thrown sentinel so the instance stays alive while
// every static-allocated object (Window, UIRenderer, GPU buffers,
// event listeners) remains untouched. Callers' code after
// StartSync() never runs — match that contract on native too.
Crafter::DomEnv::domStartFrameLoop(); Crafter::DomEnv::domStartFrameLoop();
std::_Exit(0);
} }
void Window::StartUpdate() { void Window::StartUpdate() {
@ -1421,10 +1432,25 @@ void Window::Update() {
} }
void Window::Render() { void Window::Render() {
// V1: no rendering in DOM mode. Kept as a callable no-op so if (!open) return;
// existing cross-platform code paths (e.g. main loops calling Crafter::WebGPU::wgpuFrameBegin();
// window.Render() before window.StartSync()) compile. V2 will for (RenderPass* p : passes) {
// hang the WebGPU command-submit here. if (p) p->Record(/*cmd*/ 0u, currentBuffer, *this);
}
Crafter::WebGPU::wgpuFrameEnd();
}
WebGPUCommandEncoderRef Window::StartInit() {
// DOM init: no command buffer needed — texture / buffer creation goes
// through synchronous wgpu* imports. Return 0 as a placeholder; the
// value is opaque to user code (auto-typed in HelloUI).
Crafter::WebGPU::wgpuInit();
return 0;
}
void Window::FinishInit() {
// Nothing to submit in DOM mode; all init writes are queued at call
// time via queue.writeBuffer / writeTexture.
} }
// ─── C exports the JS bridge calls back into ────────────────────────── // ─── C exports the JS bridge calls back into ──────────────────────────
@ -1437,6 +1463,7 @@ extern "C" {
g_domWindow->onBeforeUpdate.Invoke(); g_domWindow->onBeforeUpdate.Invoke();
if (g_domWindow->updating) { if (g_domWindow->updating) {
g_domWindow->Update(); g_domWindow->Update();
g_domWindow->Render();
} }
} }

View file

@ -0,0 +1,185 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// DOM-mode parallel to DescriptorHeapVulkan. WebGPU has no real bindless,
// so the "heap" is purely a CPU-side slot allocator with a side-table
// mapping slot → JS-side WebGPU handle. UIRenderer looks up the handle by
// slot at dispatch time to build (or fetch from cache) the bind group.
export module Crafter.Graphics:DescriptorHeapWebGPU;
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import :WebGPU;
export namespace Crafter {
struct DescriptorHeapWebGPU;
struct DescriptorRange {
std::uint16_t firstElement;
std::uint16_t count;
};
class BufferSlot {
public:
DescriptorHeapWebGPU* heap = nullptr;
std::uint16_t firstElement = 0xFFFF;
BufferSlot() = default;
BufferSlot(DescriptorHeapWebGPU* h, std::uint16_t f) : heap(h), firstElement(f) {}
BufferSlot(const BufferSlot&) = delete;
BufferSlot& operator=(const BufferSlot&) = delete;
BufferSlot(BufferSlot&& o) noexcept : heap(o.heap), firstElement(o.firstElement) { o.firstElement = 0xFFFF; }
BufferSlot& operator=(BufferSlot&& o) noexcept;
~BufferSlot();
explicit operator bool() const noexcept { return firstElement != 0xFFFF; }
operator std::uint16_t() const noexcept { return firstElement; }
};
class ImageSlot {
public:
DescriptorHeapWebGPU* heap = nullptr;
std::uint16_t firstElement = 0xFFFF;
ImageSlot() = default;
ImageSlot(DescriptorHeapWebGPU* h, std::uint16_t f) : heap(h), firstElement(f) {}
ImageSlot(const ImageSlot&) = delete;
ImageSlot& operator=(const ImageSlot&) = delete;
ImageSlot(ImageSlot&& o) noexcept : heap(o.heap), firstElement(o.firstElement) { o.firstElement = 0xFFFF; }
ImageSlot& operator=(ImageSlot&& o) noexcept;
~ImageSlot();
explicit operator bool() const noexcept { return firstElement != 0xFFFF; }
operator std::uint16_t() const noexcept { return firstElement; }
};
class SamplerSlot {
public:
DescriptorHeapWebGPU* heap = nullptr;
std::uint16_t firstElement = 0xFFFF;
SamplerSlot() = default;
SamplerSlot(DescriptorHeapWebGPU* h, std::uint16_t f) : heap(h), firstElement(f) {}
SamplerSlot(const SamplerSlot&) = delete;
SamplerSlot& operator=(const SamplerSlot&) = delete;
SamplerSlot(SamplerSlot&& o) noexcept : heap(o.heap), firstElement(o.firstElement) { o.firstElement = 0xFFFF; }
SamplerSlot& operator=(SamplerSlot&& o) noexcept;
~SamplerSlot();
explicit operator bool() const noexcept { return firstElement != 0xFFFF; }
operator std::uint16_t() const noexcept { return firstElement; }
};
struct DescriptorHeapWebGPU {
std::vector<WebGPUBufferRef> bufferTable;
std::vector<WebGPUTextureRef> imageTable;
std::vector<WebGPUSamplerRef> samplerTable;
std::vector<std::uint16_t> bufferFreelist;
std::vector<std::uint16_t> imageFreelist;
std::vector<std::uint16_t> samplerFreelist;
std::uint16_t nextBuffer = 0;
std::uint16_t nextImage = 0;
std::uint16_t nextSampler = 0;
void Initialize(std::uint16_t images, std::uint16_t buffers, std::uint16_t samplers) {
imageTable.assign(images, 0);
bufferTable.assign(buffers, 0);
samplerTable.assign(samplers, 0);
imageFreelist.reserve(images);
bufferFreelist.reserve(buffers);
samplerFreelist.reserve(samplers);
}
DescriptorRange AllocateBufferSlots(std::uint16_t count) {
if (count == 1 && !bufferFreelist.empty()) {
auto f = bufferFreelist.back(); bufferFreelist.pop_back();
return { f, 1 };
}
if (nextBuffer + count > bufferTable.size()) {
std::println("DescriptorHeapWebGPU: buffer slots exhausted");
std::abort();
}
DescriptorRange r{ nextBuffer, count };
nextBuffer = static_cast<std::uint16_t>(nextBuffer + count);
return r;
}
DescriptorRange AllocateImageSlots(std::uint16_t count) {
if (count == 1 && !imageFreelist.empty()) {
auto f = imageFreelist.back(); imageFreelist.pop_back();
return { f, 1 };
}
if (nextImage + count > imageTable.size()) {
std::println("DescriptorHeapWebGPU: image slots exhausted");
std::abort();
}
DescriptorRange r{ nextImage, count };
nextImage = static_cast<std::uint16_t>(nextImage + count);
return r;
}
DescriptorRange AllocateSamplerSlots(std::uint16_t count) {
if (count == 1 && !samplerFreelist.empty()) {
auto f = samplerFreelist.back(); samplerFreelist.pop_back();
return { f, 1 };
}
if (nextSampler + count > samplerTable.size()) {
std::println("DescriptorHeapWebGPU: sampler slots exhausted");
std::abort();
}
DescriptorRange r{ nextSampler, count };
nextSampler = static_cast<std::uint16_t>(nextSampler + count);
return r;
}
void FreeBufferSlots(std::uint16_t first, std::uint16_t count) noexcept {
for (std::uint16_t i = 0; i < count; ++i) {
bufferTable[first + i] = 0;
bufferFreelist.push_back(first + i);
}
}
void FreeImageSlots(std::uint16_t first, std::uint16_t count) noexcept {
for (std::uint16_t i = 0; i < count; ++i) {
imageTable[first + i] = 0;
imageFreelist.push_back(first + i);
}
}
void FreeSamplerSlots(std::uint16_t first, std::uint16_t count) noexcept {
for (std::uint16_t i = 0; i < count; ++i) {
samplerTable[first + i] = 0;
samplerFreelist.push_back(first + i);
}
}
};
// ─── slot dtors (defined here since they reference DescriptorHeapWebGPU) ─
inline BufferSlot::~BufferSlot() {
if (firstElement != 0xFFFF && heap) heap->FreeBufferSlots(firstElement, 1);
}
inline BufferSlot& BufferSlot::operator=(BufferSlot&& o) noexcept {
if (this != &o) {
if (firstElement != 0xFFFF && heap) heap->FreeBufferSlots(firstElement, 1);
heap = o.heap; firstElement = o.firstElement; o.firstElement = 0xFFFF;
}
return *this;
}
inline ImageSlot::~ImageSlot() {
if (firstElement != 0xFFFF && heap) heap->FreeImageSlots(firstElement, 1);
}
inline ImageSlot& ImageSlot::operator=(ImageSlot&& o) noexcept {
if (this != &o) {
if (firstElement != 0xFFFF && heap) heap->FreeImageSlots(firstElement, 1);
heap = o.heap; firstElement = o.firstElement; o.firstElement = 0xFFFF;
}
return *this;
}
inline SamplerSlot::~SamplerSlot() {
if (firstElement != 0xFFFF && heap) heap->FreeSamplerSlots(firstElement, 1);
}
inline SamplerSlot& SamplerSlot::operator=(SamplerSlot&& o) noexcept {
if (this != &o) {
if (firstElement != 0xFFFF && heap) heap->FreeSamplerSlots(firstElement, 1);
heap = o.heap; firstElement = o.firstElement; o.firstElement = 0xFFFF;
}
return *this;
}
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -19,13 +19,8 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/ */
module; module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "../lib/stb_truetype.h" #include "../lib/stb_truetype.h"
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
export module Crafter.Graphics:Font; export module Crafter.Graphics:Font;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import std; import std;
namespace Crafter { namespace Crafter {
@ -36,7 +31,6 @@ namespace Crafter {
if (i >= text.size()) return 0; if (i >= text.size()) return 0;
std::uint8_t b0 = static_cast<std::uint8_t>(text[i]); std::uint8_t b0 = static_cast<std::uint8_t>(text[i]);
// Single-byte ASCII is the common path.
if (b0 < 0x80) { ++i; return b0; } if (b0 < 0x80) { ++i; return b0; }
int extra; int extra;
@ -44,13 +38,13 @@ namespace Crafter {
if ((b0 & 0xE0) == 0xC0) { extra = 1; cp = b0 & 0x1F; } if ((b0 & 0xE0) == 0xC0) { extra = 1; cp = b0 & 0x1F; }
else if ((b0 & 0xF0) == 0xE0) { extra = 2; cp = b0 & 0x0F; } else if ((b0 & 0xF0) == 0xE0) { extra = 2; cp = b0 & 0x0F; }
else if ((b0 & 0xF8) == 0xF0) { extra = 3; cp = b0 & 0x07; } else if ((b0 & 0xF8) == 0xF0) { extra = 3; cp = b0 & 0x07; }
else { ++i; return 0xFFFD; } // continuation byte at start, or 5+-byte leader else { ++i; return 0xFFFD; }
++i; ++i;
for (int k = 0; k < extra; ++k) { for (int k = 0; k < extra; ++k) {
if (i >= text.size()) return 0xFFFD; if (i >= text.size()) return 0xFFFD;
std::uint8_t b = static_cast<std::uint8_t>(text[i]); std::uint8_t b = static_cast<std::uint8_t>(text[i]);
if ((b & 0xC0) != 0x80) return 0xFFFD; // missing continuation if ((b & 0xC0) != 0x80) return 0xFFFD;
cp = (cp << 6) | (b & 0x3Fu); cp = (cp << 6) | (b & 0x3Fu);
++i; ++i;
} }
@ -67,8 +61,7 @@ namespace Crafter {
Font(const std::filesystem::path& font); Font(const std::filesystem::path& font);
std::uint32_t GetLineWidth(const std::string_view text, float size); std::uint32_t GetLineWidth(const std::string_view text, float size);
float LineHeight(float size); float LineHeight(float size);
float AscentPx(float size); // baseline offset from line-top float AscentPx(float size);
float ScaleForSize(float size); // stb's pixel-units-per-em factor float ScaleForSize(float size);
}; };
} }
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -19,70 +19,61 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
module; module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM #ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h" #include "vulkan/vulkan.h"
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM #endif
export module Crafter.Graphics:FontAtlas; export module Crafter.Graphics:FontAtlas;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import std; import std;
import :Font; import :Font;
import :GraphicsTypes;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import :ImageVulkan; import :ImageVulkan;
import :Device; import :Device;
#else
import :WebGPU;
#endif
export namespace Crafter { export namespace Crafter {
// Per-glyph metrics. UVs are 0..1 in atlas space; on-screen sizes / // Per-glyph metrics. UVs are 0..1 in atlas space; on-screen sizes /
// offsets / advance are in *atlas pixels at the base size* and scale // offsets / advance are in *atlas pixels at the base size* and scale
// linearly with the requested font size at draw time. // linearly with the requested font size at draw time.
struct Glyph { struct Glyph {
float u0 = 0, v0 = 0; // top-left UV in the atlas float u0 = 0, v0 = 0;
float u1 = 0, v1 = 0; // bottom-right UV in the atlas float u1 = 0, v1 = 0;
float w = 0, h = 0; // glyph quad size in atlas px (= the bitmap size) float w = 0, h = 0;
float xoff = 0, yoff = 0; // glyph bearing relative to baseline cursor float xoff = 0, yoff = 0;
float advance = 0; // horizontal advance at base size, in atlas px float advance = 0;
}; };
// Single-channel SDF atlas. Glyphs are rasterised with stb_truetype's
// GetGlyphSDF at a fixed `kBaseSize` resolution and packed via a simple
// shelf allocator. Drawing scales the glyph quad linearly; the shader
// resolves edge AA via screen-space derivatives, so a single atlas
// serves all sizes and DPI scales without re-bake.
class FontAtlas { class FontAtlas {
public: public:
// Build-time constants. Tweak in one place if needed; values picked
// to give crisp text from ~10pt to ~96pt and leave headroom in the
// SDF distance band so smoothstep is in the linear regime.
static constexpr int kAtlasSize = 1024; static constexpr int kAtlasSize = 1024;
static constexpr float kBaseSize = 32.0f; // pixel-height at which we rasterise static constexpr float kBaseSize = 32.0f;
static constexpr int kPadding = 4; // distance-field padding around each glyph static constexpr int kPadding = 4;
static constexpr int kOnEdgeValue = 128; // 8-bit value mapped to "0 distance" static constexpr int kOnEdgeValue = 128;
static constexpr float kPixelDistScale = 32.0f; // how many distance units per pixel — wider = softer AA range static constexpr float kPixelDistScale = 32.0f;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
ImageVulkan<std::uint8_t> image; ImageVulkan<std::uint8_t> image;
bool dirty = false; // staging has unflushed writes #else
WebGPUTextureRef textureHandle = 0;
std::vector<std::uint8_t> staging;
#endif
bool dirty = false;
// Allocate the GPU image and zero-clear it. Must be called once void Initialize(GraphicsCommandBuffer cmd);
// with a one-shot init command buffer.
void Initialize(VkCommandBuffer cmd); // Returns the row-major byte pointer the CPU writes pixels into.
// Same shape on both backends.
std::uint8_t* PixelPtr() noexcept;
// Rasterise + pack the glyph if it isn't cached yet. Returns
// false only if the atlas is out of space (V2: grow). After a
// successful Ensure the bitmap lives in `image.buffer.value` and
// `dirty` is true; call Update(cmd) before reading on the GPU.
bool Ensure(Font& font, std::uint32_t codepoint); bool Ensure(Font& font, std::uint32_t codepoint);
// Lookup is cheap (hash-table). Returns nullptr if the glyph
// hasn't been Ensured.
const Glyph* Lookup(Font& font, std::uint32_t codepoint) const; const Glyph* Lookup(Font& font, std::uint32_t codepoint) const;
void Update(GraphicsCommandBuffer cmd);
// If `dirty`, flushes staging into the GPU image and transitions
// it back to SHADER_READ_ONLY_OPTIMAL. No-op if not dirty.
void Update(VkCommandBuffer cmd);
private: private:
// Shelf packer state.
struct Shelf { int y = 0; int height = 0; int cursorX = 0; }; struct Shelf { int y = 0; int height = 0; int cursorX = 0; };
std::vector<Shelf> shelves_; std::vector<Shelf> shelves_;
int nextShelfY_ = 0; int nextShelfY_ = 0;
// (font*, codepoint) → Glyph cache.
struct Key { struct Key {
const Font* font; const Font* font;
std::uint32_t cp; std::uint32_t cp;
@ -97,8 +88,6 @@ export namespace Crafter {
}; };
std::unordered_map<Key, Glyph, KeyHash> cache_; std::unordered_map<Key, Glyph, KeyHash> cache_;
// Place a wxh glyph; returns true + writes top-left into outX/outY.
bool ShelfPlace(int w, int h, int& outX, int& outY); bool ShelfPlace(int w, int h, int& outX, int& outY);
}; };
} }
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -0,0 +1,42 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// Backend-portable type aliases. NOT an abstraction layer — these are pure
// `using` declarations that resolve to the backend's native types per the
// active CRAFTER_GRAPHICS_WINDOW_* define.
module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h"
#endif
export module Crafter.Graphics:GraphicsTypes;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import :VulkanBuffer;
import :DescriptorHeapVulkan;
import :ComputeShader;
export namespace Crafter {
using GraphicsCommandBuffer = VkCommandBuffer;
using GraphicsDescriptorHeap = DescriptorHeapVulkan;
using GraphicsComputeShader = ComputeShader;
template<class T, bool Mapped>
using GraphicsBuffer = VulkanBuffer<T, Mapped>;
}
#else
import :WebGPU;
import :WebGPUBuffer;
import :DescriptorHeapWebGPU;
import :WebGPUComputeShader;
export namespace Crafter {
using GraphicsCommandBuffer = WebGPUCommandEncoderRef;
using GraphicsDescriptorHeap = DescriptorHeapWebGPU;
using GraphicsComputeShader = WebGPUComputeShader;
template<class T, bool Mapped>
using GraphicsBuffer = WebGPUBuffer<T, Mapped>;
}
#endif

View file

@ -16,20 +16,15 @@ You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/ */
module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h"
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
export module Crafter.Graphics:RenderPass; export module Crafter.Graphics:RenderPass;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import std; import std;
import :GraphicsTypes;
export namespace Crafter { export namespace Crafter {
struct Window; struct Window;
struct RenderPass { struct RenderPass {
virtual void Record(VkCommandBuffer cmd, std::uint32_t frameIdx, Window& window) = 0; virtual void Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) = 0;
virtual ~RenderPass() = default; virtual ~RenderPass() = default;
}; };
} }
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -19,20 +19,27 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
module; module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM #ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h" #include "vulkan/vulkan.h"
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM #endif
export module Crafter.Graphics:UI; export module Crafter.Graphics:UI;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import std; import std;
import Crafter.Event; import Crafter.Event;
import :Device;
import :Window; import :Window;
import :RenderPass; import :RenderPass;
import :GraphicsTypes;
import :FontAtlas;
import :Font;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import :Device;
import :DescriptorHeapVulkan; import :DescriptorHeapVulkan;
import :ImageVulkan; import :ImageVulkan;
import :VulkanBuffer; import :VulkanBuffer;
import :ComputeShader; import :ComputeShader;
import :FontAtlas; #else
import :Font; import :DescriptorHeapWebGPU;
import :WebGPU;
import :WebGPUBuffer;
import :WebGPUComputeShader;
#endif
export namespace Crafter { export namespace Crafter {
// ─── push-constant header ─────────────────────────────────────────── // ─── push-constant header ───────────────────────────────────────────
@ -56,8 +63,8 @@ export namespace Crafter {
struct QuadItem { struct QuadItem {
float x, y, w, h; float x, y, w, h;
float r, g, b, a; float r, g, b, a;
float cTL, cTR, cBR, cBL; // per-corner radius in px float cTL, cTR, cBR, cBL;
float outline, oR, oG, oB; // outline thickness + RGB float outline, oR, oG, oB;
}; };
static_assert(sizeof(QuadItem) == 64); static_assert(sizeof(QuadItem) == 64);
@ -84,15 +91,11 @@ export namespace Crafter {
static_assert(sizeof(GlyphItem) == 48); static_assert(sizeof(GlyphItem) == 48);
// ─── tiny rect-carving helper ─────────────────────────────────────── // ─── tiny rect-carving helper ───────────────────────────────────────
// Pure value semantics. No engine, just convenience. Skip if you'd rather
// compute pixels yourself.
struct Rect { struct Rect {
float x = 0, y = 0, w = 0, h = 0; float x = 0, y = 0, w = 0, h = 0;
enum class Anchor { Top, Bottom, Left, Right }; enum class Anchor { Top, Bottom, Left, Right };
// Returns a sub-rect of `size` along the given anchor edge of self.
// Does not modify `*this`. (Use `.Inset(...)` to drop a margin first.)
Rect SubRect(float size, Anchor a) const noexcept { Rect SubRect(float size, Anchor a) const noexcept {
switch (a) { switch (a) {
case Anchor::Top: return { x, y, w, std::min(size, h) }; case Anchor::Top: return { x, y, w, std::min(size, h) };
@ -123,121 +126,71 @@ export namespace Crafter {
// ─── per-frame callback args ──────────────────────────────────────── // ─── per-frame callback args ────────────────────────────────────────
struct UIBuildArgs { struct UIBuildArgs {
VkCommandBuffer cmd; GraphicsCommandBuffer cmd;
std::uint32_t frameIdx; std::uint32_t frameIdx;
}; };
// ─── UIRenderer ───────────────────────────────────────────────────── // ─── UIRenderer ─────────────────────────────────────────────────────
// One per Window (typically). Owns the four standard compute shaders,
// pre-allocates heap slots for the swapchain images, and exposes a thin
// dispatch helper for both the standard shaders and user-supplied ones.
//
// Workflow:
// 1. Construct, configure (set fontAtlas if drawing text).
// 2. Initialize(window, heap, initCmd) — once, after window.descriptorHeap
// is set and before window.FinishInit().
// 3. window.passes.push_back(&ui).
// 4. Listen on `onBuild`. Inside the callback, fill your item buffers,
// flush them, and call DispatchQuads / DispatchCircles / DispatchImages
// / DispatchText / Dispatch as needed. Library inserts a SHADER_WRITE
// → SHADER_READ|WRITE memory barrier between consecutive dispatches.
class UIRenderer : public RenderPass { class UIRenderer : public RenderPass {
public: public:
// Pre-loaded standard shaders (public so users can call Dispatch GraphicsComputeShader drawQuads;
// directly with them if they want to embed extra push-constant fields GraphicsComputeShader drawCircles;
// beyond the standard header). GraphicsComputeShader drawImages;
ComputeShader drawQuads; GraphicsComputeShader drawText;
ComputeShader drawCircles;
ComputeShader drawImages;
ComputeShader drawText;
// Optional. If set before Initialize, the atlas is registered into a
// sampled-image slot + linear sampler slot, and Update(cmd) is called
// at the top of every Record() so any glyphs ensured during onBuild
// make it to the GPU before the text dispatch reads them.
FontAtlas* fontAtlas = nullptr; FontAtlas* fontAtlas = nullptr;
// User callback. Subscribe by holding a Crafter::EventListener<UIBuildArgs>:
// EventListener<UIBuildArgs> sub(&ui.onBuild, [&](UIBuildArgs a) { ... });
// Listener lifetime governs the subscription.
Crafter::Event<UIBuildArgs> onBuild; Crafter::Event<UIBuildArgs> onBuild;
UIRenderer() = default; UIRenderer() = default;
UIRenderer(const UIRenderer&) = delete; UIRenderer(const UIRenderer&) = delete;
UIRenderer& operator=(const UIRenderer&) = delete; UIRenderer& operator=(const UIRenderer&) = delete;
// Default shader paths assume Crafter.Build placed the .spv files void Initialize(Window& window, GraphicsDescriptorHeap& heap, GraphicsCommandBuffer initCmd,
// alongside the consumer binary (this is what cfg.shaders does).
void Initialize(Window& window, DescriptorHeapVulkan& heap, VkCommandBuffer initCmd,
std::filesystem::path quadsSpv = "ui-quads.comp.spv", std::filesystem::path quadsSpv = "ui-quads.comp.spv",
std::filesystem::path circlesSpv = "ui-circles.comp.spv", std::filesystem::path circlesSpv = "ui-circles.comp.spv",
std::filesystem::path imagesSpv = "ui-images.comp.spv", std::filesystem::path imagesSpv = "ui-images.comp.spv",
std::filesystem::path textSpv = "ui-text.comp.spv"); std::filesystem::path textSpv = "ui-text.comp.spv");
// RenderPass interface — invoked from Window::Render. void Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) override;
void Record(VkCommandBuffer cmd, std::uint32_t frameIdx, Window& window) override;
// ─── helpers used inside `onBuild` ─────────────────────────────
// Builds a populated header. `clipRectPx` defaults to "no clip".
UIDispatchHeader FillHeader(std::uint32_t itemBufferSlot, UIDispatchHeader FillHeader(std::uint32_t itemBufferSlot,
std::uint32_t itemCount, std::uint32_t itemCount,
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f}, std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f},
std::uint32_t flags = 0) const noexcept; std::uint32_t flags = 0) const noexcept;
// Convenience: dispatches the named standard shader. Group count is void DispatchQuads(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
// computed from the window's surface size — the standard shaders
// dispatch one workgroup per 8×8 screen tile and iterate every item
// in the buffer in order, so item ORDER in the buffer == draw order
// on screen (later items overdraw earlier ones, race-free).
void DispatchQuads(VkCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f}); std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
void DispatchCircles(VkCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount, void DispatchCircles(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f}); std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
void DispatchImages(VkCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount, void DispatchImages(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f}); std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
// For DispatchText, the font atlas image+sampler slots are taken from void DispatchText(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
// UIRenderer's Initialize-time registration (see fontAtlasImageSlot()
// / fontAtlasSamplerSlot()). Set `fontAtlas` before Initialize.
void DispatchText(VkCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f}); std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
// Generic dispatch — for user-authored shaders. Inserts the standard #ifndef CRAFTER_GRAPHICS_WINDOW_DOM
// pre-dispatch barrier (skipped on the first call per frame). // Generic dispatch — user-authored shaders. Vulkan-only in v1; on DOM
void Dispatch(VkCommandBuffer cmd, const ComputeShader& shader, // the WebGPU side has no bindless and would need per-shader bind-group
// declaration. See plan section 3b for the design path.
void Dispatch(GraphicsCommandBuffer cmd, const ComputeShader& shader,
const void* push, std::uint32_t pushBytes, const void* push, std::uint32_t pushBytes,
std::uint32_t gx, std::uint32_t gy = 1, std::uint32_t gz = 1); std::uint32_t gx, std::uint32_t gy = 1, std::uint32_t gz = 1);
#endif
// Allocates a heap slot for the buffer and writes its descriptor into // Allocates a heap slot for the buffer and registers the GPU handle.
// every per-frame heap. The user's mapped buffer is shared across // Returns a move-only BufferSlot RAII handle.
// frames — fine because Window::Render currently waits idle before
// submitting the next frame. Returns a move-only BufferSlot handle
// whose destructor returns the slot to the heap. Implicitly converts
// to the absolute heap index when passed to FillHeader / Dispatch*.
template<typename T, bool Mapped> template<typename T, bool Mapped>
BufferSlot RegisterBuffer(VulkanBuffer<T, Mapped>& buffer); BufferSlot RegisterBuffer(GraphicsBuffer<T, Mapped>& buffer);
// Same for an ImageVulkan-managed sampled image (e.g. a user texture). #ifndef CRAFTER_GRAPHICS_WINDOW_DOM
// Caller specifies the layout the image will be sampled in (typically
// VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL).
template<typename Pixel> template<typename Pixel>
ImageSlot RegisterImage(ImageVulkan<Pixel>& image, VkFormat format, ImageSlot RegisterImage(ImageVulkan<Pixel>& image, VkFormat format,
VkImageLayout layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); VkImageLayout layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
// Allocates a sampler slot and writes a VkSamplerCreateInfo into
// every per-frame sampler heap. v1 takes the create-info inline.
SamplerSlot RegisterSampler(const VkSamplerCreateInfo& info); SamplerSlot RegisterSampler(const VkSamplerCreateInfo& info);
#endif
// Convenience: a linear-filter, clamp-to-edge sampler. Returns a
// SamplerSlot handle. Useful for the FontAtlas and most plain image
// sampling.
SamplerSlot RegisterLinearClampSampler(); SamplerSlot RegisterLinearClampSampler();
// Shapes a UTF-8 string into glyph quads at (x, y) baseline. Calls
// FontAtlas::Ensure for each codepoint (rasterising on first use),
// emits one GlyphItem per visible glyph, returns the count written.
// Use this to fill a GlyphItem buffer that you then dispatch.
// Cursor advances along +X. No line-wrap, no kerning — single line.
std::uint32_t ShapeText(Font& font, float pxSize, std::uint32_t ShapeText(Font& font, float pxSize,
float x, float baselineY, float x, float baselineY,
std::string_view utf8, std::string_view utf8,
@ -245,79 +198,53 @@ export namespace Crafter {
GlyphItem* out, std::uint32_t outCapacity, GlyphItem* out, std::uint32_t outCapacity,
float* outAdvance = nullptr); float* outAdvance = nullptr);
// Read after Initialize: the slot the font atlas was registered into.
// 0xFFFF means "no atlas" (set fontAtlas before Initialize).
std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; } std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; }
std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; } std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; }
// Heap slot whose descriptor in each per-frame heap points at that
// frame's swapchain image. Other passes (e.g. a ray-tracing pass
// that wants to render the world directly into the swapchain) can
// write to the same image by referencing this slot. Order in
// window.passes controls compositing — push such passes BEFORE
// the UI pass so UI overlays render on top.
std::uint16_t OutImageSlot() const noexcept { return outImageSlot_; } std::uint16_t OutImageSlot() const noexcept { return outImageSlot_; }
private: private:
Window* window_ = nullptr; Window* window_ = nullptr;
DescriptorHeapVulkan* heap_ = nullptr; GraphicsDescriptorHeap* heap_ = nullptr;
// One image slot used for the swapchain output. In each per-frame
// heap, that slot points at THAT frame's swapchain image. So the
// shader's `uiImages[hdr.outImage]` is always the current frame's
// swapchain image regardless of which heap is bound.
ImageSlot outImageSlot_; ImageSlot outImageSlot_;
// Stable VkImageViewCreateInfos for the descriptor heap to ingest.
// These must outlive the write call.
VkImageViewCreateInfo atlasViewCreateInfo_{};
ImageSlot fontAtlasImageSlot_; ImageSlot fontAtlasImageSlot_;
SamplerSlot fontAtlasSamplerSlot_; SamplerSlot fontAtlasSamplerSlot_;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
VkImageViewCreateInfo atlasViewCreateInfo_{};
bool firstDispatchThisFrame_ = true; bool firstDispatchThisFrame_ = true;
#endif
// Subscription to window.onResize. Each resize destroys the old
// swapchain images, so the per-frame heap entries we wrote at
// outImageSlot_ now reference dangling VkImage handles. The
// listener re-writes them and flushes the descriptor heaps.
Crafter::EventListener<void> resizeSub_; Crafter::EventListener<void> resizeSub_;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
void WriteSwapchainDescriptors(); void WriteSwapchainDescriptors();
void WriteFontAtlasDescriptor(); void WriteFontAtlasDescriptor();
// Helper used by RegisterBuffer template (defined in impl). Writes the
// address-range descriptor at `slot` into all per-frame heaps.
void WriteBufferDescriptor(std::uint16_t slot, VkDeviceAddress address, std::uint32_t size); void WriteBufferDescriptor(std::uint16_t slot, VkDeviceAddress address, std::uint32_t size);
// Helper used by RegisterImage template — writes a sampled-image at
// `slot` referring to a stable VkImageViewCreateInfo (caller stores).
void WriteSampledImageDescriptor(std::uint16_t slot, void WriteSampledImageDescriptor(std::uint16_t slot,
const VkImageViewCreateInfo& viewInfo, const VkImageViewCreateInfo& viewInfo,
VkImageLayout layout); VkImageLayout layout);
#endif
}; };
// ─── template-method implementations ──────────────────────────────── // ─── template-method implementations ────────────────────────────────
template<typename T, bool Mapped> template<typename T, bool Mapped>
BufferSlot UIRenderer::RegisterBuffer(VulkanBuffer<T, Mapped>& buffer) { BufferSlot UIRenderer::RegisterBuffer(GraphicsBuffer<T, Mapped>& buffer) {
auto range = heap_->AllocateBufferSlots(1); auto range = heap_->AllocateBufferSlots(1);
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
WriteBufferDescriptor(range.firstElement, buffer.address, buffer.size); WriteBufferDescriptor(range.firstElement, buffer.address, buffer.size);
// BufferSlot's operator uint16_t() folds in heap_->bufferStartElement, #else
// so callers receive the absolute heap index when they convert. heap_->bufferTable[range.firstElement] = buffer.handle;
#endif
return BufferSlot{heap_, range.firstElement}; return BufferSlot{heap_, range.firstElement};
} }
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
template<typename Pixel> template<typename Pixel>
ImageSlot UIRenderer::RegisterImage(ImageVulkan<Pixel>& image, VkFormat format, ImageSlot UIRenderer::RegisterImage(ImageVulkan<Pixel>& image, VkFormat format,
VkImageLayout layout) { VkImageLayout layout) {
auto range = heap_->AllocateImageSlots(1); auto range = heap_->AllocateImageSlots(1);
// Build a stable view-create-info that lives as long as the heap reads
// it. We co-locate it on the renderer for the font atlas; for arbitrary
// user images we lean on the fact that vkWriteResourceDescriptorsEXT
// copies the view descriptor immediately. (Validated by the heap spec:
// the descriptor is materialised at write time, the create-info need
// not persist past the call.)
VkImageViewCreateInfo info { VkImageViewCreateInfo info {
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = image.image, .image = image.image,
@ -338,5 +265,5 @@ export namespace Crafter {
WriteSampledImageDescriptor(range.firstElement, info, layout); WriteSampledImageDescriptor(range.firstElement, info, layout);
return ImageSlot{heap_, range.firstElement}; return ImageSlot{heap_, range.firstElement};
} }
#endif
} }
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -16,12 +16,7 @@ You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/ */
module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h"
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
export module Crafter.Graphics:UIComponents; export module Crafter.Graphics:UIComponents;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import std; import std;
import :UI; import :UI;
import :Font; import :Font;
@ -143,4 +138,3 @@ export namespace Crafter {
std::array<float, 4> tint = {1, 1, 1, 1}, std::array<float, 4> tint = {1, 1, 1, 1},
std::array<float, 4> uv = {0, 0, 1, 1}); std::array<float, 4> uv = {0, 0, 1, 1});
} }
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -0,0 +1,75 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// JS bridge declarations for the DOM-mode WebGPU backend. Each function
// corresponds to one entry in `additional/dom-webgpu.js`. Handles are
// opaque uint32 cookies into the JS-side handle tables.
export module Crafter.Graphics:WebGPU;
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
export namespace Crafter {
using WebGPUBufferRef = std::uint32_t;
using WebGPUTextureRef = std::uint32_t;
using WebGPUSamplerRef = std::uint32_t;
using WebGPUCommandEncoderRef = std::uint32_t; // unused as a real handle; just a marker type for portability
}
namespace Crafter::WebGPU {
__attribute__((import_module("env"), import_name("wgpuGetCanvasWidth")))
extern "C" std::int32_t wgpuGetCanvasWidth();
__attribute__((import_module("env"), import_name("wgpuGetCanvasHeight")))
extern "C" std::int32_t wgpuGetCanvasHeight();
__attribute__((import_module("env"), import_name("wgpuSurfaceWidth")))
extern "C" std::int32_t wgpuSurfaceWidth();
__attribute__((import_module("env"), import_name("wgpuSurfaceHeight")))
extern "C" std::int32_t wgpuSurfaceHeight();
__attribute__((import_module("env"), import_name("wgpuInit")))
extern "C" void wgpuInit();
__attribute__((import_module("env"), import_name("wgpuCreateBuffer")))
extern "C" std::uint32_t wgpuCreateBuffer(std::int32_t byteSize);
__attribute__((import_module("env"), import_name("wgpuWriteBuffer")))
extern "C" void wgpuWriteBuffer(std::uint32_t handle, const void* srcPtr, std::int32_t byteSize);
__attribute__((import_module("env"), import_name("wgpuDestroyBuffer")))
extern "C" void wgpuDestroyBuffer(std::uint32_t handle);
__attribute__((import_module("env"), import_name("wgpuCreateAtlasTexture")))
extern "C" std::uint32_t wgpuCreateAtlasTexture(std::int32_t w, std::int32_t h);
__attribute__((import_module("env"), import_name("wgpuWriteAtlasRegion")))
extern "C" void wgpuWriteAtlasRegion(std::uint32_t handle, const void* srcPtr,
std::int32_t srcW, std::int32_t srcH,
std::int32_t srcBytesPerRow,
std::int32_t dstX, std::int32_t dstY,
std::int32_t copyW, std::int32_t copyH);
__attribute__((import_module("env"), import_name("wgpuDestroyTexture")))
extern "C" void wgpuDestroyTexture(std::uint32_t handle);
__attribute__((import_module("env"), import_name("wgpuCreateLinearClampSampler")))
extern "C" std::uint32_t wgpuCreateLinearClampSampler();
__attribute__((import_module("env"), import_name("wgpuFrameBegin")))
extern "C" void wgpuFrameBegin();
__attribute__((import_module("env"), import_name("wgpuFrameEnd")))
extern "C" void wgpuFrameEnd();
__attribute__((import_module("env"), import_name("wgpuDispatchQuads")))
extern "C" void wgpuDispatchQuads(std::uint32_t itemsHandle, const void* headerPtr,
std::int32_t gx, std::int32_t gy);
__attribute__((import_module("env"), import_name("wgpuDispatchCircles")))
extern "C" void wgpuDispatchCircles(std::uint32_t itemsHandle, const void* headerPtr,
std::int32_t gx, std::int32_t gy);
__attribute__((import_module("env"), import_name("wgpuDispatchImages")))
extern "C" void wgpuDispatchImages(std::uint32_t itemsHandle, const void* headerPtr,
std::int32_t gx, std::int32_t gy,
std::uint32_t texHandle, std::uint32_t sampHandle);
__attribute__((import_module("env"), import_name("wgpuDispatchText")))
extern "C" void wgpuDispatchText(std::uint32_t itemsHandle, const void* headerPtr,
std::int32_t gx, std::int32_t gy,
std::uint32_t atlasHandle, std::uint32_t sampHandle);
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -0,0 +1,85 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// WebGPU buffer wrapper — DOM-mode parallel to VulkanBuffer<T, Mapped>.
// Holds a JS-side GPUBuffer handle + (when Mapped) a wasm-memory staging
// array. `.value` points to the staging memory; the user writes into it
// directly, and `.Flush(cmd)` copies to the GPU via queue.writeBuffer.
export module Crafter.Graphics:WebGPUBuffer;
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import :WebGPU;
export namespace Crafter {
class WebGPUBufferBase {
public:
WebGPUBufferRef handle = 0;
std::uint32_t size = 0; // bytes
};
template<typename T>
class WebGPUBufferMapped {
public:
T* value = nullptr;
};
class WebGPUBufferMappedEmpty {};
template<typename T, bool Mapped>
using WebGPUBufferMappedConditional =
std::conditional_t<Mapped, WebGPUBufferMapped<T>, WebGPUBufferMappedEmpty>;
template<typename T, bool Mapped>
class WebGPUBuffer : public WebGPUBufferBase, public WebGPUBufferMappedConditional<T, Mapped> {
public:
WebGPUBuffer() = default;
WebGPUBuffer(const WebGPUBuffer&) = delete;
WebGPUBuffer& operator=(const WebGPUBuffer&) = delete;
WebGPUBuffer(WebGPUBuffer&& other) noexcept {
handle = other.handle;
size = other.size;
other.handle = 0;
if constexpr (Mapped) {
this->value = other.value;
other.value = nullptr;
}
}
void Create(std::uint32_t count) {
size = static_cast<std::uint32_t>(count * sizeof(T));
handle = WebGPU::wgpuCreateBuffer(static_cast<std::int32_t>(size));
if constexpr (Mapped) {
this->value = new T[count]();
}
}
void Clear() {
if (handle != 0) {
WebGPU::wgpuDestroyBuffer(handle);
handle = 0;
}
if constexpr (Mapped) {
if (this->value) { delete[] this->value; this->value = nullptr; }
}
}
void Resize(std::uint32_t count) {
if (handle != 0) Clear();
Create(count);
}
void Flush(WebGPUCommandEncoderRef /*cmd*/) requires(Mapped) {
WebGPU::wgpuWriteBuffer(handle, this->value, static_cast<std::int32_t>(size));
}
void FlushDevice() requires(Mapped) {
WebGPU::wgpuWriteBuffer(handle, this->value, static_cast<std::int32_t>(size));
}
~WebGPUBuffer() { Clear(); }
};
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -0,0 +1,25 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
// Placeholder ComputeShader for DOM mode. The four standard UI pipelines
// are compiled JS-side at startup (see additional/dom-webgpu.js); the C++
// side never sees a WGSL handle. This type exists so UIRenderer can
// declare `WebGPUComputeShader drawQuads;` members for symmetry with the
// Vulkan side, but `Load()` and `Dispatch()` are intentionally absent —
// the DispatchQuads / DispatchCircles / etc convenience methods on
// UIRenderer route directly to the JS bridge.
export module Crafter.Graphics:WebGPUComputeShader;
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
export namespace Crafter {
struct WebGPUComputeShader {
// Marker only; pipelines live JS-side per dispatchStandard in
// dom-webgpu.js. No state required.
};
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -50,6 +50,10 @@ import std;
import :Types; import :Types;
import :Keys; import :Keys;
import Crafter.Event; import Crafter.Event;
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import :WebGPU;
import :DescriptorHeapWebGPU;
#endif
export namespace Crafter { export namespace Crafter {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM #ifndef CRAFTER_GRAPHICS_WINDOW_DOM
@ -61,6 +65,9 @@ export namespace Crafter {
}; };
struct RenderPass; struct RenderPass;
struct DescriptorHeapVulkan; struct DescriptorHeapVulkan;
#else
struct RenderPass;
struct DescriptorHeapWebGPU;
#endif #endif
struct Window { struct Window {
@ -242,12 +249,21 @@ export namespace Crafter {
DescriptorHeapVulkan* descriptorHeap = nullptr; DescriptorHeapVulkan* descriptorHeap = nullptr;
std::optional<std::array<float, 4>> clearColor; std::optional<std::array<float, 4>> clearColor;
#else #else
// DOM mode: the page IS the window. `numFrames` stays as a public // DOM mode: the page IS the window. WebGPU device and canvas are
// constant so cross-platform code can refer to Window::numFrames // owned JS-side (see additional/dom-webgpu.js); this struct just
// without #ifdef'ing the reference; nothing else lives here yet. // holds the per-Window state Crafter::Window users expect:
// V2 (WebGPU compute) will hang its GPUContext / swapchain texture // a list of render passes and a pointer to the descriptor heap.
// members off this branch.
static constexpr std::uint8_t numFrames = 1; static constexpr std::uint8_t numFrames = 1;
std::uint32_t currentBuffer = 0;
std::vector<RenderPass*> passes;
DescriptorHeapWebGPU* descriptorHeap = nullptr;
std::optional<std::array<float, 4>> clearColor;
// DOM-mode StartInit/FinishInit are no-ops returning an opaque
// command-buffer marker so cross-platform user code (HelloUI's
// `auto init = window.StartInit();`) compiles unchanged.
WebGPUCommandEncoderRef StartInit();
void FinishInit();
#endif #endif
}; };
} }

View file

@ -34,6 +34,7 @@ export import :Input;
export import :Device; export import :Device;
export import :Animation; export import :Animation;
export import :ForwardDeclarations; export import :ForwardDeclarations;
export import :GraphicsTypes;
export import :Clipboard; export import :Clipboard;
// Vulkan-backed partitions — empty under DOM. // Vulkan-backed partitions — empty under DOM.
@ -61,3 +62,7 @@ export import :Decompress;
export import :Dom; export import :Dom;
export import :DomEvents; export import :DomEvents;
export import :Router; export import :Router;
export import :WebGPU;
export import :WebGPUBuffer;
export import :DescriptorHeapWebGPU;
export import :WebGPUComputeShader;

View file

@ -131,13 +131,14 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// when its body is gated out. Vulkan-typed partitions stub to empty // when its body is gated out. Vulkan-typed partitions stub to empty
// modules under CRAFTER_GRAPHICS_WINDOW_DOM; the Dom/DomEvents/Router // modules under CRAFTER_GRAPHICS_WINDOW_DOM; the Dom/DomEvents/Router
// partitions stub to empty modules in the opposite direction. // partitions stub to empty modules in the opposite direction.
std::array<fs::path, 32> ifaces = { std::array<fs::path, 37> ifaces = {
"interfaces/Crafter.Graphics", "interfaces/Crafter.Graphics",
"interfaces/Crafter.Graphics-Animation", "interfaces/Crafter.Graphics-Animation",
"interfaces/Crafter.Graphics-Clipboard", "interfaces/Crafter.Graphics-Clipboard",
"interfaces/Crafter.Graphics-ComputeShader", "interfaces/Crafter.Graphics-ComputeShader",
"interfaces/Crafter.Graphics-Decompress", "interfaces/Crafter.Graphics-Decompress",
"interfaces/Crafter.Graphics-DescriptorHeapVulkan", "interfaces/Crafter.Graphics-DescriptorHeapVulkan",
"interfaces/Crafter.Graphics-DescriptorHeapWebGPU",
"interfaces/Crafter.Graphics-Device", "interfaces/Crafter.Graphics-Device",
"interfaces/Crafter.Graphics-Dom", "interfaces/Crafter.Graphics-Dom",
"interfaces/Crafter.Graphics-DomEvents", "interfaces/Crafter.Graphics-DomEvents",
@ -145,6 +146,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Graphics-FontAtlas", "interfaces/Crafter.Graphics-FontAtlas",
"interfaces/Crafter.Graphics-ForwardDeclarations", "interfaces/Crafter.Graphics-ForwardDeclarations",
"interfaces/Crafter.Graphics-Gamepad", "interfaces/Crafter.Graphics-Gamepad",
"interfaces/Crafter.Graphics-GraphicsTypes",
"interfaces/Crafter.Graphics-ImageVulkan", "interfaces/Crafter.Graphics-ImageVulkan",
"interfaces/Crafter.Graphics-Input", "interfaces/Crafter.Graphics-Input",
"interfaces/Crafter.Graphics-InputField", "interfaces/Crafter.Graphics-InputField",
@ -163,29 +165,36 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Graphics-UIComponents", "interfaces/Crafter.Graphics-UIComponents",
"interfaces/Crafter.Graphics-VulkanBuffer", "interfaces/Crafter.Graphics-VulkanBuffer",
"interfaces/Crafter.Graphics-VulkanTransition", "interfaces/Crafter.Graphics-VulkanTransition",
"interfaces/Crafter.Graphics-WebGPU",
"interfaces/Crafter.Graphics-WebGPUBuffer",
"interfaces/Crafter.Graphics-WebGPUComputeShader",
"interfaces/Crafter.Graphics-Window", "interfaces/Crafter.Graphics-Window",
}; };
if (dom) { if (dom) {
// DOM impl set: only the files whose bodies do meaningful work // DOM impl set. UI-Shared.cpp is backend-agnostic; UI-WebGPU.cpp
// under CRAFTER_GRAPHICS_WINDOW_DOM. Vulkan-only impls (Mesh, // is the DOM-only implementation of UIRenderer's GPU-touching
// ComputeShader, Font, etc.) stay out — their interface stubs // methods. Font / FontAtlas / UIComponents are now portable.
// mean no link-side references; including the impl would just std::array<fs::path, 11> domImpls = {
// be dead code with no Vulkan headers to compile against.
std::array<fs::path, 6> domImpls = {
"implementations/Crafter.Graphics-Clipboard", "implementations/Crafter.Graphics-Clipboard",
"implementations/Crafter.Graphics-Dom", "implementations/Crafter.Graphics-Dom",
"implementations/Crafter.Graphics-Font",
"implementations/Crafter.Graphics-FontAtlas",
"implementations/Crafter.Graphics-Gamepad", "implementations/Crafter.Graphics-Gamepad",
"implementations/Crafter.Graphics-Input", "implementations/Crafter.Graphics-Input",
"implementations/Crafter.Graphics-Router", "implementations/Crafter.Graphics-Router",
"implementations/Crafter.Graphics-UI-Shared",
"implementations/Crafter.Graphics-UI-WebGPU",
"implementations/Crafter.Graphics-UIComponents",
"implementations/Crafter.Graphics-Window", "implementations/Crafter.Graphics-Window",
}; };
cfg.GetInterfacesAndImplementations(ifaces, domImpls); cfg.GetInterfacesAndImplementations(ifaces, domImpls);
// JS glue shipped alongside the .wasm so the loader has the // JS glue shipped alongside the .wasm so the loader has the
// env-import surface the Window/Dom bindings expect. // env-import surface the Window/Dom bindings expect.
cfg.files.emplace_back(fs::path("additional/dom-env.js")); cfg.files.emplace_back(fs::path("additional/dom-env.js"));
cfg.files.emplace_back(fs::path("additional/dom-webgpu.js"));
} else { } else {
std::array<fs::path, 13> impls = { std::array<fs::path, 14> impls = {
"implementations/Crafter.Graphics-Clipboard", "implementations/Crafter.Graphics-Clipboard",
"implementations/Crafter.Graphics-ComputeShader", "implementations/Crafter.Graphics-ComputeShader",
"implementations/Crafter.Graphics-Device", "implementations/Crafter.Graphics-Device",
@ -197,6 +206,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"implementations/Crafter.Graphics-Mesh", "implementations/Crafter.Graphics-Mesh",
"implementations/Crafter.Graphics-RenderingElement3D", "implementations/Crafter.Graphics-RenderingElement3D",
"implementations/Crafter.Graphics-UI", "implementations/Crafter.Graphics-UI",
"implementations/Crafter.Graphics-UI-Shared",
"implementations/Crafter.Graphics-UIComponents", "implementations/Crafter.Graphics-UIComponents",
"implementations/Crafter.Graphics-Window", "implementations/Crafter.Graphics-Window",
}; };