From 05c6e6284cd398ca43620af54c52e0907d49c8b0 Mon Sep 17 00:00:00 2001 From: Zachary Levy Date: Fri, 1 May 2026 17:46:49 -0700 Subject: [PATCH] Backdrop scope implementation --- draw/README.md | 156 ++++++++----- draw/backdrop.odin | 102 ++++---- draw/core_2d.odin | 117 +++++----- draw/draw.odin | 454 ++++++++++++++++++++++++++---------- draw/examples/backdrop.odin | 153 +++++++----- 5 files changed, 633 insertions(+), 349 deletions(-) diff --git a/draw/README.md b/draw/README.md index 1505177..8259996 100644 --- a/draw/README.md +++ b/draw/README.md @@ -614,11 +614,11 @@ require, keeping each sub-pass well under the 32-register cliff. frame renders into `source_texture` (a full-resolution single-sample texture owned by the backdrop pipeline) instead of directly into the swapchain. At the end of the frame, `source_texture` is copied to the swapchain via a single `CopyGPUTextureToTexture` call. -This means the bracket has no mid-frame texture copy: by the time the bracket runs, -`source_texture` already contains the pre-bracket frame contents and is the natural sampler -input. When no layer in the frame has a backdrop draw, the existing fast path runs: the frame -renders directly to the swapchain and the backdrop pipeline's working textures are never -touched. Zero cost for backdrop-free frames. +This means each bracket has no mid-frame texture copy: by the time a bracket runs, +`source_texture` already contains the contents written by everything that preceded it on the +timeline and is the natural sampler input. When no layer in the frame has a backdrop draw, +the existing fast path runs: the frame renders directly to the swapchain and the backdrop +pipeline's working textures are never touched. Zero cost for backdrop-free frames. **Why not split the backdrop sub-passes into separate pipelines?** Each sub-pass is budgeted at ≤24 registers, well under Valhall's 32-register cliff, so there is no occupancy motivation for splitting. @@ -628,81 +628,113 @@ all. Additionally, backdrop effects cover a small fraction of the frame's total typical UI scales), so even if a sub-pass did cross a cliff, the occupancy variation within the bracket would have negligible impact on frame time. -#### Bracket scheduling model +#### Bracket scheduling -The bracket is scheduled per layer, anchored at the first backdrop sub-batch in the layer's -submission order. Concretely, a layer with one or more backdrops splits into three groups: +Backdrop draws are scheduled via **explicit scopes**: every call to `backdrop_blur` must be wrapped +in a `begin_backdrop` / `end_backdrop` pair (or the RAII-style `backdrop_scope` wrapper). Each +scope produces exactly one bracket at render time. A layer may contain any number of scopes; draws +between scopes render at their submission position relative to the brackets, so the user controls +exactly which backdrops share a bracket. -1. **Pass A (pre-bracket)** — every non-backdrop sub-batch with index `< bracket_start_index`. - Renders to `source_texture` in a single render pass. -2. **The bracket** — every backdrop sub-batch in the layer (regardless of index). Runs one - downsample pass, then one (H-blur + V-composite) pass pair per unique sigma. -3. **Pass B (post-bracket)** — every non-backdrop sub-batch with index `>= bracket_start_index`. - Renders to `source_texture` with `LOAD`, drawing on top of the composited backdrop output. +At render time, `draw_layer` walks the layer's sub-batch list once, alternating between two run +kinds: -`bracket_start_index` is the absolute index of the first `.Backdrop` kind in the layer's sub-batch -range. If the layer has no backdrops, none of this kicks in and the layer renders in a single render -pass via the existing fast path. +- **Non-backdrop runs** are rendered to `source_texture` in one render pass via + `render_layer_sub_batch_range`. Clear-vs-load is tracked frame-globally via `GLOB.cleared`. +- **Backdrop runs** are dispatched to `run_backdrop_bracket` with their index range. Each run is + one bracket; the bracket opens and closes its own render passes for downsample, H-blur, V-blur, + and composite stages. -Per-sigma-group execution. The bracket walks each layer's sub-batches and groups contiguous -`.Backdrop` sub-batches that share a sigma; each group picks its own downsample factor (1, 2, or 4) -based on `compute_backdrop_downsample_factor`. For each group it runs four sub-passes: a downsample -from `source_texture` to `downsample_texture`; an H-blur from `downsample_texture` to -`h_blur_texture`; a V-blur from `h_blur_texture` back into `downsample_texture` (ping-pong reuse); -and finally a composite that reads the fully-blurred `downsample_texture`, applies the SDF mask -and tint, and writes the result to `source_texture`. Sub-batch coalescing in -`append_or_extend_sub_batch` merges contiguous same-sigma backdrops into a single instanced -composite draw; non-contiguous same-sigma backdrops still share the blur output but issue separate -composite draws. +Within a bracket, the scheduler groups contiguous same-sigma sub-batches and runs four sub-passes +per group: downsample (`source_texture` → `downsample_texture`), H-blur (`downsample_texture` → +`h_blur_texture`), V-blur (`h_blur_texture` → `downsample_texture`, ping-pong reuse), and +composite (`downsample_texture` → `source_texture` with SDF mask and tint applied). Each group +picks its own downsample factor (1, 2, or 4) based on sigma; see the comment block at the top of +`backdrop.odin` for the factor-selection table. -The working textures are sized at the full swapchain resolution; larger downsample factors only -fill a sub-rect via viewport-limited rendering (see the comment block at the top of `backdrop.odin` -for the factor-selection table and rationale). +Sub-batch coalescing in `append_or_extend_sub_batch` merges contiguous same-sigma backdrops +sharing one scissor into a single instanced composite draw. Same-sigma backdrops separated by a +`ScissorStart` boundary stay in one sigma group (one set of blur passes) but issue separate +composite draws; the composite pass calls `SetGPUScissor` between draws when the active scissor +changes. -#### Submission-order trade-off +Working textures are sized at full swapchain resolution; larger downsample factors fill a sub-rect +via viewport-limited rendering. -Within Pass A and Pass B, sub-batches render in the user's submission order. What the bracket model -sacrifices is _interleaved_ ordering between backdrop and non-backdrop content within a single -layer. A non-backdrop sub-batch submitted between two backdrops still renders in Pass B (after the -bracket), not at its submission position. Worked example: +#### Scope contract -``` -draw.rectangle(layer, bg, GRAY) // 0 Tessellated → Pass A -draw.rectangle(layer, card_blue, BLUE) // 1 SDF → Pass A -draw.gaussian_blur(layer, panelA, sigma=12) // 2 Backdrop → Bracket (sees: bg + blue card) -draw.rectangle(layer, card_red, RED) // 3 SDF → Pass B (drawn ON TOP of panelA) -draw.gaussian_blur(layer, panelB, sigma=12) // 4 Backdrop → Bracket (sees: bg + blue card; same as panelA) -draw.text(layer, "label", ...) // 5 Text → Pass B (drawn ON TOP of both panels) -``` +Scope state is global: `GLOB.open_backdrop_layer` tracks the currently-open scope (or `nil`) for +the whole renderer. The five misuse cases panic via `log.panic` / `log.panicf`: -In this layer, panelB does _not_ see card_red — even though card_red was submitted before panelB — -because both backdrops sample `source_texture` as it stood at the bracket entry, which is after -Pass A and before card_red has rendered. card_red ends up on top of panelA, not underneath it. +1. `backdrop_blur` called outside an open scope. +2. A non-backdrop draw call issued on a layer with an open scope. Asserted at the top of + `append_or_extend_sub_batch`. +3. `new_layer` called while a scope is open. +4. `end()` called while a scope is open. +5. `begin_backdrop` while one is already open, or `end_backdrop` on the wrong layer. -The user controls the alternative outcome by splitting layers. Putting card_red and panelB into a -new layer (via `draw.new_layer`) gives panelB a fresh source snapshot that includes panelA and -card_red: +Worked example with two scopes on the same layer: ``` base := draw.begin(...) draw.rectangle(base, bg, GRAY) draw.rectangle(base, card_blue, BLUE) -draw.gaussian_blur(base, panelA, sigma=12) // panelA in base layer's bracket -top := draw.new_layer(base, ...) -draw.rectangle(top, card_red, RED) -draw.gaussian_blur(top, panelB, sigma=12) // top layer's bracket; sees base + card_red -draw.text(top, "label", ...) +{ + draw.backdrop_scope(base) + draw.backdrop_blur(base, panelA, sigma=12) // bracket 1: sees bg + blue card +} + +draw.rectangle(base, card_red, RED) // renders ON TOP of panelA's composite + +{ + draw.backdrop_scope(base) + draw.backdrop_blur(base, panelB, sigma=12) // bracket 2: sees bg + blue card + panelA + card_red +} + +draw.text(base, "label", ...) // renders ON TOP of panelB's composite ``` -Why one bracket per layer and not one per backdrop? Each bracket adds three render passes -(downsample + H-blur + V-composite) and at least three tile-cache flushes on tilers like Mali -Valhall. Strict submission-order semantics would require one bracket per cluster of contiguous -backdrops, which scales the GPU cost linearly with how interleaved the user's submission happens -to be — a footgun. The current design caps the bracket cost per layer regardless of submission -interleave, and gives the user explicit control over ordering through the existing layer -abstraction. This matches the cost/complexity envelope of iOS `UIVisualEffectView` and CSS -`backdrop-filter` (both of which constrain backdrop ordering implicitly). +Each bracket adds four render passes (downsample + H-blur + V-blur + composite) plus tile-cache +flushes on tilers like Mali Valhall, so users who don't need interleaving should group backdrops +into a single scope to amortize: + +``` +{ + draw.backdrop_scope(base) + draw.backdrop_blur(base, panelA, sigma=12) // shares one bracket with panelB; + draw.backdrop_blur(base, panelB, sigma=12) // same sigma also coalesces into one +} // instanced composite draw call +``` + +#### Clay integration: `Backdrop_Marker` + +Clay has no notion of backdrops. The integration uses Clay's only extension point — the opaque +`customData: rawptr` on `clay.CustomElementConfig` — to carry a magic-number-tagged struct that +`prepare_clay_batch` recognizes: + +``` +Backdrop_Marker :: struct { + magic: u32, // BACKDROP_MARKER_MAGIC (0x42445054, 'BDPT') + sigma: f32, + tint: Color, + radii: Rectangle_Radii, + feather_px: f32, +} +``` + +The user populates a `Backdrop_Marker` (with stable lifetime through the `prepare_clay_batch` +call) and points the corresponding `clay.CustomElementConfig.customData` at it. +`prepare_clay_batch` walks Clay's command stream once, calling `is_clay_backdrop` per command +(a u32 magic check on `customData`'s first 4 bytes). On a hit it opens a backdrop scope (or +extends an open one) and dispatches via `backdrop_blur`. Non-backdrop commands issued during an +open scope go to a deferred index buffer for replay after the scope closes; this preserves Clay's +painter's-algorithm ordering across backdrops without violating the scope contract. + +The magic-number sentinel keeps the marker type self-describing in core dumps and decouples the +integration from Clay-side changes. Zero-init memory has `magic = 0`, so a marker with a forgotten +magic field gets routed through the regular `custom_draw` path and surfaces as "my custom draw +never fired" rather than as a silent backdrop schedule. ### Vertex layout diff --git a/draw/backdrop.odin b/draw/backdrop.odin index f7437db..cb97933 100644 --- a/draw/backdrop.odin +++ b/draw/backdrop.odin @@ -392,16 +392,36 @@ frame_has_backdrop :: proc() -> bool { return false } -// Returns the absolute index of the first .Backdrop sub-batch in the layer's sub-batch range, -// or -1 if the layer has no backdrops. The index is into GLOB.tmp_sub_batches (not relative to -// layer.sub_batch_start), to match how draw_layer's render-range helpers consume it. +// Find the scissor that owns a given sub-batch index by linear scan over GLOB.scissors. +// Used by `run_backdrop_bracket`'s composite pass when the bracket loses its layer-pointer +// context: per-sub-batch scissor lookup is required to honor scissors set up upstream by +// `prepare_clay_batch`'s ScissorStart handling. O(scissors) per sub-batch is acceptable +// because scissor counts are small (single digits in typical UI frames). +// +// Panics if no scissor owns the index. The renderer's invariant is that the scissor list +// forms a contiguous, disjoint cover over `[0, len(tmp_sub_batches))` because every +// sub-batch is created via `append_or_extend_sub_batch` (which increments the active +// scissor's `sub_batch_len` in lockstep with the global array's growth) and scissors are +// only created at the current end-of-array. A miss here means that invariant is broken — +// either by a future code change that bypasses `append_or_extend_sub_batch`, by a scissor +// constructed with the wrong `sub_batch_start`, or by external corruption — and silent +// degradation would mask the bug. The panic message includes the offending index and the +// scissor list shape so the failure is locatable. //INTERNAL -find_first_backdrop_in_layer :: proc(layer: ^Layer) -> int { - for i in 0 ..< layer.sub_batch_len { - abs_idx := layer.sub_batch_start + i - if GLOB.tmp_sub_batches[abs_idx].kind == .Backdrop do return int(abs_idx) +find_scissor_for_sub_batch :: proc(sub_batch_index: u32) -> sdl.Rect { + for scissor in GLOB.scissors { + if sub_batch_index >= scissor.sub_batch_start && + sub_batch_index < scissor.sub_batch_start + scissor.sub_batch_len { + return scissor.bounds + } } - return -1 + log.panicf( + "find_scissor_for_sub_batch: no scissor owns sub-batch index %d (scissor count=%d, total sub-batches=%d); " + + "the scissor list must form a contiguous cover over all sub-batches", + sub_batch_index, + len(GLOB.scissors), + len(GLOB.tmp_sub_batches), + ) } // --------------------------------------------------------------------------------------------------------------------- @@ -754,14 +774,16 @@ compute_backdrop_group_work_region :: proc( return } -// Run the backdrop bracket for one layer. Assumes: -// - source_texture currently holds the pre-bracket frame contents (Pass A has already -// rendered everything that should appear behind the backdrop). +// Run one bracket over a contiguous range of pure-backdrop sub-batches. Assumes: +// - source_texture currently holds the pre-bracket frame contents (everything submitted +// ahead of this bracket on the same layer has already been rendered). // - The caller has invoked ensure_backdrop_textures with current swapchain dimensions. -// - At least one .Backdrop sub-batch exists in the layer (caller checked). +// - The half-open range `[sub_batch_start, sub_batch_end)` is non-empty and every +// sub-batch in it has kind == .Backdrop. The caller (draw_layer) guarantees this by +// splitting the layer into runs. // -// Per-sigma-group execution. The bracket walks the layer's sub-batches in submission order, -// grouping contiguous-same-sigma .Backdrop sub-batches. For each group: +// Per-sigma-group execution. The bracket walks the range in submission order, grouping +// contiguous-same-sigma .Backdrop sub-batches. For each group: // 1. Pick a downsample factor using compute_backdrop_downsample_factor. // 2. Compute that group's work region (primitives' AABB + 6σ halo, clamped). // 3. Downsample: source_texture → downsample_texture, viewport-limited to @@ -770,21 +792,22 @@ compute_backdrop_group_work_region :: proc( // 5. V-blur (mode 0, direction=V): h_blur_texture → downsample_texture (ping-pong reuse; // downsample_texture's data is no longer needed). Same viewport. // 6. Composite (mode 1): downsample_texture (now holds H+V blur) → source_texture, full- -// target viewport, per-primitive SDF discard handles masking and applies the tint. Each -// sub-batch in the group is one instanced draw. +// target viewport, per-primitive SDF discard handles masking and applies the tint. +// Each sub-batch in the group issues an instanced draw under its own scissor (sub- +// batches inherit scissor state from the surrounding ScissorStart/End at submission). // // V-blur is run as its own working→working pass rather than folded into the composite. The // folded variant produces a horizontal-vs-vertical asymmetry artifact (horizontal source // features end up looking sharper than vertical ones inside the panel). Matching V's // structure exactly to H's restores symmetry. // -// On exit, source_texture contains the pre-bracket contents plus all backdrop primitives -// composited on top. The caller then runs Pass B (post-bracket non-backdrop sub-batches) on -// source_texture with LOAD. +// On exit, source_texture contains the pre-bracket contents plus all backdrop primitives in +// this range composited on top. //INTERNAL run_backdrop_bracket :: proc( cmd_buffer: ^sdl.GPUCommandBuffer, - layer: ^Layer, + sub_batch_start: u32, + sub_batch_end: u32, swapchain_width, swapchain_height: u32, ) { pipeline := &GLOB.backdrop @@ -797,32 +820,23 @@ run_backdrop_bracket :: proc( min_depth = 0, max_depth = 1, } - full_scissor := sdl.Rect { - x = 0, - y = 0, - w = i32(swapchain_width), - h = i32(swapchain_height), - } // Working textures are at full swapchain resolution. Each per-group factor=N pass writes // only to a sub-rect of dimensions (work_region_phys / N), via viewport-limited rendering. - layer_end := layer.sub_batch_start + layer.sub_batch_len - i := layer.sub_batch_start + layer_end := sub_batch_end + i := sub_batch_start for i < layer_end { + // Caller guarantees this range is pure backdrop sub-batches. + assert(GLOB.tmp_sub_batches[i].kind == .Backdrop, "non-backdrop sub-batch inside bracket range") batch := GLOB.tmp_sub_batches[i] - if batch.kind != .Backdrop { - i += 1 - continue - } // Find the contiguous run of .Backdrop sub-batches with this sigma. sigma := batch.gaussian_sigma group_start := i group_end := i + 1 for group_end < layer_end { - next := GLOB.tmp_sub_batches[group_end] - if next.kind != .Backdrop || next.gaussian_sigma != sigma do break + if GLOB.tmp_sub_batches[group_end].gaussian_sigma != sigma do break group_end += 1 } @@ -997,6 +1011,10 @@ run_backdrop_bracket :: proc( // upsamples (via bilinear filtering on the read), applies the SDF mask, and applies the // tint. One render pass for the whole sigma group; each sub-batch issues its own draw // call because non-contiguous-but-same-sigma sub-batches couldn't coalesce upstream. + // + // Per-sub-batch scissor: sub-batches inherit scissor state from ScissorStart/End that + // surrounded their submission. Switching scissors mid-pass is cheap; what matters is + // that the composite respects the same clipping the caller set up. { frag_uniforms.mode = 1 // direction is unused in mode 1 but keep it set so reading the uniform doesn't see @@ -1011,7 +1029,6 @@ run_backdrop_bracket :: proc( ) sdl.BindGPUGraphicsPipeline(pass, pipeline.blur_pipeline) sdl.SetGPUViewport(pass, full_viewport) - sdl.SetGPUScissor(pass, full_scissor) push_backdrop_vert_globals(cmd_buffer, f32(swapchain_width), f32(swapchain_height), 1) push_backdrop_blur_frag_globals(cmd_buffer, &frag_uniforms) sdl.BindGPUVertexStorageBuffers(pass, 0, ([^]^sdl.GPUBuffer)(&pipeline.primitive_buffer.gpu), 1) @@ -1021,8 +1038,17 @@ run_backdrop_bracket :: proc( &sdl.GPUTextureSamplerBinding{texture = pipeline.downsample_texture, sampler = pipeline.sampler}, 1, ) + + current_scissor: sdl.Rect = {0, 0, 0, 0} + scissor_set := false for j in group_start ..< group_end { grp := GLOB.tmp_sub_batches[j] + sub_batch_scissor := find_scissor_for_sub_batch(j) + if !scissor_set || sub_batch_scissor != current_scissor { + sdl.SetGPUScissor(pass, sub_batch_scissor) + current_scissor = sub_batch_scissor + scissor_set = true + } sdl.DrawGPUPrimitives(pass, 6, grp.count, 0, grp.offset) } sdl.EndGPURenderPass(pass) @@ -1129,10 +1155,8 @@ prepare_backdrop_primitive :: proc(layer: ^Layer, prim: Gaussian_Blur_Primitive, // pass pair via sub-batch coalescing. Primitives with different sigmas in the same layer // trigger separate blur passes (cost scales with the number of unique sigmas). // -// Submission ordering is asymmetric: a non-backdrop draw submitted between two backdrops in -// the same layer renders *on top of* both backdrops, not between them. Use `draw.new_layer` -// to interleave. See README.md § "Backdrop pipeline" for the full bracket scheduling model. -gaussian_blur :: proc( +// Must be called inside a `begin_backdrop` / `end_backdrop` scope (or use `backdrop_scope`). +backdrop_blur :: proc( layer: ^Layer, rect: Rectangle, gaussian_sigma: f32, diff --git a/draw/core_2d.odin b/draw/core_2d.odin index b441911..6f9b8ae 100644 --- a/draw/core_2d.odin +++ b/draw/core_2d.odin @@ -598,6 +598,15 @@ upload :: proc(device: ^sdl.GPUDevice, pass: ^sdl.GPUCopyPass) { //----- Layer dispatch ---------------------------------- +// Walk the layer's sub-batches, alternating between non-backdrop runs (rendered to +// `render_texture` via `render_layer_sub_batch_range`) and backdrop runs (each closed by a +// `begin_backdrop`/`end_backdrop` scope at submission time, dispatched here as one bracket +// per run via `run_backdrop_bracket`). +// +// Multiple brackets per layer are allowed: each `begin_backdrop`/`end_backdrop` pair maps to +// one contiguous .Backdrop run in the sub-batch list, and non-backdrop draws between scopes +// render in their submission position relative to the brackets. This is the explicit-scope +// model that replaces the legacy single-bracket-per-layer scheduler. //INTERNAL draw_layer :: proc( device: ^sdl.GPUDevice, @@ -628,53 +637,44 @@ draw_layer :: proc( return } - bracket_start_abs := find_first_backdrop_in_layer(layer) - layer_end_abs := int(layer.sub_batch_start + layer.sub_batch_len) + // Walk sub-batches, alternating non-backdrop runs (rendered to render_texture) and + // backdrop runs (dispatched to run_backdrop_bracket). Each backdrop run corresponds to a + // single user-visible begin_backdrop/end_backdrop scope at submission time. + layer_start := int(layer.sub_batch_start) + layer_end := layer_start + int(layer.sub_batch_len) + i := layer_start + for i < layer_end { + // Find next non-backdrop run [run_start, run_end). + run_start := i + for i < layer_end && GLOB.tmp_sub_batches[i].kind != .Backdrop do i += 1 + run_end := i + if run_end > run_start { + render_layer_sub_batch_range( + cmd_buffer, + render_texture, + swapchain_width, + swapchain_height, + clear_color, + layer, + run_start, + run_end, + ) + } - if bracket_start_abs < 0 { - // Fast path: no backdrop in this layer; render the whole sub-batch range in one pass. - render_layer_sub_batch_range( - cmd_buffer, - render_texture, - swapchain_width, - swapchain_height, - clear_color, - layer, - int(layer.sub_batch_start), - layer_end_abs, - ) - return + // Find next backdrop run [backdrop_scope_start, backdrop_scope_end). Each run = one bracket. + backdrop_scope_start := i + for i < layer_end && GLOB.tmp_sub_batches[i].kind == .Backdrop do i += 1 + backdrop_scope_end := i + if backdrop_scope_end > backdrop_scope_start { + run_backdrop_bracket( + cmd_buffer, + u32(backdrop_scope_start), + u32(backdrop_scope_end), + swapchain_width, + swapchain_height, + ) + } } - - // Bracketed layer: Pass A → backdrop bracket → Pass B. - // See README.md § "Backdrop pipeline" for the full ordering semantics. - render_layer_sub_batch_range( - cmd_buffer, - render_texture, - swapchain_width, - swapchain_height, - clear_color, - layer, - int(layer.sub_batch_start), - bracket_start_abs, - ) - - run_backdrop_bracket(cmd_buffer, layer, swapchain_width, swapchain_height) - - // Pass B: render the [bracket_start_abs, layer_end_abs) range. .Backdrop sub-batches in - // this range are dispatched by the bracket above and ignored here (the .Backdrop case in - // the inner switch is a no-op). LOAD is implied because Pass A or the bracket's V- - // composite has already touched render_texture. - render_layer_sub_batch_range( - cmd_buffer, - render_texture, - swapchain_width, - swapchain_height, - clear_color, - layer, - bracket_start_abs, - layer_end_abs, - ) } // Render a sub-range of a layer's sub-batches in a single render pass. Iterates the layer's @@ -682,8 +682,10 @@ draw_layer :: proc( // and `range_end_abs` parameters are absolute indices into GLOB.tmp_sub_batches; only sub- // batches within `[range_start_abs, range_end_abs)` are drawn. // -// .Backdrop sub-batches in the range are always silently skipped — they are dispatched by -// run_backdrop_bracket, not here. The empty .Backdrop case in the inner switch enforces this. +// The caller (`draw_layer`) splits the layer into pure-kind runs before calling this proc, +// so the range MUST NOT contain any .Backdrop sub-batches; backdrop dispatch is handled by +// `run_backdrop_bracket`. The .Backdrop case in the inner switch is `unreachable()` to +// surface contract violations as fast as possible. // // Render-pass setup mirrors the original draw_layer: clear-or-load based on GLOB.cleared, // pipeline + storage + index buffer bound up front, then per-batch state tracking. After this @@ -780,7 +782,7 @@ render_layer_sub_batch_range :: proc( for abs_idx in effective_start ..< effective_end { batch := &GLOB.tmp_sub_batches[abs_idx] - switch batch.kind { + #partial switch batch.kind { case .Tessellated: if current_mode != .Tessellated { push_globals(cmd_buffer, width, height, .Tessellated) @@ -869,10 +871,7 @@ render_layer_sub_batch_range :: proc( } sdl.DrawGPUPrimitives(render_pass, 6, batch.count, 0, batch.offset) - case .Backdrop: - // Always a no-op here. Backdrop sub-batches are dispatched by run_backdrop_bracket; - // when this proc encounters one (only possible in Pass B, since Pass A and the no- - // backdrop fast path both stop their range before any .Backdrop index), we skip it. + case: log.panicf("Non core2d batch kind (%v) reached in core2d dispatch path.", batch.kind) } } } @@ -894,21 +893,11 @@ prepare_shape :: proc(layer: ^Layer, vertices: []Vertex_2D) { append_or_extend_sub_batch(scissor, layer, .Tessellated, offset, u32(len(vertices))) } -// Submit an SDF primitive to the given layer for rendering. Requires the caller to build a -// Core_2D_Primitive directly, which is the internal GPU-layout struct. -//INTERNAL -prepare_sdf_primitive :: proc(layer: ^Layer, prim: Core_2D_Primitive) { - offset := u32(len(GLOB.tmp_primitives)) - append(&GLOB.tmp_primitives, prim) - scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] - append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1) -} - // Submit an SDF primitive with optional texture binding. // The texture-aware counterpart of `prepare_sdf_primitive`; lets shape procs route a // texture_id and sampler into the sub-batch without growing the public API. //INTERNAL -prepare_sdf_primitive_ex :: proc( +prepare_sdf_primitive :: proc( layer: ^Layer, prim: Core_2D_Primitive, texture_id: Texture_Id = INVALID_TEXTURE, @@ -1409,7 +1398,7 @@ apply_brush_and_outline :: proc( } prim.flags = pack_kind_flags(kind, flags) - prepare_sdf_primitive_ex(layer, prim^, texture_id, sampler) + prepare_sdf_primitive(layer, prim^, texture_id, sampler) } // --------------------------------------------------------------------------------------------------------------------- diff --git a/draw/draw.odin b/draw/draw.odin index 1011ab9..8634f28 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -88,6 +88,10 @@ Global :: struct { clay_z_index: i16, // Tracks z-index for layer splitting during Clay batch processing. cleared: bool, // Whether the render target has been cleared this frame. + // Per-frame: which layer (if any) currently has an open begin_backdrop scope. + // Reset to nil at frame start. end() panics if non-nil at frame end. + open_backdrop_layer: ^Layer, + // -- Subsystems (accessed every draw_layer call) -- core_2d: Core_2D, // The unified 2D GPU pipeline (shaders, buffers, samplers). backdrop: Backdrop, // Frosted-glass backdrop blur subsystem (downsample + blur PSOs, working textures). @@ -423,6 +427,7 @@ clear_global :: proc() { GLOB.curr_layer_index = 0 GLOB.clay_z_index = 0 GLOB.cleared = false + GLOB.open_backdrop_layer = nil // Destroy uncached TTF_Text objects from the previous frame (after end() has submitted draw data) for ttf_text in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(ttf_text) clear(&GLOB.tmp_uncached_text) @@ -467,6 +472,10 @@ begin :: proc(bounds: Rectangle) -> ^Layer { // Creates a new layer new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer { + if GLOB.open_backdrop_layer != nil { + log.panicf("new_layer called while backdrop scope is open on layer %p", GLOB.open_backdrop_layer) + } + layer := Layer { bounds = bounds, sub_batch_start = prev_layer.sub_batch_start + prev_layer.sub_batch_len, @@ -490,6 +499,46 @@ new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer { return &GLOB.layers[GLOB.curr_layer_index] } +// Open a backdrop scope on `layer`. All subsequent draws on `layer` until the matching +// `end_backdrop` must be backdrop primitives (currently only `backdrop_blur`). Non-backdrop +// draws inside a scope, or backdrop draws outside one, panic. +// +// Bracket scheduling: each scope produces one bracket at render time. Within the scope, +// per-sigma sub-batch coalescing still applies (two contiguous backdrop_blur calls with +// the same sigma share an instanced composite draw and a single H+V blur pass pair). +// +// Multiple begin/end pairs per layer are allowed: each pair is its own bracket, and +// non-backdrop draws between pairs render in their submission position relative to the +// brackets. Use this for layered frost effects. +begin_backdrop :: proc(layer: ^Layer) { + if GLOB.open_backdrop_layer != nil { + log.panicf("begin_backdrop called while a scope is already open on layer %p", GLOB.open_backdrop_layer) + } + GLOB.open_backdrop_layer = layer +} + +// Close the backdrop scope opened by `begin_backdrop`. Must be called on the same layer that +// the scope was opened on; the layer pointer mismatch is a hard error rather than a silent +// recovery to surface integration bugs early. +end_backdrop :: proc(layer: ^Layer) { + if GLOB.open_backdrop_layer != layer { + log.panicf("end_backdrop on wrong layer (open=%p, ended=%p)", GLOB.open_backdrop_layer, layer) + } + GLOB.open_backdrop_layer = nil +} + +// Convenience wrapper for the common case of a backdrop scope tied to a block. Use with +// defer-style block scoping: +// +// { +// draw.backdrop_scope(layer) +// draw.backdrop_blur(layer, ...) +// } // end_backdrop fires automatically +@(deferred_in = end_backdrop) +backdrop_scope :: #force_inline proc(layer: ^Layer) { + begin_backdrop(layer) +} + // Render primitives. clear_color is the background fill before any layers are drawn. end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = DFT_CLEAR_COLOR) { cmd_buffer := sdl.AcquireGPUCommandBuffer(device) @@ -497,6 +546,13 @@ end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = DF log.panicf("Failed to acquire GPU command buffer: %s", sdl.GetError()) } + if GLOB.open_backdrop_layer != nil { + log.panicf( + "end() called with open backdrop scope on layer %p; missing end_backdrop", + GLOB.open_backdrop_layer, + ) + } + // Pre-scan: if any layer this frame has a backdrop sub-batch, route the entire frame to // source_texture so the bracket can sample the pre-bracket framebuffer without a mid- // frame texture copy. Frames without any backdrop hit the existing fast path and never @@ -591,6 +647,15 @@ append_or_extend_sub_batch :: proc( sampler: Sampler_Preset = DFT_SAMPLER, gaussian_sigma: f32 = 0, ) { + // Scope contract: backdrops only inside a scope, non-backdrops only outside. + in_scope := GLOB.open_backdrop_layer == layer + if kind == .Backdrop && !in_scope { + log.panic("backdrop draw outside begin_backdrop / end_backdrop scope") + } + if kind != .Backdrop && in_scope { + log.panicf("non-backdrop draw of kind %v inside backdrop scope on layer %p", kind, layer) + } + if scissor.sub_batch_len > 0 { last := &GLOB.tmp_sub_batches[scissor.sub_batch_start + scissor.sub_batch_len - 1] if last.kind == kind && @@ -664,7 +729,232 @@ ClayBatch :: struct { cmds: clay.ClayArray(clay.RenderCommand), } -// Process Clay render commands into shape and text primitives. +// Magic-number-tagged struct that user app data points at via Clay's customData field. +// `prepare_clay_batch` recognizes these and routes them through a backdrop scope automatically. +// The user populates a `Backdrop_Marker`, points `clay.CustomElementConfig.customData` at it, +// and the integration walks the command stream, opening/closing scopes around contiguous +// backdrop runs. Magic-number sentinel chosen over a separate userData flag so the marker +// type stays self-describing in core dumps and in any non-Odin debugger view of the heap. +Backdrop_Marker :: struct { + magic: u32, + sigma: f32, + tint: Color, + radii: Rectangle_Radii, + feather_px: f32, +} + +// 'BDPT' in big-endian ASCII. Picked for greppability and to be obviously non-zero in +// uninitialized memory; user code that forgets to set the magic field gets routed through +// the regular custom_draw path and surfaces as "my custom draw never fired," not as a +// silent backdrop schedule. +BACKDROP_MARKER_MAGIC :: u32(0x42445054) + +// Returns true if this Clay render command represents a backdrop primitive. +// Identified by a magic-number sentinel in the first 4 bytes of customData. +is_clay_backdrop :: proc(cmd: ^clay.RenderCommand) -> bool { + if cmd.commandType != .Custom do return false + p := cmd.renderData.custom.customData + if p == nil do return false + return (^Backdrop_Marker)(p).magic == BACKDROP_MARKER_MAGIC +} + +// Dispatch a single non-backdrop Clay render command to the appropriate `draw` primitive. +// Extracted from the main `prepare_clay_batch` walk so that the deferred-buffer flush path +// can replay commands accumulated during an open backdrop scope without duplicating the +// per-command lowering code. +//INTERNAL +dispatch_clay_command :: proc( + layer: ^Layer, + render_command: ^clay.RenderCommand, + custom_draw: Custom_Draw, + temp_allocator: runtime.Allocator, +) { + // Translate bounding box of the primitive by the layer position + bounds := Rectangle { + x = render_command.boundingBox.x + layer.bounds.x, + y = render_command.boundingBox.y + layer.bounds.y, + width = render_command.boundingBox.width, + height = render_command.boundingBox.height, + } + + switch render_command.commandType { + case clay.RenderCommandType.None: + log.errorf( + "Received render command with type None. This generally means we're in some kind of fucked up state.", + ) + case clay.RenderCommandType.Text: + render_data := render_command.renderData.text + txt := string(render_data.stringContents.chars[:render_data.stringContents.length]) + c_text := strings.clone_to_cstring(txt, temp_allocator) + defer delete(c_text, temp_allocator) + // Clay render-command IDs are derived via Clay's internal HashNumber (Jenkins-family) + // and namespaced with .Clay so they can never collide with user-provided custom text IDs. + sdl_text := cache_get_or_update( + Cache_Key{render_command.id, .Clay}, + c_text, + get_font(render_data.fontId, render_data.fontSize), + ) + prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)}) + case clay.RenderCommandType.Image: + // Any texture + render_data := render_command.renderData.image + if render_data.imageData == nil do return + img_data := (^Clay_Image_Data)(render_data.imageData)^ + cr := render_data.cornerRadius + radii := Rectangle_Radii { + top_left = cr.topLeft, + top_right = cr.topRight, + bottom_right = cr.bottomRight, + bottom_left = cr.bottomLeft, + } + + // Background color behind the image (Clay allows it) + bg := color_from_clay(render_data.backgroundColor) + if bg.a > 0 { + rectangle(layer, bounds, bg, radii = radii) + } + + // Compute fit UVs + uv, sampler, inner := fit_params(img_data.fit, bounds, img_data.texture_id) + + // Draw the image + rectangle( + layer, + inner, + Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv, sampler = sampler}, + radii = radii, + ) + case clay.RenderCommandType.ScissorStart: + if bounds.width == 0 || bounds.height == 0 do return + + curr_scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + + if curr_scissor.sub_batch_len != 0 { + // Scissor has some content, need to make a new scissor + new := Scissor { + sub_batch_start = curr_scissor.sub_batch_start + curr_scissor.sub_batch_len, + bounds = sdl.Rect { + c.int(bounds.x * GLOB.dpi_scaling), + c.int(bounds.y * GLOB.dpi_scaling), + c.int(bounds.width * GLOB.dpi_scaling), + c.int(bounds.height * GLOB.dpi_scaling), + }, + } + append(&GLOB.scissors, new) + layer.scissor_len += 1 + } else { + curr_scissor.bounds = sdl.Rect { + c.int(bounds.x * GLOB.dpi_scaling), + c.int(bounds.y * GLOB.dpi_scaling), + c.int(bounds.width * GLOB.dpi_scaling), + c.int(bounds.height * GLOB.dpi_scaling), + } + } + case clay.RenderCommandType.ScissorEnd: + case clay.RenderCommandType.Rectangle: + render_data := render_command.renderData.rectangle + cr := render_data.cornerRadius + color := color_from_clay(render_data.backgroundColor) + radii := Rectangle_Radii { + top_left = cr.topLeft, + top_right = cr.topRight, + bottom_right = cr.bottomRight, + bottom_left = cr.bottomLeft, + } + + rectangle(layer, bounds, color, radii = radii) + case clay.RenderCommandType.Border: + render_data := render_command.renderData.border + cr := render_data.cornerRadius + color := color_from_clay(render_data.color) + thickness := f32(render_data.width.top) + radii := Rectangle_Radii { + top_left = cr.topLeft, + top_right = cr.topRight, + bottom_right = cr.bottomRight, + bottom_left = cr.bottomLeft, + } + + rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii) + case clay.RenderCommandType.Custom: if is_clay_backdrop(render_command) { + // The walker pre-filters backdrops into `dispatch_clay_backdrop` and never feeds + // them here; reaching this branch means either the walker logic is broken or the + // `customData` pointee mutated between the walker's `is_clay_backdrop` check and + // this re-check (heap corruption / lifetime bug in user-managed customData + // memory). Both are renderer-level bugs that warrant a hard failure rather than a + // silently-dropped panel. + log.panicf( + "backdrop marker reached dispatch_clay_command; either the prepare_clay_batch walker is misrouting commands or the customData pointee at %p was mutated mid-frame", + render_command.renderData.custom.customData, + ) + } else if custom_draw != nil { + custom_draw(layer, bounds, render_command.renderData.custom) + } else { + log.panicf("Received clay render command of type custom but no custom_draw proc provided.") + } + } +} + +// Dispatch a single backdrop Clay render command to `backdrop_blur` on the active layer. +// Caller guarantees a backdrop scope is open on `layer` so the underlying +// `append_or_extend_sub_batch` contract assertion is satisfied. +//INTERNAL +dispatch_clay_backdrop :: proc(layer: ^Layer, cmd: ^clay.RenderCommand) { + bounds := Rectangle { + x = cmd.boundingBox.x + layer.bounds.x, + y = cmd.boundingBox.y + layer.bounds.y, + width = cmd.boundingBox.width, + height = cmd.boundingBox.height, + } + marker := (^Backdrop_Marker)(cmd.renderData.custom.customData) + backdrop_blur( + layer, + bounds, + gaussian_sigma = marker.sigma, + tint = marker.tint, + radii = marker.radii, + feather_px = marker.feather_px, + ) +} + +// Close the in-flight backdrop scope (if open) and replay every command accumulated in the +// deferred index buffer. Ordering: end_backdrop first so deferred non-backdrop draws land +// at submission position relative to the bracket they followed (the bracket is now closed, +// so these draws render after it). Used at every zIndex transition and at end of stream. +//INTERNAL +flush_deferred_and_close_backdrop_scope :: proc( + layer: ^Layer, + batch: ^ClayBatch, + deferred_indices: ^[dynamic]i32, + backdrop_scope_open: ^bool, + custom_draw: Custom_Draw, + temp_allocator: runtime.Allocator, +) { + if backdrop_scope_open^ { + end_backdrop(layer) + backdrop_scope_open^ = false + } + for index in deferred_indices^ { + cmd := clay.RenderCommandArray_Get(&batch.cmds, index) + dispatch_clay_command(layer, cmd, custom_draw, temp_allocator) + } + clear(deferred_indices) +} + +// Process Clay render commands into shape, text, and backdrop primitives. +// +// Single-walk dispatcher with a deferred buffer. The walk does three things per command: +// 1. zIndex transitions: close the in-flight scope, flush any deferred non-backdrop +// commands into the current layer, then open a new layer seeded with `base_layer.bounds` +// (NOT the bumping element's bounds — Clay's floating elements with `clipTo = .None` +// should not be over-clipped, and `clipTo = .AttachedParent` floating elements get a +// Clay-emitted ScissorStart immediately afterward that narrows correctly). +// 2. Backdrop commands: open a scope on first encounter (extending it on subsequent ones), +// then dispatch the backdrop_blur call. +// 3. Non-backdrop commands during an open scope: append to the deferred buffer for replay +// after the scope closes. The buffer holds command indices, not pointers, so it stays +// valid even if the underlying ClayArray reallocates. +// At end of stream, flush whatever remains. prepare_clay_batch :: proc( base_layer: ^Layer, batch: ^ClayBatch, @@ -684,134 +974,56 @@ prepare_clay_batch :: proc( clay.UpdateScrollContainers(true, mouse_wheel_delta, frame_time) layer := base_layer + command_count := int(batch.cmds.length) + deferred_indices := make([dynamic]i32, 0, 16, temp_allocator) + backdrop_scope_open := false + // Seed from GLOB.clay_z_index so multi-batch frames preserve the original semantics: a + // later call to `prepare_clay_batch` doesn't re-trigger layer splits for zIndex values + // the previous batch already saw. + previous_z_index := GLOB.clay_z_index - // Parse render commands - for i in 0 ..< int(batch.cmds.length) { - render_command := clay.RenderCommandArray_Get(&batch.cmds, cast(i32)i) + for i in 0 ..< command_count { + cmd := clay.RenderCommandArray_Get(&batch.cmds, i32(i)) - // Translate bounding box of the primitive by the layer position - bounds := Rectangle { - x = render_command.boundingBox.x + layer.bounds.x, - y = render_command.boundingBox.y + layer.bounds.y, - width = render_command.boundingBox.width, - height = render_command.boundingBox.height, - } - - if render_command.zIndex > GLOB.clay_z_index { - log.debug("Higher zIndex found, creating new layer & setting z_index to", render_command.zIndex) - layer = new_layer(layer, bounds) - // Update bounds to new layer offset - bounds.x = render_command.boundingBox.x + layer.bounds.x - bounds.y = render_command.boundingBox.y + layer.bounds.y - GLOB.clay_z_index = render_command.zIndex - } - - switch (render_command.commandType) { - case clay.RenderCommandType.None: - log.errorf( - "Received render command with type None. This generally means we're in some kind of fucked up state.", - ) - case clay.RenderCommandType.Text: - render_data := render_command.renderData.text - txt := string(render_data.stringContents.chars[:render_data.stringContents.length]) - c_text := strings.clone_to_cstring(txt, temp_allocator) - defer delete(c_text, temp_allocator) - // Clay render-command IDs are derived via Clay's internal HashNumber (Jenkins-family) - // and namespaced with .Clay so they can never collide with user-provided custom text IDs. - sdl_text := cache_get_or_update( - Cache_Key{render_command.id, .Clay}, - c_text, - get_font(render_data.fontId, render_data.fontSize), - ) - prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)}) - case clay.RenderCommandType.Image: - // Any texture - render_data := render_command.renderData.image - if render_data.imageData == nil do continue - img_data := (^Clay_Image_Data)(render_data.imageData)^ - cr := render_data.cornerRadius - radii := Rectangle_Radii { - top_left = cr.topLeft, - top_right = cr.topRight, - bottom_right = cr.bottomRight, - bottom_left = cr.bottomLeft, - } - - // Background color behind the image (Clay allows it) - bg := color_from_clay(render_data.backgroundColor) - if bg.a > 0 { - rectangle(layer, bounds, bg, radii = radii) - } - - // Compute fit UVs - uv, sampler, inner := fit_params(img_data.fit, bounds, img_data.texture_id) - - // Draw the image - rectangle( + // zIndex transition: close out current stratum, create new layer, continue. + if cmd.zIndex > previous_z_index { + log.debug("Higher zIndex found, creating new layer & setting z_index to", cmd.zIndex) + flush_deferred_and_close_backdrop_scope( layer, - inner, - Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv, sampler = sampler}, - radii = radii, + batch, + &deferred_indices, + &backdrop_scope_open, + custom_draw, + temp_allocator, ) - case clay.RenderCommandType.ScissorStart: - if bounds.width == 0 || bounds.height == 0 do continue + layer = new_layer(layer, base_layer.bounds) + previous_z_index = cmd.zIndex + // Keep GLOB.clay_z_index in sync for any external readers (debug tooling, etc.). + GLOB.clay_z_index = cmd.zIndex + } - curr_scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] - - if curr_scissor.sub_batch_len != 0 { - // Scissor has some content, need to make a new scissor - new := Scissor { - sub_batch_start = curr_scissor.sub_batch_start + curr_scissor.sub_batch_len, - bounds = sdl.Rect { - c.int(bounds.x * GLOB.dpi_scaling), - c.int(bounds.y * GLOB.dpi_scaling), - c.int(bounds.width * GLOB.dpi_scaling), - c.int(bounds.height * GLOB.dpi_scaling), - }, - } - append(&GLOB.scissors, new) - layer.scissor_len += 1 - } else { - curr_scissor.bounds = sdl.Rect { - c.int(bounds.x * GLOB.dpi_scaling), - c.int(bounds.y * GLOB.dpi_scaling), - c.int(bounds.width * GLOB.dpi_scaling), - c.int(bounds.height * GLOB.dpi_scaling), - } + if is_clay_backdrop(cmd) { + if !backdrop_scope_open { + begin_backdrop(layer) + backdrop_scope_open = true } - case clay.RenderCommandType.ScissorEnd: - case clay.RenderCommandType.Rectangle: - render_data := render_command.renderData.rectangle - cr := render_data.cornerRadius - color := color_from_clay(render_data.backgroundColor) - radii := Rectangle_Radii { - top_left = cr.topLeft, - top_right = cr.topRight, - bottom_right = cr.bottomRight, - bottom_left = cr.bottomLeft, - } - - rectangle(layer, bounds, color, radii = radii) - case clay.RenderCommandType.Border: - render_data := render_command.renderData.border - cr := render_data.cornerRadius - color := color_from_clay(render_data.color) - thickness := f32(render_data.width.top) - radii := Rectangle_Radii { - top_left = cr.topLeft, - top_right = cr.topRight, - bottom_right = cr.bottomRight, - bottom_left = cr.bottomLeft, - } - - rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii) - case clay.RenderCommandType.Custom: if custom_draw != nil { - custom_draw(layer, bounds, render_command.renderData.custom) - } else { - log.error("Received clay render command of type custom but no custom_draw proc provided.") - } + dispatch_clay_backdrop(layer, cmd) + } else if backdrop_scope_open { + append(&deferred_indices, i32(i)) + } else { + dispatch_clay_command(layer, cmd, custom_draw, temp_allocator) } } + + // End-of-stream: flush whatever remains. + flush_deferred_and_close_backdrop_scope( + layer, + batch, + &deferred_indices, + &backdrop_scope_open, + custom_draw, + temp_allocator, + ) } // --------------------------------------------------------------------------------------------------------------------- diff --git a/draw/examples/backdrop.odin b/draw/examples/backdrop.odin index 2d3a6fb..3b45725 100644 --- a/draw/examples/backdrop.odin +++ b/draw/examples/backdrop.odin @@ -97,13 +97,32 @@ gaussian_blur :: proc() { // Both panels share rounded corners. panel_radii := draw.Rectangle_Radii{16, 16, 16, 16} - draw.gaussian_blur( - base_layer, - {60, 80, 320, 140}, - gaussian_sigma = 30, - tint = draw.Color{170, 200, 240, 200}, // cool blue, strong mix - radii = panel_radii, - ) + // Both zone1 panels share one scope. Different sigmas still trigger separate blur + // passes (cost scales with unique sigmas, not with backdrop count); the scope just + // declares "these draws form one bracket." `backdrop_scope` is the RAII-style API: + // `end_backdrop` fires automatically when the block exits. + { + draw.backdrop_scope(base_layer) + draw.backdrop_blur( + base_layer, + {60, 80, 320, 140}, + gaussian_sigma = 30, + tint = draw.Color{170, 200, 240, 200}, // cool blue, strong mix + radii = panel_radii, + ) + + // Panel B: lighter blur, warm amber tint. sigma=6. + draw.backdrop_blur( + base_layer, + {420, 80, 320, 140}, + gaussian_sigma = 6, + tint = draw.Color{255, 220, 160, 200}, // warm amber, strong mix + radii = panel_radii, + ) + } + + // Text labels for the two panels. Drawn AFTER `end_backdrop` (which fires at the + // scope-block exit above), so they composite on top of both panels. draw.text( base_layer, "sigma = 20, cool tint", @@ -112,15 +131,6 @@ gaussian_blur :: proc() { FONT_SIZE, color = draw.Color{30, 35, 50, 255}, ) - - // Panel B: lighter blur, warm amber tint. sigma=6. - draw.gaussian_blur( - base_layer, - {420, 80, 320, 140}, - gaussian_sigma = 6, - tint = draw.Color{255, 220, 160, 200}, // warm amber, strong mix - radii = panel_radii, - ) draw.text( base_layer, "sigma = 6, warm tint", @@ -130,10 +140,10 @@ gaussian_blur :: proc() { color = draw.Color{60, 40, 20, 255}, ) - // Pass-B verification: a rectangle drawn AFTER the backdrops in the same layer - // Per the bracket scheduling model, this should render ON TOP of both panels above. - // If you see this stripe behind the panels instead of in front, something is wrong with - // the Pass B post-bracket path. + // Post-bracket verification: a white stripe drawn AFTER `end_backdrop` in the same + // layer. Should render ON TOP of both panels because the backdrop scope (and its + // composite output) is now closed; any non-backdrop draw on this layer composites + // with LOAD on top of whatever the bracket left in source_texture. draw.rectangle(base_layer, {WINDOW_W * 0.5 - 4, 70, 8, 160}, draw.Color{255, 255, 255, 230}) //----- Zone 2: second layer with its own backdrop -------------------------------- @@ -151,14 +161,18 @@ gaussian_blur :: proc() { stripe_y := 320 + (math.sin(t * 0.05) * 0.5 + 0.5) * 200 draw.rectangle(zone2, {30, stripe_y, WINDOW_W * 0.55 - 60, 18}, draw.Color{255, 100, 200, 200}) - // Zone 2's frosted panel. - draw.gaussian_blur( - zone2, - {60, 360, WINDOW_W * 0.55 - 120, 160}, - gaussian_sigma = 10, - tint = draw.WHITE, // pure blur (white tint with any alpha is a no-op) - radii = draw.Rectangle_Radii{24, 24, 24, 24}, - ) + // Zone 2's frosted panel. Single-panel scope; `backdrop_scope` keeps the begin/end + // pair tied to the block. + { + draw.backdrop_scope(zone2) + draw.backdrop_blur( + zone2, + {60, 360, WINDOW_W * 0.55 - 120, 160}, + gaussian_sigma = 10, + tint = draw.WHITE, // pure blur (white tint with any alpha is a no-op) + radii = draw.Rectangle_Radii{24, 24, 24, 24}, + ) + } draw.text( zone2, "Layer 2 backdrop", @@ -197,15 +211,44 @@ gaussian_blur :: proc() { ) } - // Edge case 1: sigma = 0 "mirror" — sharp framebuffer sample, no blur. Should reproduce - // the underlying pixels exactly through the SDF mask. Tinted slightly so it's visible. - draw.gaussian_blur( - zone3, - {WINDOW_W * 0.55 + 30, 310, 150, 70}, - gaussian_sigma = 0, - tint = draw.WHITE, // pure mirror (no blur, no tint) - radii = draw.Rectangle_Radii{12, 12, 12, 12}, - ) + // All three Zone 3 backdrops share one scope. The sigma=0 mirror, then the two + // contiguous sigma=8 panels. The sigma=8 pair stays contiguous in the sub-batch list, + // so `append_or_extend_sub_batch` still coalesces them into a single instanced + // composite draw — scope boundaries don't affect coalescing, only kind/sigma identity. + { + draw.backdrop_scope(zone3) + + // Edge case 1: sigma = 0 "mirror" — sharp framebuffer sample, no blur. Should reproduce + // the underlying pixels exactly through the SDF mask. Tinted slightly so it's visible. + draw.backdrop_blur( + zone3, + {WINDOW_W * 0.55 + 30, 310, 150, 70}, + gaussian_sigma = 0, + tint = draw.WHITE, // pure mirror (no blur, no tint) + radii = draw.Rectangle_Radii{12, 12, 12, 12}, + ) + + // Edge case 2: two same-sigma panels submitted contiguously. The sub-batch coalescer + // should merge these into a single instanced V-composite draw. Visually, both should + // look identical (modulo position) — same blur radius, same tint. + draw.backdrop_blur( + zone3, + {WINDOW_W * 0.55 + 30, 400, 150, 70}, + gaussian_sigma = 8, + tint = draw.Color{160, 255, 160, 200}, // green tint, strong mix + radii = draw.Rectangle_Radii{12, 12, 12, 12}, + ) + draw.backdrop_blur( + zone3, + {WINDOW_W * 0.55 + 200, 400, 150, 70}, + gaussian_sigma = 8, + tint = draw.Color{160, 255, 160, 200}, // identical: tests sub-batch coalescing + radii = draw.Rectangle_Radii{12, 12, 12, 12}, + ) + } + + // Edge case 3: text drawn AFTER `end_backdrop` in the same layer. Composites on top of + // the bracket's V-composite output and should appear sharply over the green panels. draw.text( zone3, "sigma=0 (mirror)", @@ -214,24 +257,6 @@ gaussian_blur :: proc() { FONT_SIZE, color = draw.Color{20, 20, 20, 255}, ) - - // Edge case 2: two same-sigma panels submitted contiguously. The sub-batch coalescer - // should merge these into a single instanced V-composite draw. Visually, both should - // look identical (modulo position) — same blur radius, same tint. - draw.gaussian_blur( - zone3, - {WINDOW_W * 0.55 + 30, 400, 150, 70}, - gaussian_sigma = 8, - tint = draw.Color{160, 255, 160, 200}, // green tint, strong mix - radii = draw.Rectangle_Radii{12, 12, 12, 12}, - ) - draw.gaussian_blur( - zone3, - {WINDOW_W * 0.55 + 200, 400, 150, 70}, - gaussian_sigma = 8, - tint = draw.Color{160, 255, 160, 200}, // identical: tests sub-batch coalescing - radii = draw.Rectangle_Radii{12, 12, 12, 12}, - ) draw.text( zone3, "sigma=8 (coalesced pair)", @@ -240,12 +265,9 @@ gaussian_blur :: proc() { FONT_SIZE, color = draw.Color{20, 40, 20, 255}, ) - - // Edge case 3: text drawn AFTER a backdrop in the same layer. Tests Pass B over a fresh - // V-composite output. The text should appear sharply on top of the green panels above. draw.text( zone3, - "Pass B text overlay", + "Post-scope text overlay", {WINDOW_W * 0.55 + 38, 480}, PLEX_SANS_REGULAR, FONT_SIZE, @@ -343,18 +365,23 @@ gaussian_blur_debug :: proc() { // THE PANEL UNDER TEST. Square, centered, large enough to cover multiple grid cells and // the circle row. Square shape makes any horizontal-vs-vertical asymmetry purely // renderer-driven (geometry can't introduce it). + // + // Uses the explicit begin/end form (instead of `backdrop_scope`) to exercise the + // alternative API surface in the diagnostic harness. panel := draw.Rectangle{250, 150, 300, 300} - draw.gaussian_blur( + draw.begin_backdrop(base_layer) + draw.backdrop_blur( base_layer, panel, gaussian_sigma = sigma, tint = draw.WHITE, radii = draw.Rectangle_Radii{20, 20, 20, 20}, ) + draw.end_backdrop(base_layer) - // Pass B test: a bright rectangle drawn AFTER the backdrop in the same layer. Should - // always render on top of the panel. If the panel ever shows a "ghost" of this rect - // inside its blur, the V-composite is sampling the wrong texture state. + // Post-scope test: a bright rectangle drawn AFTER `end_backdrop` in the same layer. + // Should always render on top of the panel. If the panel ever shows a "ghost" of this + // rect inside its blur, the V-composite is sampling the wrong texture state. if show_test_rect { draw.rectangle(base_layer, {380, 280, 40, 40}, draw.Color{0, 200, 0, 255}) } -- 2.43.0