Backdrop scope implementation #25

Merged
zack merged 1 commits from draw-scopes into master 2026-05-02 01:31:58 +00:00
5 changed files with 633 additions and 349 deletions
+94 -62
View File
@@ -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
+63 -39
View File
@@ -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,
+41 -52
View File
@@ -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,11 +637,18 @@ 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)
if bracket_start_abs < 0 {
// Fast path: no backdrop in this layer; render the whole sub-batch range in one pass.
// 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,
@@ -640,50 +656,36 @@ draw_layer :: proc(
swapchain_height,
clear_color,
layer,
int(layer.sub_batch_start),
layer_end_abs,
run_start,
run_end,
)
return
}
// Bracketed layer: Pass A → backdrop bracket → Pass B.
// See README.md § "Backdrop pipeline" for the full ordering semantics.
render_layer_sub_batch_range(
// 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,
render_texture,
u32(backdrop_scope_start),
u32(backdrop_scope_end),
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
// scissors and walks each scissor's sub-batches, dispatching by kind. The `range_start_abs`
// 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)
}
// ---------------------------------------------------------------------------------------------------------------------
+250 -38
View File
@@ -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,31 +729,46 @@ ClayBatch :: struct {
cmds: clay.ClayArray(clay.RenderCommand),
}
// Process Clay render commands into shape and text primitives.
prepare_clay_batch :: proc(
base_layer: ^Layer,
batch: ^ClayBatch,
mouse_wheel_delta: [2]f32,
frame_time: f32 = 0,
custom_draw: Custom_Draw = nil,
temp_allocator := context.temp_allocator,
// 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,
) {
mouse_pos: [2]f32
mouse_flags := sdl.GetMouseState(&mouse_pos.x, &mouse_pos.y)
// Update clay internals
clay.SetPointerState(
clay.Vector2{mouse_pos.x - base_layer.bounds.x, mouse_pos.y - base_layer.bounds.y},
.LEFT in mouse_flags,
)
clay.UpdateScrollContainers(true, mouse_wheel_delta, frame_time)
layer := base_layer
// Parse render commands
for i in 0 ..< int(batch.cmds.length) {
render_command := clay.RenderCommandArray_Get(&batch.cmds, cast(i32)i)
// Translate bounding box of the primitive by the layer position
bounds := Rectangle {
x = render_command.boundingBox.x + layer.bounds.x,
@@ -697,16 +777,7 @@ prepare_clay_batch :: proc(
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) {
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.",
@@ -727,7 +798,7 @@ prepare_clay_batch :: proc(
case clay.RenderCommandType.Image:
// Any texture
render_data := render_command.renderData.image
if render_data.imageData == nil do continue
if render_data.imageData == nil do return
img_data := (^Clay_Image_Data)(render_data.imageData)^
cr := render_data.cornerRadius
radii := Rectangle_Radii {
@@ -754,7 +825,7 @@ prepare_clay_batch :: proc(
radii = radii,
)
case clay.RenderCommandType.ScissorStart:
if bounds.width == 0 || bounds.height == 0 do continue
if bounds.width == 0 || bounds.height == 0 do return
curr_scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
@@ -805,13 +876,154 @@ prepare_clay_batch :: proc(
}
rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii)
case clay.RenderCommandType.Custom: if custom_draw != nil {
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.error("Received clay render command of type custom but no custom_draw proc provided.")
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,
mouse_wheel_delta: [2]f32,
frame_time: f32 = 0,
custom_draw: Custom_Draw = nil,
temp_allocator := context.temp_allocator,
) {
mouse_pos: [2]f32
mouse_flags := sdl.GetMouseState(&mouse_pos.x, &mouse_pos.y)
// Update clay internals
clay.SetPointerState(
clay.Vector2{mouse_pos.x - base_layer.bounds.x, mouse_pos.y - base_layer.bounds.y},
.LEFT in mouse_flags,
)
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
for i in 0 ..< command_count {
cmd := clay.RenderCommandArray_Get(&batch.cmds, i32(i))
// 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,
batch,
&deferred_indices,
&backdrop_scope_open,
custom_draw,
temp_allocator,
)
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
}
if is_clay_backdrop(cmd) {
if !backdrop_scope_open {
begin_backdrop(layer)
backdrop_scope_open = true
}
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,
)
}
// ---------------------------------------------------------------------------------------------------------------------
+70 -43
View File
@@ -97,13 +97,32 @@ gaussian_blur :: proc() {
// Both panels share rounded corners.
panel_radii := draw.Rectangle_Radii{16, 16, 16, 16}
draw.gaussian_blur(
// 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(
// 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() {
)
}
// 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.gaussian_blur(
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})
}