Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions _render_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}
16 changes: 16 additions & 0 deletions render_draw_dispatch.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
71 changes: 64 additions & 7 deletions render_validate.v
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -143,20 +203,17 @@ 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
}

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
}
Expand Down
1 change: 1 addition & 0 deletions window.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading