From 51b57a31b0a184934d6b1376136020e7fae8cfa3 Mon Sep 17 00:00:00 2001 From: MartenH Date: Sat, 20 Jun 2026 17:00:06 +0200 Subject: [PATCH 1/5] render: guard per-frame triangle-vertex budget (prevent sokol-gl overflow blanking the window) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- _render_test.v | 56 +++++++++++++++++++++++++++++++++++++++++++++++ render_validate.v | 44 +++++++++++++++++++++++++++++++------ window.v | 1 + window_update.v | 1 + 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/_render_test.v b/_render_test.v index a8f0429..a3dc765 100644 --- a/_render_test.v +++ b/_render_test.v @@ -840,3 +840,59 @@ fn test_invalid_clip_is_skipped_and_next_draw_kept() { } } } + +// svg_triangles_with_vertices builds a geometrically-valid DrawSvg with exactly +// `vertices` vertices (triangles are x,y pairs, so len = vertices * 2). `vertices` +// must be a multiple of 3 so the float count is a multiple of 6. All-zero coords are +// finite, so the renderer passes renderer_valid_for_draw and only the capacity guard +// can skip it. +fn svg_triangles_with_vertices(vertices int) Renderer { + return Renderer(DrawSvg{ + triangles: []f32{len: vertices * 2} + color: gg.Color{255, 255, 255, 255} + x: 0 + y: 0 + scale: 1 + }) +} + +// A single oversized triangle batch — more vertices than the whole sokol-gl buffer — +// is skipped (and warned once) rather than emitted, since on its own it would overflow +// the buffer and blank the entire frame. +fn test_triangle_vertex_budget_skips_single_oversized_batch() { + mut w := make_window() + huge := max_frame_triangle_vertices + 3 // multiple of 3, just over the budget + assert !emit_renderer_if_valid(svg_triangles_with_vertices(huge), mut w) + assert w.renderers.len == 0 + assert w.frame_triangle_vertices == 0 + assert w.render_guard_warned['triangle_vertex_budget'] +} + +// Triangle batches accumulate across a frame; the batch that would push the cumulative +// count past the shared sokol-gl buffer is skipped while everything that fit stays. +// This is exactly the case that previously overflowed the buffer (many medium polyline +// batches summing past 64k) and blanked the whole window. +fn test_triangle_vertex_budget_is_cumulative_per_frame() { + mut w := make_window() + half := 30000 // multiple of 3; two of these exceed max_frame_triangle_vertices + // First batch fits. + assert emit_renderer_if_valid(svg_triangles_with_vertices(half), mut w) + assert w.renderers.len == 1 + assert w.frame_triangle_vertices == half + // Second batch would overflow the buffer -> skipped, count unchanged, warned once. + assert !emit_renderer_if_valid(svg_triangles_with_vertices(half), mut w) + assert w.renderers.len == 1 + assert w.frame_triangle_vertices == half + assert w.render_guard_warned['triangle_vertex_budget'] + // A small non-triangle renderer is unaffected by the triangle budget. + small_rect := Renderer(DrawRect{ + x: 0 + y: 0 + w: 5 + h: 5 + color: gg.Color{255, 255, 255, 255} + style: .fill + }) + assert emit_renderer_if_valid(small_rect, mut w) + assert w.renderers.len == 2 +} diff --git a/render_validate.v b/render_validate.v index 5e3f61c..88a6463 100644 --- a/render_validate.v +++ b/render_validate.v @@ -3,6 +3,30 @@ module gui import log import math +// max_frame_triangle_vertices caps the triangle geometry emitted in a single frame. +// gg sets up sokol-gl with the default descriptor, so every triangle drawn in a frame +// is batched into ONE fixed-capacity vertex buffer (sokol's 64k-vertex default). +// Overflowing that buffer makes sokol-gl silently drop the WHOLE frame's geometry — a +// blank window — with no error surfaced to the application. Triangle geometry (DrawSvg, +// e.g. draw_canvas polylines/polygons) is the only unbounded contributor; text, rects +// and images are bounded by the widget count. We reserve ~16k vertices of the 64k +// buffer for that bounded chrome and cap the unbounded triangle stream at the rest, so +// a runaway batch (e.g. a plot fed far more points than the canvas has pixels) skips +// itself with a one-time warning instead of blanking everything. DrawSvg.triangles are +// x,y pairs, so the vertex count is triangles.len / 2. +const max_frame_triangle_vertices = 49152 // 64k sokol-gl buffer − 16k chrome headroom + +// render_guard_warn_once logs a render-guard warning at most once per key per window. +fn render_guard_warn_once(mut w Window, key string, msg string) { + if w.render_guard_warned.len == 0 { + w.render_guard_warned = map[string]bool{} + } + if !w.render_guard_warned[key] { + log.warn(msg) + w.render_guard_warned[key] = true + } +} + fn f32_is_finite(value f32) bool { return !math.is_nan(value) && !math.is_inf(value, 0) } @@ -143,13 +167,7 @@ fn guard_renderer_or_skip(r Renderer, mut w Window) bool { } kind := renderer_kind(r) - if w.render_guard_warned.len == 0 { - w.render_guard_warned = map[string]bool{} - } - if !w.render_guard_warned[kind] { - log.warn('renderer guard skipped invalid renderer: ${kind}') - w.render_guard_warned[kind] = true - } + render_guard_warn_once(mut w, kind, 'renderer guard skipped invalid renderer: ${kind}') return false } @@ -157,6 +175,18 @@ fn emit_renderer_if_valid(r Renderer, mut window Window) bool { if !renderer_valid_for_draw(r) { return false } + // Capacity guard: keep the cumulative triangle vertices for this frame under the + // shared sokol-gl buffer so one oversized batch skips itself instead of overflowing + // the buffer and blanking the entire frame (see max_frame_triangle_vertices). + if r is DrawSvg { + vertices := r.triangles.len / 2 // triangles are x,y pairs + if window.frame_triangle_vertices + vertices > max_frame_triangle_vertices { + render_guard_warn_once(mut window, 'triangle_vertex_budget', + 'renderer guard skipped DrawSvg: per-frame triangle-vertex budget (${max_frame_triangle_vertices}) exceeded — sokol-gl buffer would overflow') + return false + } + window.frame_triangle_vertices += vertices + } window.renderers << r return true } diff --git a/window.v b/window.v index f14972b..0e51f56 100644 --- a/window.v +++ b/window.v @@ -45,6 +45,7 @@ mut: refresh_layout bool // Trigger full view/layout/renderer rebuild next frame refresh_render_only bool // Trigger renderer-only rebuild from existing layout render_guard_warned map[string]bool // Renderer kinds warned by render guard (prod only) + frame_triangle_vertices int // Cumulative triangle vertices emitted this frame (sokol-gl buffer guard) renderers []Renderer // Flat list of drawing instructions for the current frame scratch ScratchPools // Bounded scratch arrays reused in hot paths stats Stats // Rendering statistics diff --git a/window_update.v b/window_update.v index bfa5f47..fc09ec2 100644 --- a/window_update.v +++ b/window_update.v @@ -122,6 +122,7 @@ fn (mut window Window) build_renderers(background_color Color, clip_rect DrawCli window.scratch.put_filter_renderers(mut filter_renderers) window.scratch.begin_svg_transform_batches() array_clear(mut window.renderers) + window.frame_triangle_vertices = 0 // reset the per-frame sokol-gl vertex budget render_layout(mut window.layout, background_color, clip_rect, mut window) window.scratch.trim_svg_transform_batches() $if !prod { From 72f646334074794f27bceb4225c7799c80d8ac0c Mon Sep 17 00:00:00 2001 From: MartenH Date: Sat, 20 Jun 2026 23:28:35 +0200 Subject: [PATCH 2/5] render: budget clip-mask geometry at 2x (drawn for stencil write + clear) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- _render_test.v | 39 +++++++++++++++++++++++++++++++++++++++ render_validate.v | 8 +++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/_render_test.v b/_render_test.v index a3dc765..3559130 100644 --- a/_render_test.v +++ b/_render_test.v @@ -896,3 +896,42 @@ fn test_triangle_vertex_budget_is_cumulative_per_frame() { assert emit_renderer_if_valid(small_rect, mut w) assert w.renderers.len == 2 } + +// clip_mask_with_vertices builds a valid stencil clip-mask DrawSvg with `vertices` +// vertices (multiple of 3). A clip mask is drawn twice per frame (stencil write + +// clear), so the budget must count its geometry at 2x. +fn clip_mask_with_vertices(vertices int) Renderer { + return Renderer(DrawSvg{ + triangles: []f32{len: vertices * 2} + color: gg.Color{255, 255, 255, 255} + is_clip_mask: true + clip_group: 1 + x: 0 + y: 0 + scale: 1 + }) +} + +// A clip mask that fits the buffer once but NOT when doubled is skipped — without the +// 2x accounting it would pass the cap yet emit ~2x its vertices (stencil write + clear) +// and overflow the buffer, the original blank-window failure on the clipped path. +fn test_triangle_vertex_budget_counts_clip_mask_twice() { + mut w := make_window() + v := 32766 // multiple of 3; v <= budget but 2*v > budget + assert v <= max_frame_triangle_vertices + assert 2 * v > max_frame_triangle_vertices + assert !emit_renderer_if_valid(clip_mask_with_vertices(v), mut w) + assert w.renderers.len == 0 + assert w.frame_triangle_vertices == 0 + assert w.render_guard_warned['triangle_vertex_budget'] +} + +// A clip mask that fits even when doubled is accepted and consumes 2x its vertices, +// so subsequent geometry is budgeted against the mask's true (doubled) cost. +fn test_clip_mask_consumes_double_budget_when_it_fits() { + mut w := make_window() + k := 9000 // multiple of 3; 2*k well under the budget + assert emit_renderer_if_valid(clip_mask_with_vertices(k), mut w) + assert w.renderers.len == 1 + assert w.frame_triangle_vertices == 2 * k +} diff --git a/render_validate.v b/render_validate.v index 88a6463..9f1e009 100644 --- a/render_validate.v +++ b/render_validate.v @@ -179,7 +179,13 @@ fn emit_renderer_if_valid(r Renderer, mut window Window) bool { // shared sokol-gl buffer so one oversized batch skips itself instead of overflowing // the buffer and blanking the entire frame (see max_frame_triangle_vertices). if r is DrawSvg { - vertices := r.triangles.len / 2 // triangles are x,y pairs + mut vertices := r.triangles.len / 2 // triangles are x,y pairs + // A clip mask is emitted TWICE per frame — written to the stencil buffer and + // then re-drawn to clear it (render_draw_dispatch.v draw_clipped_svg_group steps + // 1 and 3) — so budget it at 2x its geometry, matching the real SGL emissions. + if r.is_clip_mask { + vertices *= 2 + } if window.frame_triangle_vertices + vertices > max_frame_triangle_vertices { render_guard_warn_once(mut window, 'triangle_vertex_budget', 'renderer guard skipped DrawSvg: per-frame triangle-vertex budget (${max_frame_triangle_vertices}) exceeded — sokol-gl buffer would overflow') From 2ca51958ff119a8aa6dfb8f698f45c9b847f05d1 Mon Sep 17 00:00:00 2001 From: MartenH Date: Sat, 20 Jun 2026 23:36:21 +0200 Subject: [PATCH 3/5] render: drop the whole clip group when budget-skipped (no unclipped fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- _render_test.v | 39 ++++++++++++++++++++++ render_draw_dispatch.v | 7 ++++ render_validate.v | 14 ++++++++ window.v | 75 +++++++++++++++++++++--------------------- window_update.v | 3 ++ 5 files changed, 101 insertions(+), 37 deletions(-) diff --git a/_render_test.v b/_render_test.v index 3559130..83488c6 100644 --- a/_render_test.v +++ b/_render_test.v @@ -935,3 +935,42 @@ fn test_clip_mask_consumes_double_budget_when_it_fits() { assert w.renderers.len == 1 assert w.frame_triangle_vertices == 2 * k } + +fn svg_content_in_group(vertices int, group int) Renderer { + return Renderer(DrawSvg{ + triangles: []f32{len: vertices * 2} + color: gg.Color{255, 255, 255, 255} + clip_group: group + x: 0 + y: 0 + scale: 1 + }) +} + +// When a clip group's mask is budget-skipped, the WHOLE group is poisoned so its content +// can't render unclipped: content queued before the over-budget mask sets the poison flag +// (the draw path drops the group), and any group geometry emitted after is skipped here. +fn test_over_budget_clip_mask_poisons_whole_group() { + mut w := make_window() + grp := 7 + // Content of the group fits first and is queued. + assert emit_renderer_if_valid(svg_content_in_group(9000, grp), mut w) + assert w.renderers.len == 1 + // An over-budget mask for the same group poisons it (2*32766 > budget). + big_mask := Renderer(DrawSvg{ + triangles: []f32{len: 32766 * 2} + color: gg.Color{255, 255, 255, 255} + is_clip_mask: true + clip_group: grp + x: 0 + y: 0 + scale: 1 + }) + assert !emit_renderer_if_valid(big_mask, mut w) + assert w.frame_poisoned_clip_groups[grp] // draw path drops the whole group on this flag + // Further geometry of the poisoned group is dropped (not queued, not counted). + before := w.frame_triangle_vertices + assert !emit_renderer_if_valid(svg_content_in_group(3, grp), mut w) + assert w.frame_triangle_vertices == before + assert w.renderers.len == 1 +} diff --git a/render_draw_dispatch.v b/render_draw_dispatch.v index ebbbae5..0d25dff 100644 --- a/render_draw_dispatch.v +++ b/render_draw_dispatch.v @@ -304,6 +304,13 @@ fn draw_clipped_svg_group(renderers []Renderer, idx int, mut window Window) int break } + // If this clip group was poisoned by the vertex-budget guard (a part exceeded the + // per-frame budget), drop the WHOLE group — drawing its content without the mask + // would render it unclipped, and re-adding the mask would overflow the SGL buffer. + if window.frame_poisoned_clip_groups[group] { + return group_end + } + if !has_content { return group_end } diff --git a/render_validate.v b/render_validate.v index 9f1e009..bf30e00 100644 --- a/render_validate.v +++ b/render_validate.v @@ -179,6 +179,14 @@ fn emit_renderer_if_valid(r Renderer, mut window Window) bool { // shared sokol-gl buffer so one oversized batch skips itself instead of overflowing // the buffer and blanking the entire frame (see max_frame_triangle_vertices). if r is DrawSvg { + group := r.clip_group + // A stencil-clipped group is all-or-nothing: drawing its content WITHOUT its mask + // renders the content unclipped. So once any part of a clip group is budget-skipped + // the whole group is "poisoned" — drop every later renderer of it here (and the + // draw path drops any content already queued before the poison). + if group > 0 && window.frame_poisoned_clip_groups[group] { + return false + } mut vertices := r.triangles.len / 2 // triangles are x,y pairs // A clip mask is emitted TWICE per frame — written to the stencil buffer and // then re-drawn to clear it (render_draw_dispatch.v draw_clipped_svg_group steps @@ -187,6 +195,12 @@ fn emit_renderer_if_valid(r Renderer, mut window Window) bool { vertices *= 2 } if window.frame_triangle_vertices + vertices > max_frame_triangle_vertices { + if group > 0 { + if window.frame_poisoned_clip_groups.len == 0 { + window.frame_poisoned_clip_groups = map[int]bool{} + } + window.frame_poisoned_clip_groups[group] = true + } render_guard_warn_once(mut window, 'triangle_vertex_budget', 'renderer guard skipped DrawSvg: per-frame triangle-vertex budget (${max_frame_triangle_vertices}) exceeded — sokol-gl buffer would overflow') return false diff --git a/window.v b/window.v index 0e51f56..dfe9682 100644 --- a/window.v +++ b/window.v @@ -19,43 +19,44 @@ pub type WindowCommand = fn (mut Window) pub struct Window { mut: - commands_mutex &sync.Mutex = sync.new_mutex() // Mutex for command queue - focused bool = true // Window focus state - mutex &sync.Mutex = sync.new_mutex() // Mutex for thread-safety - on_event fn (e &Event, mut w Window) = fn (_ &Event, mut _ Window) {} // Global event handler - state voidptr = unsafe { nil } // User state passed to the window - text_system &vglyph.TextSystem = unsafe { nil } // Text rendering system - ui &gg.Context = &gg.Context{} // Main sokol/gg graphics context - view_generator fn (&Window) View = empty_view // Function to generate the UI view - a11y A11y // Accessibility backend state (lazily initialized) - animations map[string]Animation // Active animations (keyed by id) - commands []WindowCommand // Atomic command queue for UI state updates - debug_layout bool // enable layout performance stats - inspector_enabled bool // dev-only inspector overlay (F12) - inspector_tree_cache []TreeNodeCfg // previous-frame tree for inspector - inspector_props_cache map[string]InspectorNodeProps // previous-frame node properties - dialog_cfg DialogCfg // Configuration for the active dialog (if any) - filter_state SvgFilterState // Offscreen state for SVG filters - ime IME // Input Method Editor state (lazily initialized) - init_error string // error during initialization (e.g. text system fail) - layout Layout // The current calculated layout tree - layout_callback_lifetime LayoutCallbackLifetime // Owns callbacks created while rebuilding layout epochs - layout_stats LayoutStats // populated when debug_layout is true - pip Pipelines // GPU rendering pipelines (lazily initialized) - refresh_layout bool // Trigger full view/layout/renderer rebuild next frame - refresh_render_only bool // Trigger renderer-only rebuild from existing layout - render_guard_warned map[string]bool // Renderer kinds warned by render guard (prod only) - frame_triangle_vertices int // Cumulative triangle vertices emitted this frame (sokol-gl buffer guard) - renderers []Renderer // Flat list of drawing instructions for the current frame - scratch ScratchPools // Bounded scratch arrays reused in hot paths - stats Stats // Rendering statistics - clip_radius f32 // rounded clip radius, render-time only - toasts []ToastNotification // active toast queue - toast_counter u64 // monotonic toast id - view_state ViewState // Manages state for widgets (scroll, selection, etc.) - window_size gg.Size // cached, gg.window_size() relatively slow - file_access FileAccessState // security-scoped bookmark state - file_access_mutex &sync.Mutex = sync.new_mutex() // guards file access state + commands_mutex &sync.Mutex = sync.new_mutex() // Mutex for command queue + focused bool = true // Window focus state + mutex &sync.Mutex = sync.new_mutex() // Mutex for thread-safety + on_event fn (e &Event, mut w Window) = fn (_ &Event, mut _ Window) {} // Global event handler + state voidptr = unsafe { nil } // User state passed to the window + text_system &vglyph.TextSystem = unsafe { nil } // Text rendering system + ui &gg.Context = &gg.Context{} // Main sokol/gg graphics context + view_generator fn (&Window) View = empty_view // Function to generate the UI view + a11y A11y // Accessibility backend state (lazily initialized) + animations map[string]Animation // Active animations (keyed by id) + commands []WindowCommand // Atomic command queue for UI state updates + debug_layout bool // enable layout performance stats + inspector_enabled bool // dev-only inspector overlay (F12) + inspector_tree_cache []TreeNodeCfg // previous-frame tree for inspector + inspector_props_cache map[string]InspectorNodeProps // previous-frame node properties + dialog_cfg DialogCfg // Configuration for the active dialog (if any) + filter_state SvgFilterState // Offscreen state for SVG filters + ime IME // Input Method Editor state (lazily initialized) + init_error string // error during initialization (e.g. text system fail) + layout Layout // The current calculated layout tree + layout_callback_lifetime LayoutCallbackLifetime // Owns callbacks created while rebuilding layout epochs + layout_stats LayoutStats // populated when debug_layout is true + pip Pipelines // GPU rendering pipelines (lazily initialized) + refresh_layout bool // Trigger full view/layout/renderer rebuild next frame + refresh_render_only bool // Trigger renderer-only rebuild from existing layout + render_guard_warned map[string]bool // Renderer kinds warned by render guard (prod only) + frame_triangle_vertices int // Cumulative triangle vertices emitted this frame (sokol-gl buffer guard) + frame_poisoned_clip_groups map[int]bool // Clip groups dropped this frame (a part exceeded the vertex budget; drawing content without its mask renders unclipped) + renderers []Renderer // Flat list of drawing instructions for the current frame + scratch ScratchPools // Bounded scratch arrays reused in hot paths + stats Stats // Rendering statistics + clip_radius f32 // rounded clip radius, render-time only + toasts []ToastNotification // active toast queue + toast_counter u64 // monotonic toast id + view_state ViewState // Manages state for widgets (scroll, selection, etc.) + window_size gg.Size // cached, gg.window_size() relatively slow + file_access FileAccessState // security-scoped bookmark state + file_access_mutex &sync.Mutex = sync.new_mutex() // guards file access state } // Window is the main application window. `state` holds app state. diff --git a/window_update.v b/window_update.v index fc09ec2..da885fd 100644 --- a/window_update.v +++ b/window_update.v @@ -123,6 +123,9 @@ fn (mut window Window) build_renderers(background_color Color, clip_rect DrawCli window.scratch.begin_svg_transform_batches() array_clear(mut window.renderers) window.frame_triangle_vertices = 0 // reset the per-frame sokol-gl vertex budget + if window.frame_poisoned_clip_groups.len > 0 { + window.frame_poisoned_clip_groups = map[int]bool{} + } render_layout(mut window.layout, background_color, clip_rect, mut window) window.scratch.trim_svg_transform_batches() $if !prod { From 74902327fe82d40eca1c5b8b4c6060c60311e6a9 Mon Sep 17 00:00:00 2001 From: MartenH Date: Sun, 21 Jun 2026 09:02:36 +0200 Subject: [PATCH 4/5] render: meter the triangle-vertex budget in the draw pass (per-run clip groups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- _render_test.v | 169 ++++++++++++++--------------------------- render_draw_dispatch.v | 19 +++-- render_validate.v | 90 ++++++++++++---------- window.v | 75 +++++++++--------- window_update.v | 4 - 5 files changed, 157 insertions(+), 200 deletions(-) diff --git a/_render_test.v b/_render_test.v index 83488c6..09a9271 100644 --- a/_render_test.v +++ b/_render_test.v @@ -841,136 +841,79 @@ fn test_invalid_clip_is_skipped_and_next_draw_kept() { } } -// svg_triangles_with_vertices builds a geometrically-valid DrawSvg with exactly -// `vertices` vertices (triangles are x,y pairs, so len = vertices * 2). `vertices` -// must be a multiple of 3 so the float count is a multiple of 6. All-zero coords are -// finite, so the renderer passes renderer_valid_for_draw and only the capacity guard -// can skip it. -fn svg_triangles_with_vertices(vertices int) Renderer { +// svg_with_triangle_floats builds a DrawSvg with a triangle float array of the given +// length (x,y pairs). Used to exercise the budget helpers (group_triangle_vertices does +// not validate geometry; it only sums lengths). +fn svg_with_triangle_floats(float_len int, is_mask bool, group int) Renderer { return Renderer(DrawSvg{ - triangles: []f32{len: vertices * 2} - color: gg.Color{255, 255, 255, 255} - x: 0 - y: 0 - scale: 1 - }) -} - -// A single oversized triangle batch — more vertices than the whole sokol-gl buffer — -// is skipped (and warned once) rather than emitted, since on its own it would overflow -// the buffer and blank the entire frame. -fn test_triangle_vertex_budget_skips_single_oversized_batch() { - mut w := make_window() - huge := max_frame_triangle_vertices + 3 // multiple of 3, just over the budget - assert !emit_renderer_if_valid(svg_triangles_with_vertices(huge), mut w) - assert w.renderers.len == 0 - assert w.frame_triangle_vertices == 0 - assert w.render_guard_warned['triangle_vertex_budget'] -} - -// Triangle batches accumulate across a frame; the batch that would push the cumulative -// count past the shared sokol-gl buffer is skipped while everything that fit stays. -// This is exactly the case that previously overflowed the buffer (many medium polyline -// batches summing past 64k) and blanked the whole window. -fn test_triangle_vertex_budget_is_cumulative_per_frame() { - mut w := make_window() - half := 30000 // multiple of 3; two of these exceed max_frame_triangle_vertices - // First batch fits. - assert emit_renderer_if_valid(svg_triangles_with_vertices(half), mut w) - assert w.renderers.len == 1 - assert w.frame_triangle_vertices == half - // Second batch would overflow the buffer -> skipped, count unchanged, warned once. - assert !emit_renderer_if_valid(svg_triangles_with_vertices(half), mut w) - assert w.renderers.len == 1 - assert w.frame_triangle_vertices == half - assert w.render_guard_warned['triangle_vertex_budget'] - // A small non-triangle renderer is unaffected by the triangle budget. - small_rect := Renderer(DrawRect{ - x: 0 - y: 0 - w: 5 - h: 5 - color: gg.Color{255, 255, 255, 255} - style: .fill - }) - assert emit_renderer_if_valid(small_rect, mut w) - assert w.renderers.len == 2 -} - -// clip_mask_with_vertices builds a valid stencil clip-mask DrawSvg with `vertices` -// vertices (multiple of 3). A clip mask is drawn twice per frame (stencil write + -// clear), so the budget must count its geometry at 2x. -fn clip_mask_with_vertices(vertices int) Renderer { - return Renderer(DrawSvg{ - triangles: []f32{len: vertices * 2} + triangles: []f32{len: float_len} color: gg.Color{255, 255, 255, 255} - is_clip_mask: true - clip_group: 1 + is_clip_mask: is_mask + clip_group: group x: 0 y: 0 scale: 1 }) } -// A clip mask that fits the buffer once but NOT when doubled is skipped — without the -// 2x accounting it would pass the cap yet emit ~2x its vertices (stencil write + clear) -// and overflow the buffer, the original blank-window failure on the clipped path. -fn test_triangle_vertex_budget_counts_clip_mask_twice() { +// admit_triangle_vertices accumulates within the budget and rejects (warning once) the +// batch that would overflow the shared sokol-gl buffer, leaving the running total intact. +fn test_admit_triangle_vertices_accumulates_and_caps() { mut w := make_window() - v := 32766 // multiple of 3; v <= budget but 2*v > budget - assert v <= max_frame_triangle_vertices - assert 2 * v > max_frame_triangle_vertices - assert !emit_renderer_if_valid(clip_mask_with_vertices(v), mut w) - assert w.renderers.len == 0 assert w.frame_triangle_vertices == 0 + assert w.admit_triangle_vertices(30000) + assert w.frame_triangle_vertices == 30000 + // 30000 + 30000 > 49152 -> rejected, total unchanged, warned once. + assert !w.admit_triangle_vertices(30000) + assert w.frame_triangle_vertices == 30000 assert w.render_guard_warned['triangle_vertex_budget'] + // A batch that still fits the remaining budget is admitted (exact-boundary fit). + assert w.admit_triangle_vertices(max_frame_triangle_vertices - 30000) + assert w.frame_triangle_vertices == max_frame_triangle_vertices } -// A clip mask that fits even when doubled is accepted and consumes 2x its vertices, -// so subsequent geometry is budgeted against the mask's true (doubled) cost. -fn test_clip_mask_consumes_double_budget_when_it_fits() { +// A single batch larger than the whole buffer is rejected outright. +fn test_admit_triangle_vertices_rejects_single_oversized() { mut w := make_window() - k := 9000 // multiple of 3; 2*k well under the budget - assert emit_renderer_if_valid(clip_mask_with_vertices(k), mut w) - assert w.renderers.len == 1 - assert w.frame_triangle_vertices == 2 * k + assert !w.admit_triangle_vertices(max_frame_triangle_vertices + 1) + assert w.frame_triangle_vertices == 0 + assert w.render_guard_warned['triangle_vertex_budget'] } -fn svg_content_in_group(vertices int, group int) Renderer { - return Renderer(DrawSvg{ - triangles: []f32{len: vertices * 2} - color: gg.Color{255, 255, 255, 255} - clip_group: group - x: 0 - y: 0 - scale: 1 - }) +// group_triangle_vertices counts a clip mask's geometry TWICE (stencil write + clear) +// and content once, matching the real sokol-gl emissions of draw_clipped_svg_group. +fn test_group_triangle_vertices_counts_mask_twice() { + // mask: 90 vertices (len 180) drawn twice -> 180; content: 30 vertices (len 60) -> 30. + renderers := [ + svg_with_triangle_floats(180, true, 1), + svg_with_triangle_floats(60, false, 1), + ] + assert group_triangle_vertices(renderers, 0, renderers.len) == 180 + 30 +} + +// A clip group with no mask (unclipped fallback) is budgeted as content-only. +fn test_group_triangle_vertices_content_only() { + renderers := [ + svg_with_triangle_floats(60, false, 1), + svg_with_triangle_floats(120, false, 1), + ] + assert group_triangle_vertices(renderers, 0, renderers.len) == 30 + 60 } -// When a clip group's mask is budget-skipped, the WHOLE group is poisoned so its content -// can't render unclipped: content queued before the over-budget mask sets the poison flag -// (the draw path drops the group), and any group geometry emitted after is skipped here. -fn test_over_budget_clip_mask_poisons_whole_group() { +// The clipped-group draw path decision: a group whose doubled-mask + content total would +// overflow is rejected atomically (admit returns false, nothing accumulated) — even +// though the mask and content each fit individually. This is the all-or-nothing skip +// that prevents both the unclipped-content fallback and the buffer overflow. +fn test_clip_group_over_budget_rejected_atomically() { + // mask 32766 vertices (len 65532) -> counted 65532 (2x); already over budget alone. + renderers := [ + svg_with_triangle_floats(65532, true, 1), + svg_with_triangle_floats(60, false, 1), + ] + total := group_triangle_vertices(renderers, 0, renderers.len) + assert total > max_frame_triangle_vertices mut w := make_window() - grp := 7 - // Content of the group fits first and is queued. - assert emit_renderer_if_valid(svg_content_in_group(9000, grp), mut w) - assert w.renderers.len == 1 - // An over-budget mask for the same group poisons it (2*32766 > budget). - big_mask := Renderer(DrawSvg{ - triangles: []f32{len: 32766 * 2} - color: gg.Color{255, 255, 255, 255} - is_clip_mask: true - clip_group: grp - x: 0 - y: 0 - scale: 1 - }) - assert !emit_renderer_if_valid(big_mask, mut w) - assert w.frame_poisoned_clip_groups[grp] // draw path drops the whole group on this flag - // Further geometry of the poisoned group is dropped (not queued, not counted). - before := w.frame_triangle_vertices - assert !emit_renderer_if_valid(svg_content_in_group(3, grp), mut w) - assert w.frame_triangle_vertices == before - assert w.renderers.len == 1 + assert !w.admit_triangle_vertices(total) + assert w.frame_triangle_vertices == 0 + assert w.render_guard_warned['triangle_vertex_budget'] } diff --git a/render_draw_dispatch.v b/render_draw_dispatch.v index 0d25dff..afd7bc7 100644 --- a/render_draw_dispatch.v +++ b/render_draw_dispatch.v @@ -104,6 +104,7 @@ fn draw_quad_uv(x f32, y f32, w f32, h f32, z f32, u0 f32, v0 f32, u1 f32, v1 f3 // draw logic of GUI fn renderers_draw(mut window Window) { renderers := window.renderers + window.frame_triangle_vertices = 0 // reset the sokol-gl triangle budget for this draw pass mut active_clip := window.window_rect() mut i := 0 @@ -199,6 +200,9 @@ fn draw_svg_batch(renderers []Renderer, start int, end int, c gg.Color, x f32, y continue } if renderer is DrawSvg { + if !window.admit_triangle_vertices(renderer.triangles.len / 2) { + continue // over the frame's triangle budget — skip this batch member + } mut i := 0 for i < renderer.triangles.len - 5 { x0 := (x + renderer.triangles[i] * tri_scale) * scale @@ -304,14 +308,16 @@ fn draw_clipped_svg_group(renderers []Renderer, idx int, mut window Window) int break } - // If this clip group was poisoned by the vertex-budget guard (a part exceeded the - // per-frame budget), drop the WHOLE group — drawing its content without the mask - // would render it unclipped, and re-adding the mask would overflow the SGL buffer. - if window.frame_poisoned_clip_groups[group] { + if !has_content { return group_end } - if !has_content { + // Budget the clipped group atomically against this frame's sokol-gl triangle buffer: + // a mask is drawn twice (stencil write + clear), content once. If the group would + // overflow, drop the WHOLE group — drawing content without its mask renders it + // unclipped — with a one-time warning. Per consecutive run, so clip-group ids that + // repeat across different SVGs never interfere. + if !window.admit_triangle_vertices(group_triangle_vertices(renderers, group_start, group_end)) { return group_end } @@ -555,6 +561,9 @@ fn draw_triangles_gradient(triangles []f32, vertex_colors []gg.Color, x f32, y f if triangles.len < 6 || vertex_colors.len < 3 { return } + if !window.admit_triangle_vertices(triangles.len / 2) { + return + } scale := window.ui.scale diff --git a/render_validate.v b/render_validate.v index bf30e00..67be131 100644 --- a/render_validate.v +++ b/render_validate.v @@ -7,14 +7,18 @@ import math // gg sets up sokol-gl with the default descriptor, so every triangle drawn in a frame // is batched into ONE fixed-capacity vertex buffer (sokol's 64k-vertex default). // Overflowing that buffer makes sokol-gl silently drop the WHOLE frame's geometry — a -// blank window — with no error surfaced to the application. Triangle geometry (DrawSvg, -// e.g. draw_canvas polylines/polygons) is the only unbounded contributor; text, rects -// and images are bounded by the widget count. We reserve ~16k vertices of the 64k -// buffer for that bounded chrome and cap the unbounded triangle stream at the rest, so -// a runaway batch (e.g. a plot fed far more points than the canvas has pixels) skips -// itself with a one-time warning instead of blanking everything. DrawSvg.triangles are -// x,y pairs, so the vertex count is triangles.len / 2. -const max_frame_triangle_vertices = 49152 // 64k sokol-gl buffer − 16k chrome headroom +// blank window — with no error surfaced to the application. +// +// The budget is metered in the DRAW pass (render_draw_dispatch.v) at the points where +// triangle geometry is actually emitted to sokol-gl, via admit_triangle_vertices(). It +// covers the only UNBOUNDED contributor — DrawSvg triangle geometry (draw_canvas +// polylines/polygons, SVG paths). Vector chrome (rounded rects, circles, images) shares +// the same buffer but is bounded by the widget count, so it is covered by a fixed +// reserve (the 16k below) rather than metered per-draw. A runaway DrawSvg batch (e.g. a +// plot fed far more points than the canvas has pixels) is skipped — for a clipped group, +// the whole group is skipped atomically — with a one-time warning, instead of blanking. +// DrawSvg.triangles are x,y pairs, so a renderer's vertex count is triangles.len / 2. +const max_frame_triangle_vertices = 49152 // 64k sokol-gl buffer − 16k chrome reserve // render_guard_warn_once logs a render-guard warning at most once per key per window. fn render_guard_warn_once(mut w Window, key string, msg string) { @@ -27,6 +31,41 @@ fn render_guard_warn_once(mut w Window, key string, msg string) { } } +// admit_triangle_vertices reserves `vertices` from this frame's sokol-gl triangle +// budget. Returns true (and accumulates) if it fits, or false (warning once) if drawing +// it would overflow the shared vertex buffer and blank the frame. Called from the draw +// pass at each triangle-emission site; frame_triangle_vertices is reset per draw pass in +// renderers_draw(). +fn (mut window Window) admit_triangle_vertices(vertices int) bool { + if window.frame_triangle_vertices + vertices > max_frame_triangle_vertices { + render_guard_warn_once(mut window, 'triangle_vertex_budget', + 'renderer guard skipped triangle geometry: per-frame budget (${max_frame_triangle_vertices}) exceeded — sokol-gl buffer would overflow') + return false + } + window.frame_triangle_vertices += vertices + return true +} + +// group_triangle_vertices sums the sokol-gl vertices a stencil-clipped DrawSvg group +// emits in renderers[start..end]: a clip mask is drawn TWICE (stencil write + clear, so +// triangles.len = 2 × triangles.len/2), content once (triangles.len/2). Used to budget a +// clipped group atomically — drawing content without its mask renders it unclipped, so it +// is all-or-nothing. +fn group_triangle_vertices(renderers []Renderer, start int, end int) int { + mut total := 0 + for idx in start .. end { + r := renderers[idx] + if r is DrawSvg { + if r.is_clip_mask { + total += r.triangles.len // drawn twice: len/2 vertices × 2: len/2 vertices × 2 + } else { + total += r.triangles.len / 2 + } + } + } + return total +} + fn f32_is_finite(value f32) bool { return !math.is_nan(value) && !math.is_inf(value, 0) } @@ -175,38 +214,9 @@ fn emit_renderer_if_valid(r Renderer, mut window Window) bool { if !renderer_valid_for_draw(r) { return false } - // Capacity guard: keep the cumulative triangle vertices for this frame under the - // shared sokol-gl buffer so one oversized batch skips itself instead of overflowing - // the buffer and blanking the entire frame (see max_frame_triangle_vertices). - if r is DrawSvg { - group := r.clip_group - // A stencil-clipped group is all-or-nothing: drawing its content WITHOUT its mask - // renders the content unclipped. So once any part of a clip group is budget-skipped - // the whole group is "poisoned" — drop every later renderer of it here (and the - // draw path drops any content already queued before the poison). - if group > 0 && window.frame_poisoned_clip_groups[group] { - return false - } - mut vertices := r.triangles.len / 2 // triangles are x,y pairs - // A clip mask is emitted TWICE per frame — written to the stencil buffer and - // then re-drawn to clear it (render_draw_dispatch.v draw_clipped_svg_group steps - // 1 and 3) — so budget it at 2x its geometry, matching the real SGL emissions. - if r.is_clip_mask { - vertices *= 2 - } - if window.frame_triangle_vertices + vertices > max_frame_triangle_vertices { - if group > 0 { - if window.frame_poisoned_clip_groups.len == 0 { - window.frame_poisoned_clip_groups = map[int]bool{} - } - window.frame_poisoned_clip_groups[group] = true - } - render_guard_warn_once(mut window, 'triangle_vertex_budget', - 'renderer guard skipped DrawSvg: per-frame triangle-vertex budget (${max_frame_triangle_vertices}) exceeded — sokol-gl buffer would overflow') - return false - } - window.frame_triangle_vertices += vertices - } + // Triangle-vertex budgeting happens in the DRAW pass (render_draw_dispatch.v), at + // the actual sokol-gl emission sites — not here — so it meters real emissions and + // budgets clipped groups atomically. See max_frame_triangle_vertices. window.renderers << r return true } diff --git a/window.v b/window.v index dfe9682..17b19e9 100644 --- a/window.v +++ b/window.v @@ -19,44 +19,43 @@ pub type WindowCommand = fn (mut Window) pub struct Window { mut: - commands_mutex &sync.Mutex = sync.new_mutex() // Mutex for command queue - focused bool = true // Window focus state - mutex &sync.Mutex = sync.new_mutex() // Mutex for thread-safety - on_event fn (e &Event, mut w Window) = fn (_ &Event, mut _ Window) {} // Global event handler - state voidptr = unsafe { nil } // User state passed to the window - text_system &vglyph.TextSystem = unsafe { nil } // Text rendering system - ui &gg.Context = &gg.Context{} // Main sokol/gg graphics context - view_generator fn (&Window) View = empty_view // Function to generate the UI view - a11y A11y // Accessibility backend state (lazily initialized) - animations map[string]Animation // Active animations (keyed by id) - commands []WindowCommand // Atomic command queue for UI state updates - debug_layout bool // enable layout performance stats - inspector_enabled bool // dev-only inspector overlay (F12) - inspector_tree_cache []TreeNodeCfg // previous-frame tree for inspector - inspector_props_cache map[string]InspectorNodeProps // previous-frame node properties - dialog_cfg DialogCfg // Configuration for the active dialog (if any) - filter_state SvgFilterState // Offscreen state for SVG filters - ime IME // Input Method Editor state (lazily initialized) - init_error string // error during initialization (e.g. text system fail) - layout Layout // The current calculated layout tree - layout_callback_lifetime LayoutCallbackLifetime // Owns callbacks created while rebuilding layout epochs - layout_stats LayoutStats // populated when debug_layout is true - pip Pipelines // GPU rendering pipelines (lazily initialized) - refresh_layout bool // Trigger full view/layout/renderer rebuild next frame - refresh_render_only bool // Trigger renderer-only rebuild from existing layout - render_guard_warned map[string]bool // Renderer kinds warned by render guard (prod only) - frame_triangle_vertices int // Cumulative triangle vertices emitted this frame (sokol-gl buffer guard) - frame_poisoned_clip_groups map[int]bool // Clip groups dropped this frame (a part exceeded the vertex budget; drawing content without its mask renders unclipped) - renderers []Renderer // Flat list of drawing instructions for the current frame - scratch ScratchPools // Bounded scratch arrays reused in hot paths - stats Stats // Rendering statistics - clip_radius f32 // rounded clip radius, render-time only - toasts []ToastNotification // active toast queue - toast_counter u64 // monotonic toast id - view_state ViewState // Manages state for widgets (scroll, selection, etc.) - window_size gg.Size // cached, gg.window_size() relatively slow - file_access FileAccessState // security-scoped bookmark state - file_access_mutex &sync.Mutex = sync.new_mutex() // guards file access state + commands_mutex &sync.Mutex = sync.new_mutex() // Mutex for command queue + focused bool = true // Window focus state + mutex &sync.Mutex = sync.new_mutex() // Mutex for thread-safety + on_event fn (e &Event, mut w Window) = fn (_ &Event, mut _ Window) {} // Global event handler + state voidptr = unsafe { nil } // User state passed to the window + text_system &vglyph.TextSystem = unsafe { nil } // Text rendering system + ui &gg.Context = &gg.Context{} // Main sokol/gg graphics context + view_generator fn (&Window) View = empty_view // Function to generate the UI view + a11y A11y // Accessibility backend state (lazily initialized) + animations map[string]Animation // Active animations (keyed by id) + commands []WindowCommand // Atomic command queue for UI state updates + debug_layout bool // enable layout performance stats + inspector_enabled bool // dev-only inspector overlay (F12) + inspector_tree_cache []TreeNodeCfg // previous-frame tree for inspector + inspector_props_cache map[string]InspectorNodeProps // previous-frame node properties + dialog_cfg DialogCfg // Configuration for the active dialog (if any) + filter_state SvgFilterState // Offscreen state for SVG filters + ime IME // Input Method Editor state (lazily initialized) + init_error string // error during initialization (e.g. text system fail) + layout Layout // The current calculated layout tree + layout_callback_lifetime LayoutCallbackLifetime // Owns callbacks created while rebuilding layout epochs + layout_stats LayoutStats // populated when debug_layout is true + pip Pipelines // GPU rendering pipelines (lazily initialized) + refresh_layout bool // Trigger full view/layout/renderer rebuild next frame + refresh_render_only bool // Trigger renderer-only rebuild from existing layout + render_guard_warned map[string]bool // Renderer kinds warned by render guard (prod only) + frame_triangle_vertices int // Running sokol-gl triangle-vertex count for the current draw pass (reset in renderers_draw) + renderers []Renderer // Flat list of drawing instructions for the current frame + scratch ScratchPools // Bounded scratch arrays reused in hot paths + stats Stats // Rendering statistics + clip_radius f32 // rounded clip radius, render-time only + toasts []ToastNotification // active toast queue + toast_counter u64 // monotonic toast id + view_state ViewState // Manages state for widgets (scroll, selection, etc.) + window_size gg.Size // cached, gg.window_size() relatively slow + file_access FileAccessState // security-scoped bookmark state + file_access_mutex &sync.Mutex = sync.new_mutex() // guards file access state } // Window is the main application window. `state` holds app state. diff --git a/window_update.v b/window_update.v index da885fd..bfa5f47 100644 --- a/window_update.v +++ b/window_update.v @@ -122,10 +122,6 @@ fn (mut window Window) build_renderers(background_color Color, clip_rect DrawCli window.scratch.put_filter_renderers(mut filter_renderers) window.scratch.begin_svg_transform_batches() array_clear(mut window.renderers) - window.frame_triangle_vertices = 0 // reset the per-frame sokol-gl vertex budget - if window.frame_poisoned_clip_groups.len > 0 { - window.frame_poisoned_clip_groups = map[int]bool{} - } render_layout(mut window.layout, background_color, clip_rect, mut window) window.scratch.trim_svg_transform_batches() $if !prod { From b85733a00c3e3833333589c62f59545cf4a1fd9a Mon Sep 17 00:00:00 2001 From: MartenH Date: Sun, 21 Jun 2026 09:09:04 +0200 Subject: [PATCH 5/5] render: drop dead map lazy-init guard; make budget test boundary self-validating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- _render_test.v | 18 +++++++++++------- render_validate.v | 3 --- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/_render_test.v b/_render_test.v index 09a9271..35ca521 100644 --- a/_render_test.v +++ b/_render_test.v @@ -861,14 +861,18 @@ fn svg_with_triangle_floats(float_len int, is_mask bool, group int) Renderer { fn test_admit_triangle_vertices_accumulates_and_caps() { mut w := make_window() assert w.frame_triangle_vertices == 0 - assert w.admit_triangle_vertices(30000) - assert w.frame_triangle_vertices == 30000 - // 30000 + 30000 > 49152 -> rejected, total unchanged, warned once. - assert !w.admit_triangle_vertices(30000) - assert w.frame_triangle_vertices == 30000 + // More than half the budget, so a second identical batch must overflow. Derived from + // the const so the boundary stays valid if max_frame_triangle_vertices is retuned. + half := max_frame_triangle_vertices / 2 + 1 + assert 2 * half > max_frame_triangle_vertices + assert w.admit_triangle_vertices(half) + assert w.frame_triangle_vertices == half + // half + half > budget -> rejected, total unchanged, warned once. + assert !w.admit_triangle_vertices(half) + assert w.frame_triangle_vertices == half assert w.render_guard_warned['triangle_vertex_budget'] - // A batch that still fits the remaining budget is admitted (exact-boundary fit). - assert w.admit_triangle_vertices(max_frame_triangle_vertices - 30000) + // A batch that exactly fills the remaining budget is admitted (boundary fit). + assert w.admit_triangle_vertices(max_frame_triangle_vertices - half) assert w.frame_triangle_vertices == max_frame_triangle_vertices } diff --git a/render_validate.v b/render_validate.v index 67be131..37aaa13 100644 --- a/render_validate.v +++ b/render_validate.v @@ -22,9 +22,6 @@ const max_frame_triangle_vertices = 49152 // 64k sokol-gl buffer − 16k chrome // render_guard_warn_once logs a render-guard warning at most once per key per window. fn render_guard_warn_once(mut w Window, key string, msg string) { - if w.render_guard_warned.len == 0 { - w.render_guard_warned = map[string]bool{} - } if !w.render_guard_warned[key] { log.warn(msg) w.render_guard_warned[key] = true