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);
+ },
+});