#387

`Surface.read()` silently returns an all-zero buffer when called outside the render task, instead of failing validation

Openmaudraniopened 2mo ago

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.js

The 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

  1. Create a surface(gpu, canvas) and drive a continuous render loop (any visible content).
  2. From a separate task — e.g. an awaited page.evaluate in Playwright, or any callback that resolves after the current frame's task — call await surface.read().
  3. Every RGBA byte is 0, while a screenshot of the same canvas shows the rendered content.

Suggestions, in rough order of value

  1. 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.
  2. Document the constraint on Surface.read() / readFloats(): readback is only valid for a texture you rendered to and have not presented — the way Target.read() is.
  3. Optionally, have the color getter reuse the frame's acquired texture within the same frame rather than re-acquiring per access, so a same-task read() is at least consistent.

Workaround (what we adopted)

  • Render to an offscreen Target and read that. An offscreen target never calls getCurrentTexture(), so the whole class does not apply — this is why the vgpu/node path 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

0

No activity yet.