Graphics/Canvas: fix bgfx view exhaustion (shared nanovg view + guarded mid-frame flush) - #1805
Conversation
There was a problem hiding this comment.
🟡 Not ready to approve
The mid-frame view-id reset can break existing view-ordering guarantees used for the canvas→texture blit path, potentially reintroducing one-frame latency/incorrect sampling.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR addresses bgfx view-id exhaustion triggered by Canvas/nanovg rendering by (1) reusing a shared bgfx view for non-filtered draws and (2) introducing a mid-frame “view flush” mechanism that advances a bgfx frame and resets the view counter so rendering can continue without hitting the hard max-views cap.
Changes:
- Add filter-stack introspection (
HasFilters) and use it in nanovg to reuse a single canvas view for consecutive non-filtered draws. - Add
DeviceImpl::FlushViewsIfNeeded()plus a cross-thread handshake to perform a mid-frame bgfxframe()and reset the view-id counter. - Re-enable a previously excluded validation test in
Apps/Playground/Scripts/config.json.
File summaries
| File | Description |
|---|---|
| Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h | Adds HasFilters() to detect whether intermediate filtered rendering is required. |
| Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp | Reuses the canvas framebuffer view for non-filtered draws; refreshes view only when needed for ordering. |
| Plugins/NativeEngine/Source/NativeEngine.cpp | Calls DeviceContext::FlushViewsIfNeeded() at encoder acquisition boundaries. |
| Core/Graphics/Source/DeviceImpl.h | Declares mid-frame view flush API and synchronization fields. |
| Core/Graphics/Source/DeviceImpl.cpp | Implements the mid-frame flush handshake and performs bgfx::frame() + view-id reset. |
| Core/Graphics/Source/DeviceContext.cpp | Plumbs FlushViewsIfNeeded() to DeviceImpl. |
| Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h | Exposes FlushViewsIfNeeded() in the public DeviceContext contract. |
| Apps/Playground/Scripts/config.json | Removes an automatic-test exclusion for “Vertex Pulling - Normals UVs Colors Tangents”. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
CI failures: root-caused and fixedThe 9 CI failures were pre-existing on this branch (they also occur on Why it passed locally but aborted in CINot a data difference. I instrumented The difference is only whether bgfx's asserts are compiled in. bgfx::allocTransientVertexBuffer(&gl->tvb, gl->nverts, s_nvgLayout);
int allocated = gl->tvb.size/gl->tvb.stride;
if (allocated < gl->nverts) { gl->nverts = allocated; ... } // unreachable if bgfx assertsbgfx asserts inside Fix
const uint32_t availVerts = bgfx::getAvailTransientVertexBuffer(uint32_t(gl->nverts), s_nvgLayout);
if (availVerts < uint32_t(gl->nverts)) { BX_WARN(...); gl->nverts = int(availVerts); }
if (gl->nverts > 0) { /* alloc, copy, draw */ }The Both configurations now behave identically. Local suite: ran=301 passed=301 failed=0. Note for reviewersThis makes the abort impossible, but the underlying oddity stands: a canvas asking for 3.4M vertices (~54 MB) in one flush is a lot, and it only fits because the dropped geometry happens to be near-invisible. I deliberately did not raise |
nanovg_babylon acquired a fresh bgfx view for every draw call. bgfx has a hard cap on view ids, so a scene with many canvas draws exhausted them and failed with "Error: Too many views". Only filtered draws actually need their own view (the filter stack renders into it). Non-filtered draws can share one, so acquire lazily and reuse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Sharing views in nanovg raises the ceiling but does not remove it: a scene can still exhaust bgfx view ids within a single frame. Add a mid-frame flush so views can be recycled without ending the frame. The handshake parks the requesting JS thread and relies on the render thread being parked in FinishRenderingCurrentFrame to service the flush. That only holds while a FrameCompletionScope is active, i.e. the normal requestAnimationFrame render path. Snippets that drive frames manually (setInterval + engine.beginFrame/scene.render/engine.endFrame) acquire no scope, and would deadlock the handshake. FlushViewsIfNeeded therefore skips the flush when m_pendingFrameScopes == 0; the AcquireNewViewId cap remains as the backstop, so those snippets behave exactly as they do today. The guard is folded into this commit rather than added as a follow-up so no commit in history contains the deadlocking version. Full Win32 D3D11 suite: 301/301. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
PerformMidFrameViewFlush resets m_nextViewId to 0 in the middle of a logical
frame. Any view id cached across draw calls then sorts *after* ids handed out
from the reset counter, inverting bgfx submission order relative to JS draw
order. Two holders cache view ids:
- FrameBuffer::m_viewId, retained by SetBgfxViewPortAndScissor and only
cleared by Bind() at frame start.
- The Canvas blit reservation (Context::Flush -> Texture::BlitViewId),
consumed by NativeEngine::CopyTexture, which relies on the reserved id
ordering before the layer that samples the destination.
Add a view-id generation counter, bumped on every mid-frame flush. Both
holders now cache the generation alongside the id and re-acquire (FrameBuffer)
or fall back to PeekNextViewId (CopyTexture) when it no longer matches. After
a flush the canvas draws have already been submitted in a previous bgfx frame,
so the PeekNextViewId fallback is both safe and correctly ordered.
Verified by temporarily forcing a flush every 8 views and running the suite:
417 flushes / 318 stale ids re-acquired, 301/301 pass. With the generation
check neutralized under the same forcing, 'Native Canvas' fails at 21.9%
pixel divergence - the exact GUI-latency regression this prevents.
bgfx asserts inside allocTransientVertexBuffer when the request exceeds what is left of the frame's transient buffer, so the request has to be checked with getAvailTransientVertexBuffer *before* allocating. The guard that used to live here inspected gl->tvb after the call, which an assert-enabled build never reaches. A canvas painted with a very large number of ops, or several canvases sharing a frame, can legitimately exceed the budget. Truncating is not an option: the draw calls index the buffer with offsets recorded while the geometry was built, so every draw past the truncation point reads outside the allocation. Besides being visually wrong, that makes the D3D11 debug layer emit an oversized diagnostic per draw call - enough log volume to stall a validation run for the better part of an hour. Skip the whole flush instead. The frame is then missing that canvas content, but no out-of-bounds draw is issued and the process does not abort. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
db65364 to
4f86785
Compare
Root cause of the CI failures: a D3D11 debug-layer log flood, not a test failureThe six Win32 D3D11 jobs were reported as failing, but they were The job log for The flood starts at line 42,249 and never stops. The last real log line before it is: That test ( Why my previous commit caused itThe earlier fix clamped the nanovg vertex count to That explains the platform split exactly:
So the truncation didn't just render the wrong thing — it generated enough log volume to consume the entire job budget. The fixA partial allocation cannot be rendered correctly, so this now skips the whole flush instead of truncating it. The frame is missing that canvas content, but no out-of-bounds draw is issued and the process doesn't abort — which is still strictly better than the original behaviour (a bgfx assert/abort in an assert-enabled build). I also restored the Worth recording: idx 665 does pass locally under the exact CI configuration that was hanging (Win32 D3D11, VerificationRebased onto current Built
( Note for the workflow, independent of this PRA single misbehaving test can currently burn a full 60-minute job and produce a multi-gigabyte log. bgfx's D3D11 debug-message pump has no rate limit, so any out-of-bounds draw in a hot loop reproduces this. Capping repeated identical debug messages would turn a 60-minute timeout into a fast, readable failure — happy to open a separate issue if that's useful. |
Second half of the Canvas/bgfx work whose first half landed as #1800. Independent of it; this one is about bgfx view exhaustion, not pixel readback.
The problem
nanovg_babylonacquired a fresh bgfx view for every draw call. bgfx caps view ids, so a scene with enough canvas draws runs out and dies withError: Too many views.The changes
1. Share a view across non-filtered draws. Only filtered draws need a private view (the filter stack renders into it). Non-filtered draws now acquire lazily and reuse one.
2. Add a mid-frame view flush. Sharing raises the ceiling but does not remove it, so views can now be recycled mid-frame.
The deadlock this could have introduced
The flush handshake parks the requesting JS thread and relies on the render thread being parked in
FinishRenderingCurrentFrameto service it. That only holds while aFrameCompletionScopeis active — the normalrequestAnimationFramepath.Snippets that drive frames manually (
setInterval+engine.beginFrame/scene.render/engine.endFrame) acquire no scope, so the handshake would never be serviced and the snippet would hang.FlushViewsIfNeededskips the flush whenm_pendingFrameScopes == 0; theAcquireNewViewIdcap remains the backstop, so those snippets behave exactly as they do today.The guard is folded into commit 2 rather than added as a follow-up, so no commit in this branch's history contains the deadlocking version.
Evidence
Re-enables "Vertex Pulling - Normals UVs Colors Tangents" (idx 665), excluded as "Test crashes or hangs on Babylon Native".
This is the only
config.jsonchange in the PR — a single hunk dropping that one test'sexcludeFromAutomaticTesting/reasonpair. The un-exclusion is intentional and is the evidence for the fix.Negative control: with the nanovg commit reverted, that test fails with
Error: Too many views. With it applied, it passes (2.224% diff, allowed 2.5%).Full Win32 D3D11 validation suite: 301/301, 0 failures.
On the other five un-exclusions
The shotgun branch this comes from also un-excluded idx 185, 253, 653, 654 and 659. I tested each individually and they are not earned by this PR, so they are not included:
Error: Failed to create frame buffer— needs the MRT workThey passed on the shotgun branch only because other changes were present alongside. They will come back with the PR that actually earns them.
Worth noting for anyone reading suite output: the runner aborts on the first failure, so an un-exclusion early in the config can silently mask everything after it. Un-excluding all six produced a green-looking
ran=127 passed=126 failed=1that had simply stopped at idx 185 and never reached the other five.Update: mid-frame flush invalidates cached view ids (d298801)
Review raised that
PerformMidFrameViewFlushresetsm_nextViewIdwhile a logical frame is in flight, which strands view ids cached across draw calls. That was correct, and broader than the canvas blit alone —FrameBuffer::m_viewIdis retained bySetBgfxViewPortAndScissorand only cleared byBind()at frame start.Added a view-id generation counter bumped on each flush.
FrameBufferre-acquires on mismatch;CopyTexturefalls back toPeekNextViewId()(safe, because a flush has already submitted the canvas draws in a prior bgfx frame).The flush never fires in the suite as shipped (256 views, margin 16 — instrumented count was 0), so it was verified by temporarily forcing a flush every 8 views:
Native Canvasfails at 21.9% pixel divergence