Compare commits

..

2 Commits

Author SHA1 Message Date
zack e8ffa28de3 Backdrop scope implementation (#25)
Co-authored-by: Zachary Levy <zachary@sunforge.is>
Reviewed-on: #25
2026-05-02 01:31:58 +00:00
zack 5317b8f142 Backdrop Path + Cybersteel (#23)
Co-authored-by: Zachary Levy <zachary@sunforge.is>
Reviewed-on: #23
2026-05-01 05:43:10 +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 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, 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. `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, This means each bracket has no mid-frame texture copy: by the time a bracket runs,
`source_texture` already contains the pre-bracket frame contents and is the natural sampler `source_texture` already contains the contents written by everything that preceded it on the
input. When no layer in the frame has a backdrop draw, the existing fast path runs: the frame timeline and is the natural sampler input. When no layer in the frame has a backdrop draw,
renders directly to the swapchain and the backdrop pipeline's working textures are never the existing fast path runs: the frame renders directly to the swapchain and the backdrop
touched. Zero cost for backdrop-free frames. 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 **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. 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 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 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 Backdrop draws are scheduled via **explicit scopes**: every call to `backdrop_blur` must be wrapped
submission order. Concretely, a layer with one or more backdrops splits into three groups: 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`. At render time, `draw_layer` walks the layer's sub-batch list once, alternating between two run
Renders to `source_texture` in a single render pass. kinds:
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.
`bracket_start_index` is the absolute index of the first `.Backdrop` kind in the layer's sub-batch - **Non-backdrop runs** are rendered to `source_texture` in one render pass via
range. If the layer has no backdrops, none of this kicks in and the layer renders in a single render `render_layer_sub_batch_range`. Clear-vs-load is tracked frame-globally via `GLOB.cleared`.
pass via the existing fast path. - **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 Within a bracket, the scheduler groups contiguous same-sigma sub-batches and runs four sub-passes
`.Backdrop` sub-batches that share a sigma; each group picks its own downsample factor (1, 2, or 4) per group: downsample (`source_texture``downsample_texture`), H-blur (`downsample_texture`
based on `compute_backdrop_downsample_factor`. For each group it runs four sub-passes: a downsample `h_blur_texture`), V-blur (`h_blur_texture``downsample_texture`, ping-pong reuse), and
from `source_texture` to `downsample_texture`; an H-blur from `downsample_texture` to composite (`downsample_texture``source_texture` with SDF mask and tint applied). Each group
`h_blur_texture`; a V-blur from `h_blur_texture` back into `downsample_texture` (ping-pong reuse); picks its own downsample factor (1, 2, or 4) based on sigma; see the comment block at the top of
and finally a composite that reads the fully-blurred `downsample_texture`, applies the SDF mask `backdrop.odin` for the factor-selection table.
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.
The working textures are sized at the full swapchain resolution; larger downsample factors only Sub-batch coalescing in `append_or_extend_sub_batch` merges contiguous same-sigma backdrops
fill a sub-rect via viewport-limited rendering (see the comment block at the top of `backdrop.odin` sharing one scissor into a single instanced composite draw. Same-sigma backdrops separated by a
for the factor-selection table and rationale). `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 #### Scope contract
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 state is global: `GLOB.open_backdrop_layer` tracks the currently-open scope (or `nil`) for
draw.rectangle(layer, bg, GRAY) // 0 Tessellated → Pass A the whole renderer. The five misuse cases panic via `log.panic` / `log.panicf`:
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)
```
In this layer, panelB does _not_ see card_red — even though card_red was submitted before panelB — 1. `backdrop_blur` called outside an open scope.
because both backdrops sample `source_texture` as it stood at the bracket entry, which is after 2. A non-backdrop draw call issued on a layer with an open scope. Asserted at the top of
Pass A and before card_red has rendered. card_red ends up on top of panelA, not underneath it. `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 Worked example with two scopes on the same layer:
new layer (via `draw.new_layer`) gives panelB a fresh source snapshot that includes panelA and
card_red:
``` ```
base := draw.begin(...) base := draw.begin(...)
draw.rectangle(base, bg, GRAY) draw.rectangle(base, bg, GRAY)
draw.rectangle(base, card_blue, BLUE) 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.backdrop_scope(base)
draw.gaussian_blur(top, panelB, sigma=12) // top layer's bracket; sees base + card_red draw.backdrop_blur(base, panelA, sigma=12) // bracket 1: sees bg + blue card
draw.text(top, "label", ...) }
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 Each bracket adds four render passes (downsample + H-blur + V-blur + composite) plus tile-cache
(downsample + H-blur + V-composite) and at least three tile-cache flushes on tilers like Mali flushes on tilers like Mali Valhall, so users who don't need interleaving should group backdrops
Valhall. Strict submission-order semantics would require one bracket per cluster of contiguous into a single scope to amortize:
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 draw.backdrop_scope(base)
`backdrop-filter` (both of which constrain backdrop ordering implicitly). 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 ### Vertex layout
+63 -39
View File
@@ -392,16 +392,36 @@ frame_has_backdrop :: proc() -> bool {
return false return false
} }
// Returns the absolute index of the first .Backdrop sub-batch in the layer's sub-batch range, // Find the scissor that owns a given sub-batch index by linear scan over GLOB.scissors.
// or -1 if the layer has no backdrops. The index is into GLOB.tmp_sub_batches (not relative to // Used by `run_backdrop_bracket`'s composite pass when the bracket loses its layer-pointer
// layer.sub_batch_start), to match how draw_layer's render-range helpers consume it. // 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 //INTERNAL
find_first_backdrop_in_layer :: proc(layer: ^Layer) -> int { find_scissor_for_sub_batch :: proc(sub_batch_index: u32) -> sdl.Rect {
for i in 0 ..< layer.sub_batch_len { for scissor in GLOB.scissors {
abs_idx := layer.sub_batch_start + i if sub_batch_index >= scissor.sub_batch_start &&
if GLOB.tmp_sub_batches[abs_idx].kind == .Backdrop do return int(abs_idx) 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 return
} }
// Run the backdrop bracket for one layer. Assumes: // Run one bracket over a contiguous range of pure-backdrop sub-batches. Assumes:
// - source_texture currently holds the pre-bracket frame contents (Pass A has already // - source_texture currently holds the pre-bracket frame contents (everything submitted
// rendered everything that should appear behind the backdrop). // ahead of this bracket on the same layer has already been rendered).
// - The caller has invoked ensure_backdrop_textures with current swapchain dimensions. // - 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, // Per-sigma-group execution. The bracket walks the range in submission order, grouping
// grouping contiguous-same-sigma .Backdrop sub-batches. For each group: // contiguous-same-sigma .Backdrop sub-batches. For each group:
// 1. Pick a downsample factor using compute_backdrop_downsample_factor. // 1. Pick a downsample factor using compute_backdrop_downsample_factor.
// 2. Compute that group's work region (primitives' AABB + 6σ halo, clamped). // 2. Compute that group's work region (primitives' AABB + 6σ halo, clamped).
// 3. Downsample: source_texture → downsample_texture, viewport-limited to // 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; // 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. // downsample_texture's data is no longer needed). Same viewport.
// 6. Composite (mode 1): downsample_texture (now holds H+V blur) → source_texture, full- // 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 // target viewport, per-primitive SDF discard handles masking and applies the tint.
// sub-batch in the group is one instanced draw. // 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 // 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 // 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 // features end up looking sharper than vertical ones inside the panel). Matching V's
// structure exactly to H's restores symmetry. // structure exactly to H's restores symmetry.
// //
// On exit, source_texture contains the pre-bracket contents plus all backdrop primitives // On exit, source_texture contains the pre-bracket contents plus all backdrop primitives in
// composited on top. The caller then runs Pass B (post-bracket non-backdrop sub-batches) on // this range composited on top.
// source_texture with LOAD.
//INTERNAL //INTERNAL
run_backdrop_bracket :: proc( run_backdrop_bracket :: proc(
cmd_buffer: ^sdl.GPUCommandBuffer, cmd_buffer: ^sdl.GPUCommandBuffer,
layer: ^Layer, sub_batch_start: u32,
sub_batch_end: u32,
swapchain_width, swapchain_height: u32, swapchain_width, swapchain_height: u32,
) { ) {
pipeline := &GLOB.backdrop pipeline := &GLOB.backdrop
@@ -797,32 +820,23 @@ run_backdrop_bracket :: proc(
min_depth = 0, min_depth = 0,
max_depth = 1, 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 // 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. // 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 layer_end := sub_batch_end
i := layer.sub_batch_start i := sub_batch_start
for i < layer_end { 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] batch := GLOB.tmp_sub_batches[i]
if batch.kind != .Backdrop {
i += 1
continue
}
// Find the contiguous run of .Backdrop sub-batches with this sigma. // Find the contiguous run of .Backdrop sub-batches with this sigma.
sigma := batch.gaussian_sigma sigma := batch.gaussian_sigma
group_start := i group_start := i
group_end := i + 1 group_end := i + 1
for group_end < layer_end { for group_end < layer_end {
next := GLOB.tmp_sub_batches[group_end] if GLOB.tmp_sub_batches[group_end].gaussian_sigma != sigma do break
if next.kind != .Backdrop || next.gaussian_sigma != sigma do break
group_end += 1 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 // 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 // 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. // 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 frag_uniforms.mode = 1
// direction is unused in mode 1 but keep it set so reading the uniform doesn't see // 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.BindGPUGraphicsPipeline(pass, pipeline.blur_pipeline)
sdl.SetGPUViewport(pass, full_viewport) 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_vert_globals(cmd_buffer, f32(swapchain_width), f32(swapchain_height), 1)
push_backdrop_blur_frag_globals(cmd_buffer, &frag_uniforms) push_backdrop_blur_frag_globals(cmd_buffer, &frag_uniforms)
sdl.BindGPUVertexStorageBuffers(pass, 0, ([^]^sdl.GPUBuffer)(&pipeline.primitive_buffer.gpu), 1) 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}, &sdl.GPUTextureSamplerBinding{texture = pipeline.downsample_texture, sampler = pipeline.sampler},
1, 1,
) )
current_scissor: sdl.Rect = {0, 0, 0, 0}
scissor_set := false
for j in group_start ..< group_end { for j in group_start ..< group_end {
grp := GLOB.tmp_sub_batches[j] 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.DrawGPUPrimitives(pass, 6, grp.count, 0, grp.offset)
} }
sdl.EndGPURenderPass(pass) 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 // 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). // 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 // Must be called inside a `begin_backdrop` / `end_backdrop` scope (or use `backdrop_scope`).
// the same layer renders *on top of* both backdrops, not between them. Use `draw.new_layer` backdrop_blur :: proc(
// to interleave. See README.md § "Backdrop pipeline" for the full bracket scheduling model.
gaussian_blur :: proc(
layer: ^Layer, layer: ^Layer,
rect: Rectangle, rect: Rectangle,
gaussian_sigma: f32, gaussian_sigma: f32,
+53 -64
View File
@@ -598,6 +598,15 @@ upload :: proc(device: ^sdl.GPUDevice, pass: ^sdl.GPUCopyPass) {
//----- Layer dispatch ---------------------------------- //----- 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 //INTERNAL
draw_layer :: proc( draw_layer :: proc(
device: ^sdl.GPUDevice, device: ^sdl.GPUDevice,
@@ -628,53 +637,44 @@ draw_layer :: proc(
return return
} }
bracket_start_abs := find_first_backdrop_in_layer(layer) // Walk sub-batches, alternating non-backdrop runs (rendered to render_texture) and
layer_end_abs := int(layer.sub_batch_start + layer.sub_batch_len) // 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 { // Find next backdrop run [backdrop_scope_start, backdrop_scope_end). Each run = one bracket.
// Fast path: no backdrop in this layer; render the whole sub-batch range in one pass. backdrop_scope_start := i
render_layer_sub_batch_range( for i < layer_end && GLOB.tmp_sub_batches[i].kind == .Backdrop do i += 1
cmd_buffer, backdrop_scope_end := i
render_texture, if backdrop_scope_end > backdrop_scope_start {
swapchain_width, run_backdrop_bracket(
swapchain_height, cmd_buffer,
clear_color, u32(backdrop_scope_start),
layer, u32(backdrop_scope_end),
int(layer.sub_batch_start), swapchain_width,
layer_end_abs, swapchain_height,
) )
return }
} }
// 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 // 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- // 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. // batches within `[range_start_abs, range_end_abs)` are drawn.
// //
// .Backdrop sub-batches in the range are always silently skipped — they are dispatched by // The caller (`draw_layer`) splits the layer into pure-kind runs before calling this proc,
// run_backdrop_bracket, not here. The empty .Backdrop case in the inner switch enforces this. // 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, // 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 // 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 { for abs_idx in effective_start ..< effective_end {
batch := &GLOB.tmp_sub_batches[abs_idx] batch := &GLOB.tmp_sub_batches[abs_idx]
switch batch.kind { #partial switch batch.kind {
case .Tessellated: case .Tessellated:
if current_mode != .Tessellated { if current_mode != .Tessellated {
push_globals(cmd_buffer, width, height, .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) sdl.DrawGPUPrimitives(render_pass, 6, batch.count, 0, batch.offset)
case .Backdrop: case: log.panicf("Non core2d batch kind (%v) reached in core2d dispatch path.", batch.kind)
// 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.
} }
} }
} }
@@ -894,21 +893,11 @@ prepare_shape :: proc(layer: ^Layer, vertices: []Vertex_2D) {
append_or_extend_sub_batch(scissor, layer, .Tessellated, offset, u32(len(vertices))) 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. // Submit an SDF primitive with optional texture binding.
// The texture-aware counterpart of `prepare_sdf_primitive`; lets shape procs route a // 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. // texture_id and sampler into the sub-batch without growing the public API.
//INTERNAL //INTERNAL
prepare_sdf_primitive_ex :: proc( prepare_sdf_primitive :: proc(
layer: ^Layer, layer: ^Layer,
prim: Core_2D_Primitive, prim: Core_2D_Primitive,
texture_id: Texture_Id = INVALID_TEXTURE, texture_id: Texture_Id = INVALID_TEXTURE,
@@ -1409,7 +1398,7 @@ apply_brush_and_outline :: proc(
} }
prim.flags = pack_kind_flags(kind, flags) prim.flags = pack_kind_flags(kind, flags)
prepare_sdf_primitive_ex(layer, prim^, texture_id, sampler) prepare_sdf_primitive(layer, prim^, texture_id, sampler)
} }
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
+333 -121
View File
@@ -88,6 +88,10 @@ Global :: struct {
clay_z_index: i16, // Tracks z-index for layer splitting during Clay batch processing. 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. 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) -- // -- Subsystems (accessed every draw_layer call) --
core_2d: Core_2D, // The unified 2D GPU pipeline (shaders, buffers, samplers). core_2d: Core_2D, // The unified 2D GPU pipeline (shaders, buffers, samplers).
backdrop: Backdrop, // Frosted-glass backdrop blur subsystem (downsample + blur PSOs, working textures). 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.curr_layer_index = 0
GLOB.clay_z_index = 0 GLOB.clay_z_index = 0
GLOB.cleared = false GLOB.cleared = false
GLOB.open_backdrop_layer = nil
// Destroy uncached TTF_Text objects from the previous frame (after end() has submitted draw data) // 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) for ttf_text in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(ttf_text)
clear(&GLOB.tmp_uncached_text) clear(&GLOB.tmp_uncached_text)
@@ -467,6 +472,10 @@ begin :: proc(bounds: Rectangle) -> ^Layer {
// Creates a new layer // Creates a new layer
new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^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 { layer := Layer {
bounds = bounds, bounds = bounds,
sub_batch_start = prev_layer.sub_batch_start + prev_layer.sub_batch_len, 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] 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. // 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) { end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = DFT_CLEAR_COLOR) {
cmd_buffer := sdl.AcquireGPUCommandBuffer(device) 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()) 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 // 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- // 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 // 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, sampler: Sampler_Preset = DFT_SAMPLER,
gaussian_sigma: f32 = 0, 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 { if scissor.sub_batch_len > 0 {
last := &GLOB.tmp_sub_batches[scissor.sub_batch_start + scissor.sub_batch_len - 1] last := &GLOB.tmp_sub_batches[scissor.sub_batch_start + scissor.sub_batch_len - 1]
if last.kind == kind && if last.kind == kind &&
@@ -664,7 +729,232 @@ ClayBatch :: struct {
cmds: clay.ClayArray(clay.RenderCommand), 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( prepare_clay_batch :: proc(
base_layer: ^Layer, base_layer: ^Layer,
batch: ^ClayBatch, batch: ^ClayBatch,
@@ -684,134 +974,56 @@ prepare_clay_batch :: proc(
clay.UpdateScrollContainers(true, mouse_wheel_delta, frame_time) clay.UpdateScrollContainers(true, mouse_wheel_delta, frame_time)
layer := base_layer 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 ..< command_count {
for i in 0 ..< int(batch.cmds.length) { cmd := clay.RenderCommandArray_Get(&batch.cmds, i32(i))
render_command := clay.RenderCommandArray_Get(&batch.cmds, cast(i32)i)
// Translate bounding box of the primitive by the layer position // zIndex transition: close out current stratum, create new layer, continue.
bounds := Rectangle { if cmd.zIndex > previous_z_index {
x = render_command.boundingBox.x + layer.bounds.x, log.debug("Higher zIndex found, creating new layer & setting z_index to", cmd.zIndex)
y = render_command.boundingBox.y + layer.bounds.y, flush_deferred_and_close_backdrop_scope(
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(
layer, layer,
inner, batch,
Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv, sampler = sampler}, &deferred_indices,
radii = radii, &backdrop_scope_open,
custom_draw,
temp_allocator,
) )
case clay.RenderCommandType.ScissorStart: layer = new_layer(layer, base_layer.bounds)
if bounds.width == 0 || bounds.height == 0 do continue 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 is_clay_backdrop(cmd) {
if !backdrop_scope_open {
if curr_scissor.sub_batch_len != 0 { begin_backdrop(layer)
// Scissor has some content, need to make a new scissor backdrop_scope_open = true
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: dispatch_clay_backdrop(layer, cmd)
case clay.RenderCommandType.Rectangle: } else if backdrop_scope_open {
render_data := render_command.renderData.rectangle append(&deferred_indices, i32(i))
cr := render_data.cornerRadius } else {
color := color_from_clay(render_data.backgroundColor) dispatch_clay_command(layer, cmd, custom_draw, temp_allocator)
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.")
}
} }
} }
// End-of-stream: flush whatever remains.
flush_deferred_and_close_backdrop_scope(
layer,
batch,
&deferred_indices,
&backdrop_scope_open,
custom_draw,
temp_allocator,
)
} }
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
+90 -63
View File
@@ -97,13 +97,32 @@ gaussian_blur :: proc() {
// Both panels share rounded corners. // Both panels share rounded corners.
panel_radii := draw.Rectangle_Radii{16, 16, 16, 16} panel_radii := draw.Rectangle_Radii{16, 16, 16, 16}
draw.gaussian_blur( // Both zone1 panels share one scope. Different sigmas still trigger separate blur
base_layer, // passes (cost scales with unique sigmas, not with backdrop count); the scope just
{60, 80, 320, 140}, // declares "these draws form one bracket." `backdrop_scope` is the RAII-style API:
gaussian_sigma = 30, // `end_backdrop` fires automatically when the block exits.
tint = draw.Color{170, 200, 240, 200}, // cool blue, strong mix {
radii = panel_radii, 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( draw.text(
base_layer, base_layer,
"sigma = 20, cool tint", "sigma = 20, cool tint",
@@ -112,15 +131,6 @@ gaussian_blur :: proc() {
FONT_SIZE, FONT_SIZE,
color = draw.Color{30, 35, 50, 255}, 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( draw.text(
base_layer, base_layer,
"sigma = 6, warm tint", "sigma = 6, warm tint",
@@ -130,10 +140,10 @@ gaussian_blur :: proc() {
color = draw.Color{60, 40, 20, 255}, color = draw.Color{60, 40, 20, 255},
) )
// Pass-B verification: a rectangle drawn AFTER the backdrops in the same layer // Post-bracket verification: a white stripe drawn AFTER `end_backdrop` in the same
// Per the bracket scheduling model, this should render ON TOP of both panels above. // layer. Should render ON TOP of both panels because the backdrop scope (and its
// If you see this stripe behind the panels instead of in front, something is wrong with // composite output) is now closed; any non-backdrop draw on this layer composites
// the Pass B post-bracket path. // 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}) 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 -------------------------------- //----- 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 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}) draw.rectangle(zone2, {30, stripe_y, WINDOW_W * 0.55 - 60, 18}, draw.Color{255, 100, 200, 200})
// Zone 2's frosted panel. // Zone 2's frosted panel. Single-panel scope; `backdrop_scope` keeps the begin/end
draw.gaussian_blur( // pair tied to the block.
zone2, {
{60, 360, WINDOW_W * 0.55 - 120, 160}, draw.backdrop_scope(zone2)
gaussian_sigma = 10, draw.backdrop_blur(
tint = draw.WHITE, // pure blur (white tint with any alpha is a no-op) zone2,
radii = draw.Rectangle_Radii{24, 24, 24, 24}, {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( draw.text(
zone2, zone2,
"Layer 2 backdrop", "Layer 2 backdrop",
@@ -197,15 +211,44 @@ gaussian_blur :: proc() {
) )
} }
// Edge case 1: sigma = 0 "mirror" — sharp framebuffer sample, no blur. Should reproduce // All three Zone 3 backdrops share one scope. The sigma=0 mirror, then the two
// the underlying pixels exactly through the SDF mask. Tinted slightly so it's visible. // contiguous sigma=8 panels. The sigma=8 pair stays contiguous in the sub-batch list,
draw.gaussian_blur( // so `append_or_extend_sub_batch` still coalesces them into a single instanced
zone3, // composite draw — scope boundaries don't affect coalescing, only kind/sigma identity.
{WINDOW_W * 0.55 + 30, 310, 150, 70}, {
gaussian_sigma = 0, draw.backdrop_scope(zone3)
tint = draw.WHITE, // pure mirror (no blur, no tint)
radii = draw.Rectangle_Radii{12, 12, 12, 12}, // 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( draw.text(
zone3, zone3,
"sigma=0 (mirror)", "sigma=0 (mirror)",
@@ -214,24 +257,6 @@ gaussian_blur :: proc() {
FONT_SIZE, FONT_SIZE,
color = draw.Color{20, 20, 20, 255}, 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( draw.text(
zone3, zone3,
"sigma=8 (coalesced pair)", "sigma=8 (coalesced pair)",
@@ -240,12 +265,9 @@ gaussian_blur :: proc() {
FONT_SIZE, FONT_SIZE,
color = draw.Color{20, 40, 20, 255}, 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( draw.text(
zone3, zone3,
"Pass B text overlay", "Post-scope text overlay",
{WINDOW_W * 0.55 + 38, 480}, {WINDOW_W * 0.55 + 38, 480},
PLEX_SANS_REGULAR, PLEX_SANS_REGULAR,
FONT_SIZE, 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 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 // the circle row. Square shape makes any horizontal-vs-vertical asymmetry purely
// renderer-driven (geometry can't introduce it). // 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} panel := draw.Rectangle{250, 150, 300, 300}
draw.gaussian_blur( draw.begin_backdrop(base_layer)
draw.backdrop_blur(
base_layer, base_layer,
panel, panel,
gaussian_sigma = sigma, gaussian_sigma = sigma,
tint = draw.WHITE, tint = draw.WHITE,
radii = draw.Rectangle_Radii{20, 20, 20, 20}, 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 // Post-scope test: a bright rectangle drawn AFTER `end_backdrop` in the same layer.
// always render on top of the panel. If the panel ever shows a "ghost" of this rect // Should always render on top of the panel. If the panel ever shows a "ghost" of this
// inside its blur, the V-composite is sampling the wrong texture state. // rect inside its blur, the V-composite is sampling the wrong texture state.
if show_test_rect { if show_test_rect {
draw.rectangle(base_layer, {380, 280, 40, 40}, draw.Color{0, 200, 0, 255}) draw.rectangle(base_layer, {380, 280, 40, 40}, draw.Color{0, 200, 0, 255})
} }