diff --git a/_render_test.v b/_render_test.v index a8f0429..35ca521 100644 --- a/_render_test.v +++ b/_render_test.v @@ -840,3 +840,84 @@ fn test_invalid_clip_is_skipped_and_next_draw_kept() { } } } + +// 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: float_len} + color: gg.Color{255, 255, 255, 255} + is_clip_mask: is_mask + clip_group: group + x: 0 + y: 0 + scale: 1 + }) +} + +// 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() + assert w.frame_triangle_vertices == 0 + // 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 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 +} + +// A single batch larger than the whole buffer is rejected outright. +fn test_admit_triangle_vertices_rejects_single_oversized() { + mut w := make_window() + assert !w.admit_triangle_vertices(max_frame_triangle_vertices + 1) + assert w.frame_triangle_vertices == 0 + assert w.render_guard_warned['triangle_vertex_budget'] +} + +// 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 +} + +// 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() + 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 ebbbae5..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 @@ -308,6 +312,15 @@ fn draw_clipped_svg_group(renderers []Renderer, idx int, mut window Window) int return group_end } + // 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 + } + if !has_mask { // No mask — draw content unclipped for i in group_start .. group_end { @@ -548,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 5e3f61c..37aaa13 100644 --- a/render_validate.v +++ b/render_validate.v @@ -3,6 +3,66 @@ 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. +// +// 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) { + if !w.render_guard_warned[key] { + log.warn(msg) + w.render_guard_warned[key] = true + } +} + +// 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) } @@ -143,13 +203,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 +211,9 @@ fn emit_renderer_if_valid(r Renderer, mut window Window) bool { if !renderer_valid_for_draw(r) { return false } + // 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 f14972b..17b19e9 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 // 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