Skip to content

render: guard per-frame triangle-vertex budget (prevent sokol-gl overflow blanking the window)#65

Merged
dy-tea merged 5 commits into
vlang:mainfrom
MartenH:fix-vertex-budget-guard
Jun 23, 2026
Merged

render: guard per-frame triangle-vertex budget (prevent sokol-gl overflow blanking the window)#65
dy-tea merged 5 commits into
vlang:mainfrom
MartenH:fix-vertex-budget-guard

Conversation

@MartenH

@MartenH MartenH commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Problem

gg sets up sokol-gl with the default descriptor, so all triangles drawn in a frame share one fixed-capacity vertex buffer (sokol's 64k-vertex default). When a frame's triangle geometry exceeds that buffer, sokol-gl silently drops the entire frame's geometry — the window goes blank — with no error surfaced to the application.

This is easy to hit from draw_canvas: a plot/polyline fed far more points than the canvas has pixels (e.g. a wide time window over a long history) tessellates to enough triangles, across a handful of DrawSvg batches, to blow past 64k cumulatively. The whole window blanks, every frame, until the geometry shrinks — no panic, process alive, nothing logged.

Fix

Extend the existing render guard with a cumulative per-frame triangle-vertex budget:

  • emit_renderer_if_valid() tracks the vertices emitted by DrawSvg batches (triangles are x,y pairs → vertices = triangles.len / 2) in a new Window.frame_triangle_vertices, and skips a batch that would push the frame past the budget — with a one-time warning — instead of letting it overflow the buffer and blank everything.
  • The counter resets each frame in Window.update() (next to array_clear(window.renderers)).
  • Triangle geometry is the only unbounded contributor; text/rects/images are bounded by widget count, so the budget reserves ~16k of the 64k buffer for that chrome (max_frame_triangle_vertices = 49152).
  • Factored the warn-once logic into render_guard_warn_once() (reused by the existing validity guard, no behavior change there).

The net effect: an oversized canvas degrades gracefully (drops the excess geometry + warns once) instead of blanking the whole UI.

Tests

_render_test.v drives the guard directly:

  • test_triangle_vertex_budget_skips_single_oversized_batch — one batch larger than the whole buffer is skipped and warned.
  • test_triangle_vertex_budget_is_cumulative_per_frame — medium batches accumulate and are accepted until the cumulative count would overflow (the exact pattern that blanked the window); a non-triangle renderer is unaffected.

Verified both tests fail when the budget is removed (i.e. they genuinely exercise the guard), and pass with it.

Notes / scope

  • A fully precise guard would also count the (bounded) vertices from other renderer kinds; this PR targets the unbounded triangle source that causes the blank in practice. Happy to broaden if preferred.
  • A complementary improvement: check sgl.error() after draw to at least surface SGL_ERROR_VERTICES_FULL rather than failing silently. Left out to keep this focused.
  • max_frame_triangle_vertices could be made configurable (e.g. via WindowCfg) if the 64k assumption should be overridable alongside a custom sgl.Desc.

@MartenH

MartenH commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Good catch on the clipped path — fixed in the latest commit.

Stencil-clipped groups draw the mask geometry twice (draw_clipped_svg_group step 1 = stencil write, step 3 = stencil clear) plus the content once, so a ~24k mask + ~24k content group passed the 49,152 cap while actually emitting ~72k. emit_renderer_if_valid now budgets is_clip_mask DrawSvg vertices at , matching the real SGL emissions.

Added two regression tests on that path:

  • test_triangle_vertex_budget_counts_clip_mask_twice — a mask that fits once (v ≤ budget) but not doubled (2v > budget) is skipped (the clipped-path blank).
  • test_clip_mask_consumes_double_budget_when_it_fits — a fitting mask consumes the budget.

Verified both fail with the 2× accounting removed, and pass with it; full render suite green.

Note on scope: content geometry is still budgeted once (it is drawn once), and non-mask renderers unchanged. A fully precise alternative would budget at actual draw-emission time, but counting clip masks 2× at queue time matches the only double-emitted path and keeps the guard at the existing choke point.

@MartenH

MartenH commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the unclipped-degradation in the latest commit — clip groups are now all-or-nothing, order-independently:

  • emit (emit_renderer_if_valid): when any DrawSvg of a clip_group exceeds the budget, the group is poisoned (Window.frame_poisoned_clip_groups), and every later renderer of a poisoned group is skipped without counting. Covers the mask-before-content order.
  • draw (draw_clipped_svg_group): if the group is poisoned, the whole group is dropped before any stencil/content draw — so content that was queued before the over-budget mask isn't drawn unclipped. Covers the content-before-mask order.

frame_poisoned_clip_groups resets per frame alongside frame_triangle_vertices. So instead of "oversized mask → content renders unclipped," the clipped group simply doesn't render that frame (with the one-time warning), which is the safe failure.

Regression test test_over_budget_clip_mask_poisons_whole_group: content is queued first, then an over-budget mask for the same group poisons it (the flag the draw path keys off) and subsequent group geometry is skipped. Verified it fails without the poison logic; full render suite green.

I went with poisoning over moving all budgeting into the draw path because the running counter is accumulated at emit time; the poison set is the minimal bridge that makes the clipped path atomic without a second budget mechanism.

@MartenH

MartenH commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

Reworked the guard from emit-time to draw-pass budgeting — this is a cleaner design that fixes a real bug the emit-time approach had and removes the special-cases it required.

Why rework: emit-time budgeting counted queued geometry, not actual sokol-gl emissions. That forced the 2× clip-mask heuristic and a global poison map keyed by clip_group — but clip_group ids are per-SVG-local (svg/tessellate.v resets the counter each tessellate()), so the global map collided across widgets: one over-budget clipped SVG would wrongly drop every other clipped SVG that shares the same local id. It also over-counted filtered SVG content that's actually rendered in a separate offscreen pass, and left poisoned-but-counted content inflating the budget.

Now: frame_triangle_vertices resets at the top of renderers_draw() (so the print_raster path that reuses renderers_draw is covered with no separate reset to forget), and admit_triangle_vertices() is called at the real emission sites:

  • draw_svg_batch — per-renderer (flat non-clipped SVG)
  • draw_triangles_gradient — per-call (per-vertex-colored SVG)
  • draw_clipped_svg_groupatomically for the whole consecutive run via group_triangle_vertices() (mask counted twice for stencil write+clear, content once). Per-run, so repeated clip_group ids across different SVGs never interfere, and the group is all-or-nothing (no content-without-mask = unclipped).

This removes Window.frame_poisoned_clip_groups, the emit-time DrawSvg block, and the 2× heuristic — budgeting now matches real emissions, and filtered offscreen content is no longer charged to the swapchain budget.

Scope (unchanged): only the unbounded contributor (DrawSvg triangle geometry) is metered; bounded vector chrome is covered by the fixed 16k reserve.

Tests retargeted at the pure decision helpers (admit_triangle_vertices, group_triangle_vertices): accumulate-and-cap, single-oversized, mask-counted-twice, content-only, and atomic over-budget clip-group rejection. Verified each fails when the cap or the 2× is removed. (The GL-touching draw functions can't run headless; the budget decision they call is fully covered.)

@GGRei

GGRei commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

@MartenH You need to rebase/sync your branch with the current vlang/gui main branch, because your PR is still using the old Windows CI workflow with V_VERSION: 0.5.1 Thanks!

MartenH and others added 5 commits June 22, 2026 19:30
…flow blanking the window)

gg sets up sokol-gl with the default descriptor, so all triangles drawn in a frame
share ONE fixed-capacity vertex buffer (sokol's 64k-vertex default). When a frame's
triangle geometry exceeds that buffer, sokol-gl silently drops the whole frame's
geometry — the window goes blank — with no error surfaced to the application. It's
easy to hit from draw_canvas: a plot/polyline fed far more points than the canvas has
pixels (e.g. a wide time window over a long history) tessellates to enough triangles,
across a handful of DrawSvg batches, to blow past 64k cumulatively.

This extends the existing render guard with a cumulative per-frame triangle-vertex
budget. emit_renderer_if_valid() now tracks the vertices emitted by DrawSvg batches
(triangles are x,y pairs, so vertices = triangles.len/2) and skips a batch that would
push the frame past the budget — with a one-time warning — instead of letting it
overflow the buffer and blank everything. The counter resets each frame in
Window.update(). Triangle geometry is the only unbounded contributor; text/rects/
images are bounded by widget count, so the budget reserves ~16k of the 64k buffer for
that chrome and caps the unbounded triangle stream at the rest.

Tests (in _render_test.v) drive the guard directly: a single oversized batch is
skipped, and a sequence of medium batches is accepted until the cumulative count would
overflow (the exact pattern that previously blanked the window), while a non-triangle
renderer is unaffected. Verified the tests fail when the budget is removed.

Note: a fully precise guard would also count the (bounded) vertices from other
renderer kinds; this targets the unbounded triangle source that causes the blank in
practice. A complementary improvement would be checking sgl.error() after draw to at
least surface SGL_ERROR_VERTICES_FULL rather than failing silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ear)

Addresses Codex P1: a stencil-clipped SVG group draws its mask geometry TWICE per
frame — once to write the stencil (draw_clipped_svg_group step 1) and once to clear
it (step 3) — plus the content once. The guard counted each DrawSvg once, so a group
with ~24k mask + ~24k content vertices passed the 49,152 cap but actually emitted
~72k, still overflowing the SGL buffer and blanking the window.

Count is_clip_mask DrawSvg vertices at 2x in emit_renderer_if_valid so the budget
matches the real emissions. Added two regression tests: a clip mask that fits once
but not when doubled is skipped (the clipped-path blank), and a fitting clip mask
consumes 2x the budget. Verified both fail without the 2x accounting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…allback)

Addresses Codex P2: skipping only an over-budget clip mask left its content in the
queue, and the draw path treats "content but no mask" as draw-unclipped — so an
oversized mask + small content degraded into visibly wrong (unclipped) rendering.

Make clip groups all-or-nothing, order-independently:
- emit_renderer_if_valid poisons a clip_group when any of its DrawSvg parts exceeds
  the vertex budget, and skips (without counting) every later renderer of a poisoned
  group. (Handles mask-before-content.)
- draw_clipped_svg_group drops the entire group when poisoned, so content queued
  BEFORE the over-budget mask is not drawn unclipped. (Handles content-before-mask.)

frame_poisoned_clip_groups is reset per frame next to frame_triangle_vertices.
Regression test: content queued first, then an over-budget mask for the same group
poisons it (draw path drops the group on that flag) and later group geometry is
skipped. Verified the test fails without the poison logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ip groups)

Reworks the guard from emit-time to draw-time budgeting, which fixes a class of
issues with the previous approach and removes the special-cases it needed.

Why: emit-time budgeting counted *queued* geometry, not actual sokol-gl emissions,
which forced a 2x clip-mask heuristic and a global poison map keyed by clip_group —
but clip_group ids are per-SVG-local (svg/tessellate.v resets the counter each
tessellate()), so the global map collided across widgets: one over-budget clipped
SVG would wrongly drop every other clipped SVG sharing the same local id. It also
over-counted offscreen-rendered filtered SVG content and left poisoned-but-counted
content inflating the budget.

Now: frame_triangle_vertices is reset at the top of renderers_draw() (so the print/
raster path that reuses renderers_draw is covered too — no separate reset to forget),
and admit_triangle_vertices() is called at the actual emission sites:
- draw_svg_batch: per-renderer (flat non-clipped SVG),
- draw_triangles_gradient: per-call (per-vertex-colored SVG),
- draw_clipped_svg_group: ATOMICALLY for the whole consecutive run via
  group_triangle_vertices() (mask counted twice for stencil write+clear, content
  once) — per-run, so repeated clip_group ids across SVGs never interfere, and a
  group is all-or-nothing (never content-without-mask = unclipped).

This removes the Window.frame_poisoned_clip_groups map, the emit-time DrawSvg block,
and the 2x heuristic; budgeting now matches real emissions. Filtered SVG content
rendered in the separate offscreen pass is no longer charged to the swapchain budget.

Scope (unchanged): only the unbounded contributor (DrawSvg triangle geometry) is
metered; bounded vector chrome is covered by the fixed 16k reserve.

Tests retargeted at the pure decision helpers (admit_triangle_vertices,
group_triangle_vertices): accumulate-and-cap, single-oversized, mask-counted-twice,
content-only, and atomic over-budget clip-group rejection. Verified each fails when
the cap or the 2x is removed. (The GL-touching draw functions can't run headless;
the budget decision they call is fully covered.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-validating

render_guard_warn_once kept a `if .len == 0 { init }` guard carried over from the old
inlined code, but V auto-initializes map struct fields, so reads/writes already work on
a zero-value Window — the guard never did anything. Removed it (verified: the warn path
writes render_guard_warned on a make_window() Window{} and all render tests pass).

Also derive the cumulative-budget test's batch size from max_frame_triangle_vertices
(with an explicit `2*half > budget` assert) so retuning the const can't silently
invalidate the boundary it tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MartenH MartenH force-pushed the fix-vertex-budget-guard branch from bbdf903 to b85733a Compare June 22, 2026 17:31
@MartenH

MartenH commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@GGRei Done

@GGRei

GGRei commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

@MartenH I can’t merge PRs. Please say that you are ready on Discord in the v-gui channel. Thanks!

@dy-tea dy-tea merged commit 9687aba into vlang:main Jun 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants