From 06b82af5aab6c176666024f93f18e4f2242f3246 Mon Sep 17 00:00:00 2001 From: shadowcodex <1348053+shadowcodex@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:42:02 -0500 Subject: [PATCH] webgpu: correct two defects in per-frame attribute buffer handling --- packages/brometal/src/runtime/webgpu.ts | 106 +++++++++++++++---- scripts/gpu/entry.ts | 38 +++++++ scripts/gpu/fixtures/gpu-batch.shader.gen.ts | 33 ++++++ scripts/gpu/fixtures/gpu-batch.shader.ts | 20 ++++ 4 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 scripts/gpu/fixtures/gpu-batch.shader.gen.ts create mode 100644 scripts/gpu/fixtures/gpu-batch.shader.ts diff --git a/packages/brometal/src/runtime/webgpu.ts b/packages/brometal/src/runtime/webgpu.ts index fffed19..c121c27 100644 --- a/packages/brometal/src/runtime/webgpu.ts +++ b/packages/brometal/src/runtime/webgpu.ts @@ -337,6 +337,14 @@ interface GpuAttributeState { buffer: GPUBuffer; capacity: number; elementCount: number; + /** + * Byte offset the most recent upload was written at, and the frame it + * happened in. A second upload within one frame appends rather than + * overwriting — see `uploadAttribute`. + */ + offset: number; + writtenThisFrame: number; + frame: number; } interface GpuTextureBinding { @@ -594,6 +602,20 @@ export function createWebgpuProgram { if (data.length % entry.size !== 0) { throw new Error( @@ -602,20 +624,50 @@ export function createWebgpuProgram { const states = entry.divisor === 1 ? instanceStates : vertexStates; - pass.setVertexBuffer(slot, states.get(entry.name)!.buffer); + const state = states.get(entry.name)!; + // Bind at the offset holding this draw's data, not at 0. + pass.setVertexBuffer(slot, state.buffer, state.offset); }); if (indexBuffer !== null) { pass.setIndexBuffer(indexBuffer, indexFormat); @@ -785,6 +847,10 @@ export function createWebgpuProgram { }); } + // Two batches through one program in one frame. queue.writeBuffer is ordered + // against the frame's single submit, not against the draws inside it, so + // without per-draw offsets both draws read whatever was written last and the + // left half comes out the colour of the right. Growing the buffer between the + // two uploads also retires the first one mid-frame, which is the second + // defect: destroying it immediately fails the whole submit. + { + const batch = createProgram(renderer, batchShader); + await new Promise((resolve) => { + const stop = renderer.loop(() => { + // Left half, red. Two vertices' worth of tint. + batch.attributes.aPosition.set(new Float32Array([-1, -1, 0, 0, -1, 0, -1, 1, 0])); + batch.attributes.aTint.set(new Float32Array([1, 0, 0, 1, 0, 0, 1, 0, 0])); + batch.draw(); + // Right half, blue. A second upload in the same frame, to the same + // attributes, and larger so the buffer grows and the first is retired. + batch.attributes.aPosition.set( + new Float32Array([0, -1, 0, 1, -1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 0, 1, -1, 0]), + ); + batch.attributes.aTint.set( + new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), + ); + batch.draw(); + stop(); + resolve(); + }); + }); + + const [leftRed, , leftBlue] = samplePixel(canvas, 40, 32); + const [rightRed, , rightBlue] = samplePixel(canvas, 216, 32); + checks.push({ + name: 'two batches in one frame keep their own attribute data', + passed: leftRed! > 150 && leftBlue! < 100 && rightBlue! > 150 && rightRed! < 100, + detail: `left rgb(${leftRed},_,${leftBlue}) expected red, right rgb(${rightRed},_,${rightBlue}) expected blue`, + }); + } + window.__GPU_RESULTS__ = { backend: renderer.backend, mode: 'webgpu', checks }; } diff --git a/scripts/gpu/fixtures/gpu-batch.shader.gen.ts b/scripts/gpu/fixtures/gpu-batch.shader.gen.ts new file mode 100644 index 0000000..8c63c7e --- /dev/null +++ b/scripts/gpu/fixtures/gpu-batch.shader.gen.ts @@ -0,0 +1,33 @@ +/* Generated by BroMetal. Do not edit — recompile with `npx brometal dev`. */ +import type { CompiledShader } from 'brometal'; + +const GpuBatch: CompiledShader<{ aPosition: 'vec3'; aTint: 'vec3' }, Record, Record> = { + wgslSrc: `struct BmVSIn { + @location(0) aPosition : vec3f, + @location(1) aTint : vec3f, +} +struct BmVSOut { + @builtin(position) bm_position : vec4f, + @location(0) vTint : vec3f, +} +@vertex +fn vs_main(bm_in : BmVSIn) -> BmVSOut { + var bm_out : BmVSOut; + bm_out.vTint = bm_in.aTint; + bm_out.bm_position = vec4f(bm_in.aPosition.x, bm_in.aPosition.y, 0.0, 1.0); + bm_out.bm_position.z = (bm_out.bm_position.z + bm_out.bm_position.w) * 0.5; + return bm_out; +} +@fragment +fn fs_main(bm_in : BmVSOut) -> @location(0) vec4f { + return vec4f(bm_in.vTint, 1.0); +} +`, + attributes: { aPosition: 'vec3', aTint: 'vec3' }, + instanceAttributes: {}, + uniforms: {}, + layout: {"attributes":[{"name":"aPosition","type":"vec3","location":0,"size":3,"divisor":0},{"name":"aTint","type":"vec3","location":1,"size":3,"divisor":0}],"uniforms":[],"uniformBlockSize":0}, + +}; + +export default GpuBatch; diff --git a/scripts/gpu/fixtures/gpu-batch.shader.ts b/scripts/gpu/fixtures/gpu-batch.shader.ts new file mode 100644 index 0000000..1e5111d --- /dev/null +++ b/scripts/gpu/fixtures/gpu-batch.shader.ts @@ -0,0 +1,20 @@ +import { shader, vec4 } from 'brometal'; + +/** + * Fixture: draws a solid colour taken from a per-vertex attribute, so two + * uploads to the same attribute in one frame produce two visibly different + * draws — unless the second upload overwrote the first. + */ +export const GpuBatch = shader({ + attributes: { aPosition: 'vec3', aTint: 'vec3' }, + varyings: { vTint: 'vec3' }, + + vertex({ aPosition, aTint }, _uniforms, v) { + v.vTint = aTint; + return vec4(aPosition.x, aPosition.y, 0, 1); + }, + + fragment(_uniforms, { vTint }) { + return vec4(vTint, 1); + }, +});