From 55ea9ab5032595305e1f362212a5a7e24c07fe33 Mon Sep 17 00:00:00 2001 From: shadowcodex <1348053+shadowcodex@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:36:28 -0500 Subject: [PATCH] renderer: add present() for a single explicit frame --- packages/brometal/src/runtime/context.ts | 9 ++ packages/brometal/src/runtime/webgpu.ts | 126 ++++++++++++----------- scripts/gpu/entry.ts | 22 ++++ 3 files changed, 96 insertions(+), 61 deletions(-) diff --git a/packages/brometal/src/runtime/context.ts b/packages/brometal/src/runtime/context.ts index 33e1edf..d81d695 100644 --- a/packages/brometal/src/runtime/context.ts +++ b/packages/brometal/src/runtime/context.ts @@ -38,6 +38,15 @@ export interface Renderer { readonly canvas: HTMLCanvasElement; /** Drawing-buffer aspect ratio, for building projection matrices. */ readonly aspect: number; + /** + * Draw exactly one frame, now. + * + * `loop` owns `requestAnimationFrame`, which a host that already has its own + * frame loop cannot give up, and a capture needs one frame on demand rather + * than a stream of them. `present` is `loop`'s body without the scheduling — + * resize, begin the pass, run the callback, submit. + */ + present(callback: () => void): void; loop(callback: (elapsedSeconds: number) => void): () => void; /** * Runs `draw` with every `program.draw()` writing into `target` instead of diff --git a/packages/brometal/src/runtime/webgpu.ts b/packages/brometal/src/runtime/webgpu.ts index fffed19..acf908a 100644 --- a/packages/brometal/src/runtime/webgpu.ts +++ b/packages/brometal/src/runtime/webgpu.ts @@ -185,6 +185,70 @@ export async function createWebgpuRenderer( get aspect(): number { return canvas.width / Math.max(canvas.height, 1); }, + present(callback: () => void): void { + if (needsResize || observer === null) { + needsResize = false; + resizeToDisplaySize(canvas, window.devicePixelRatio || 1); + if (depthTexture === null || depthTexture.width !== canvas.width || depthTexture.height !== canvas.height) { + depthTexture?.destroy(); + depthTexture = device.createTexture({ + size: [canvas.width, canvas.height], + format: 'depth24plus', + sampleCount: internals.sampleCount, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + depthView = depthTexture.createView(); + if (internals.sampleCount > 1) { + msaaTexture?.destroy(); + msaaTexture = device.createTexture({ + size: [canvas.width, canvas.height], + format, + sampleCount: internals.sampleCount, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + msaaView = msaaTexture.createView(); + } + } + } + internals.frame++; + const [r, g, b, a] = internals.clearColor; + const encoder = device.createCommandEncoder(); + // With MSAA the pass renders into the multisampled texture and + // resolves into the swapchain; the samples themselves are discarded. + const swapchainView = context.getCurrentTexture().createView(); + internals.pass = encoder.beginRenderPass({ + colorAttachments: [ + msaaView !== null + ? { + view: msaaView, + resolveTarget: swapchainView, + clearValue: { r, g, b, a }, + loadOp: 'clear', + storeOp: 'discard', + } + : { + view: swapchainView, + clearValue: { r, g, b, a }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + depthStencilAttachment: { + view: depthView!, + depthClearValue: 1, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }, + }); + internals.passFormat = format; + internals.passSamples = internals.sampleCount; + internals.passDepth = true; + callback(); + internals.pass.end(); + internals.pass = null; + device.queue.submit([encoder.finish()]); + }, + loop(callback: (elapsedSeconds: number) => void): () => void { let frameId = 0; let running = true; @@ -192,67 +256,7 @@ export async function createWebgpuRenderer( const frame = (now: number): void => { if (!running) return; - if (needsResize || observer === null) { - needsResize = false; - resizeToDisplaySize(canvas, window.devicePixelRatio || 1); - if (depthTexture === null || depthTexture.width !== canvas.width || depthTexture.height !== canvas.height) { - depthTexture?.destroy(); - depthTexture = device.createTexture({ - size: [canvas.width, canvas.height], - format: 'depth24plus', - sampleCount: internals.sampleCount, - usage: GPUTextureUsage.RENDER_ATTACHMENT, - }); - depthView = depthTexture.createView(); - if (internals.sampleCount > 1) { - msaaTexture?.destroy(); - msaaTexture = device.createTexture({ - size: [canvas.width, canvas.height], - format, - sampleCount: internals.sampleCount, - usage: GPUTextureUsage.RENDER_ATTACHMENT, - }); - msaaView = msaaTexture.createView(); - } - } - } - internals.frame++; - const [r, g, b, a] = internals.clearColor; - const encoder = device.createCommandEncoder(); - // With MSAA the pass renders into the multisampled texture and - // resolves into the swapchain; the samples themselves are discarded. - const swapchainView = context.getCurrentTexture().createView(); - internals.pass = encoder.beginRenderPass({ - colorAttachments: [ - msaaView !== null - ? { - view: msaaView, - resolveTarget: swapchainView, - clearValue: { r, g, b, a }, - loadOp: 'clear', - storeOp: 'discard', - } - : { - view: swapchainView, - clearValue: { r, g, b, a }, - loadOp: 'clear', - storeOp: 'store', - }, - ], - depthStencilAttachment: { - view: depthView!, - depthClearValue: 1, - depthLoadOp: 'clear', - depthStoreOp: 'store', - }, - }); - internals.passFormat = format; - internals.passSamples = internals.sampleCount; - internals.passDepth = true; - callback((now - startedAt) / 1000); - internals.pass.end(); - internals.pass = null; - device.queue.submit([encoder.finish()]); + renderer.present(() => callback((now - startedAt) / 1000)); frameId = requestAnimationFrame(frame); }; frameId = requestAnimationFrame(frame); diff --git a/scripts/gpu/entry.ts b/scripts/gpu/entry.ts index b684e25..11fd672 100644 --- a/scripts/gpu/entry.ts +++ b/scripts/gpu/entry.ts @@ -246,6 +246,28 @@ async function run(): Promise { }); } + // present(): one frame, drawn synchronously, with no requestAnimationFrame in + // sight. loop() is built on it, so this also proves the extraction kept the + // pass setup intact — a present that skipped the resize or the submit would + // leave the canvas empty here. + { + const presentProgram = createProgram(renderer, targetReadShader); + presentProgram.attributes.aPosition.set(quad.positions); + presentProgram.attributes.aUv.set(quad.uvs); + presentProgram.setIndices(quad.indices); + presentProgram.uniforms.uTarget.set(target.texture); + + const before = samplePixel(canvas, 128, 32); + renderer.present(() => presentProgram.draw()); + const after = samplePixel(canvas, 128, 32); + + checks.push({ + name: 'present() draws a frame without a loop', + passed: after.some((channel, index) => channel !== before[index]) || after[2]! > 0, + detail: `canvas ${before.slice(0, 3).join(',')} -> ${after.slice(0, 3).join(',')}`, + }); + } + window.__GPU_RESULTS__ = { backend: renderer.backend, mode: 'webgpu', checks }; }