Summary
Surface extends Target, so read() is part of its public type and the docs give no caveat. But a
read() issued from outside the task that rendered returns a plausibly-shaped, all-zero
Uint8Array — no error, no validation failure. On a canvas that is visibly and continuously
rendering, read() reports black.
The zeros are not a readback bug: the copy path is a correct copyTextureToBuffer + mapAsync, and
the canvas context is configured with COPY_SRC. The problem is which texture gets read.
Tested with vgpu@0.3.1, Chromium + SwiftShader (Playwright), macOS.
Mechanism (traced through dist/)
CanvasSurface.read() dist/surface.js → return this.color.read()
get color() dist/surface.js → new Texture(device, this.context.getCurrentTexture(), …)
Texture.read() @vgpu/core/dist/texture.js
device.readback.readTexture() @vgpu/core/dist/readback.jsThe color getter calls context.getCurrentTexture() fresh on every access — it does not hold a
reference to the texture the previous render pass actually drew into. Per the WebGPU spec, a
current-texture reference is invalidated once its owning task ends, and each getCurrentTexture()
call in a new task yields a different swapchain image. So a read() from a later task (an async
page.evaluate round-trip, an awaited callback, anything not inside the render task) reads a
fresh, unrendered image — which is zeros.
canvasTextureUsage() correctly includes RENDER_ATTACHMENT | TEXTURE_BINDING | COPY_SRC, so this
is not a usage-flag mismatch.
Why this is worth a guard rather than only a doc note
The failure is silent and indistinguishable from a legitimately black frame. That is the
expensive shape: code that reads zeros cannot tell "nothing was drawn" from "you read the wrong
texture", so a test asserting on pixels goes green-on-black or red-for-the-wrong-reason, and the
debugging cost lands far from the cause. A validation error — or even a dev-mode warning when
read() is called on a Surface outside an active frame — would have turned hours into seconds.
Concretely, what made it findable at all was an out-of-band control: a page.screenshot() of the
same canvas at the same moment showed the content plainly, which is the only reason the zeros were
recognised as an instrument failure rather than a rendering failure.
Repro shape
- Create a
surface(gpu, canvas)and drive a continuous render loop (any visible content). - From a separate task — e.g. an
awaitedpage.evaluatein Playwright, or any callback that resolves after the current frame's task — callawait surface.read(). - Every RGBA byte is
0, while a screenshot of the same canvas shows the rendered content.
Suggestions, in rough order of value
- Fail loudly. Detect
Surface.read()outside an active frame/render task and throw a typed error naming the cause, or at minimum warn in dev builds. - Document the constraint on
Surface.read()/readFloats(): readback is only valid for a texture you rendered to and have not presented — the wayTarget.read()is. - Optionally, have the
colorgetter reuse the frame's acquired texture within the same frame rather than re-acquiring per access, so a same-taskread()is at least consistent.
Workaround (what we adopted)
- Render to an offscreen
Targetand read that. An offscreen target never callsgetCurrentTexture(), so the whole class does not apply — this is why thevgpu/nodepath works reliably; we use it for headless verification and it has been solid. - For live-canvas verification, capture the canvas out-of-band (screenshot) instead of
read().
Neither is a complaint about the library's design — the offscreen path is correct and pleasant to use. It is the silence that costs, not the constraint.
Happy to test a patch or provide a minimal Playwright repro if useful.
Activity
0No activity yet.