1755 lines
72 KiB
Odin
1755 lines
72 KiB
Odin
// Rendering library built on SDL3 GPU.
|
||
//
|
||
// ----- Coordinate system -----
|
||
// Origin is the top-left corner of the window/layer. X increases rightward, Y increases
|
||
// downward. This matches SDL, HTML Canvas, and most 2D UI coordinate conventions. All
|
||
// public position parameters (`center`, `origin`, `start_position`, `end_position`, every
|
||
// `Vec2`-typed field, every `Rectangle.x/y`, etc.) live in this coordinate system.
|
||
//
|
||
// ----- Unit-suffix convention -----
|
||
// Public CPU-side dimensions are in *logical* pixels by default (CSS-style: a value of 200
|
||
// looks the same physical size on a 1× monitor and a 2× Retina display). Suffix rules:
|
||
//
|
||
// no suffix — logical pixels. Default for layout values (positions, sizes, radii,
|
||
// outline widths, line thicknesses, gradient endpoints, etc.).
|
||
// `_lpx` — logical pixels, *explicit*. Optional. Use when an identifier would
|
||
// otherwise be ambiguous about which kind of pixel it carries —
|
||
// typically standalone constants like `SCANLINE_STRIPE_LPX` where the
|
||
// context doesn't make the unit obvious from the surrounding code.
|
||
// Procedure parameters and struct fields named after a layout property
|
||
// (`width`, `radius`, ...) don't need this suffix.
|
||
// `_ppx` — physical (device) pixels. Required whenever a value is in physical
|
||
// pixels, regardless of context. Reserved for quantities whose
|
||
// right-feeling magnitude is a property of the device pixel grid rather
|
||
// than of the layout: anti-aliasing band widths, sub-pixel snap targets,
|
||
// MSDF screen-pixel-range parameters.
|
||
//
|
||
// Examples:
|
||
//
|
||
// width, height, radius, outline_width, thickness — logical px (no suffix)
|
||
// SCANLINE_STRIPE_LPX, SCANLINE_GAP_LPX — logical px (explicit `_lpx`)
|
||
// feather_ppx, aa_ppx — physical px (`_ppx`)
|
||
//
|
||
// Layout values scale with DPI; rasterization-grid values do not. The shader handles the
|
||
// logical-to-physical conversion at the rasterization boundary; CPU-side `_ppx` inputs that
|
||
// need to interact with logical-space data convert via `/ dpi_scaling` at the use site.
|
||
//
|
||
// ----- Anti-aliasing -----
|
||
// MSAA is intentionally NOT supported. SDF text and shapes compute fragment coverage
|
||
// analytically via `smoothstep`, so they don't benefit from multisampling. Tessellated
|
||
// user geometry submitted via `prepare_shape` is rendered without anti-aliasing — if AA is
|
||
// required for tessellated content, the caller must either render it to their own offscreen
|
||
// target and submit the result as a texture, or use the AA helpers in the `tess` subpackage
|
||
// (e.g. `tess.triangle_aa` extrudes 1-physical-pixel alpha-falloff edge bands). This
|
||
// decision aligns with the SBC target (Mali Valhall, where MSAA's per-tile bandwidth
|
||
// multiplier is expensive) and matches RAD Debugger's architecture.
|
||
//
|
||
// ----- Color and blending -----
|
||
// `Color` is RGBA8 in memory order (R, G, B, A at indices 0..3). The shader unpacks via
|
||
// `unpackUnorm4x8`, which reads bytes in that exact order. Alpha 255 = fully opaque, 0 =
|
||
// fully transparent.
|
||
//
|
||
// All rendering uses *premultiplied-over* blending (blend state ONE, ONE_MINUS_SRC_ALPHA —
|
||
// the standard mode used by Skia, Flutter, and GPUI). Three implications:
|
||
//
|
||
// - Public shape procs (`rectangle`, `circle`, `line`, etc.) accept straight-alpha
|
||
// `Color` values and the SDF fragment shaders premultiply internally; users of these
|
||
// procs don't need to think about premultiplication.
|
||
// - Vertex colors written to the shared vertex stream (the tessellated path — text and
|
||
// anything submitted via `prepare_shape`, including `tess.*` helpers) MUST be
|
||
// premultiplied at the CPU. The tessellated fragment shader passes vertex color through
|
||
// directly without further modification. The `premultiply_color` helper handles this.
|
||
// - The clear color passed to `end()` is also premultiplied internally before being
|
||
// handed to the GPU; callers pass straight-alpha `Color` here too.
|
||
package draw
|
||
|
||
import "base:runtime"
|
||
import "core:c"
|
||
import "core:log"
|
||
import "core:math"
|
||
import "core:strings"
|
||
import sdl "vendor:sdl3"
|
||
import sdl_ttf "vendor:sdl3/ttf"
|
||
|
||
import clay "../vendor/clay"
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Shader format ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
//INTERNAL (each constant in the when-block below)
|
||
when ODIN_OS == .Darwin {
|
||
PLATFORM_SHADER_FORMAT_FLAG :: sdl.GPUShaderFormatFlag.MSL
|
||
SHADER_ENTRY :: cstring("main0")
|
||
BASE_VERT_2D_RAW :: #load("shaders/generated/base_2d.vert.metal")
|
||
BASE_FRAG_2D_RAW :: #load("shaders/generated/base_2d.frag.metal")
|
||
BACKDROP_FULLSCREEN_VERT_RAW :: #load("shaders/generated/backdrop_fullscreen.vert.metal")
|
||
BACKDROP_DOWNSAMPLE_FRAG_RAW :: #load("shaders/generated/backdrop_downsample.frag.metal")
|
||
BACKDROP_BLUR_VERT_RAW :: #load("shaders/generated/backdrop_blur.vert.metal")
|
||
BACKDROP_BLUR_FRAG_RAW :: #load("shaders/generated/backdrop_blur.frag.metal")
|
||
} else {
|
||
PLATFORM_SHADER_FORMAT_FLAG :: sdl.GPUShaderFormatFlag.SPIRV
|
||
SHADER_ENTRY :: cstring("main")
|
||
BASE_VERT_2D_RAW :: #load("shaders/generated/base_2d.vert.spv")
|
||
BASE_FRAG_2D_RAW :: #load("shaders/generated/base_2d.frag.spv")
|
||
BACKDROP_FULLSCREEN_VERT_RAW :: #load("shaders/generated/backdrop_fullscreen.vert.spv")
|
||
BACKDROP_DOWNSAMPLE_FRAG_RAW :: #load("shaders/generated/backdrop_downsample.frag.spv")
|
||
BACKDROP_BLUR_VERT_RAW :: #load("shaders/generated/backdrop_blur.vert.spv")
|
||
BACKDROP_BLUR_FRAG_RAW :: #load("shaders/generated/backdrop_blur.frag.spv")
|
||
}
|
||
|
||
PLATFORM_SHADER_FORMAT :: sdl.GPUShaderFormat{PLATFORM_SHADER_FORMAT_FLAG}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Defaults and config ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
//INTERNAL
|
||
BUFFER_INIT_SIZE :: 256
|
||
//INTERNAL
|
||
INITIAL_LAYER_SIZE :: 5
|
||
//INTERNAL
|
||
INITIAL_SCISSOR_SIZE :: 10
|
||
|
||
// ----- Default parameter values -----
|
||
// Named constants for non-zero default procedure parameters. Centralizes magic numbers
|
||
// so they can be tuned in one place and referenced by name in proc signatures.
|
||
DFT_FEATHER_PPX :: 1 // Total AA feather width in physical pixels (half on each side of boundary).
|
||
DFT_STROKE_THICKNESS :: 1 // Default line/stroke thickness in logical pixels.
|
||
DFT_FONT_SIZE :: 44 // Default font size in points for text rendering.
|
||
DFT_CIRC_END_ANGLE :: 360 // Full-circle end angle in degrees (ring/arc).
|
||
DFT_UV_RECT :: Rectangle{0, 0, 1, 1} // Full-texture UV rect (Texture_Fill default).
|
||
DFT_TINT :: WHITE // Default texture tint (Texture_Fill, clay_image).
|
||
DFT_TEXT_COLOR :: BLACK // Default text color.
|
||
DFT_CLEAR_COLOR :: BLACK // Default clear color for end().
|
||
DFT_SAMPLER :: Sampler_Preset.Linear_Clamp // Default texture sampler preset.
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Global state ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
//INTERNAL
|
||
GLOB: Global
|
||
|
||
//INTERNAL
|
||
Global :: struct {
|
||
// -- Per-frame staging (hottest — touched by every prepare/upload/clear cycle) --
|
||
tmp_shape_verts: [dynamic]Vertex_2D, // Tessellated shape vertices staged for GPU upload.
|
||
tmp_text_verts: [dynamic]Vertex_2D, // Text vertices staged for GPU upload.
|
||
tmp_text_indices: [dynamic]c.int, // Text index buffer staged for GPU upload.
|
||
tmp_text_batches: [dynamic]Text_Batch, // Text atlas batch metadata for indexed drawing.
|
||
tmp_primitives: [dynamic]Core_2D_Primitive, // SDF primitives staged for GPU storage buffer upload (core 2D subsystem).
|
||
tmp_sub_batches: [dynamic]Sub_Batch, // Sub-batch records that drive draw call dispatch.
|
||
tmp_uncached_text: [dynamic]^sdl_ttf.Text, // Uncached TTF_Text objects destroyed after end() submits.
|
||
tmp_gaussian_blur_primitives: [dynamic]Gaussian_Blur_Primitive, // Gaussian blur primitives staged for GPU storage buffer upload.
|
||
layers: [dynamic]Layer, // Draw layers, each with its own scissor stack.
|
||
scissors: [dynamic]Scissor, // Scissor rects that clip drawing within each layer.
|
||
|
||
// -- Per-frame scalars (accessed during prepare and draw_layer) --
|
||
curr_layer_index: uint, // Index of the currently active layer.
|
||
dpi_scaling: f32, // Window DPI scale factor applied to all pixel coordinates.
|
||
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).
|
||
device: ^sdl.GPUDevice, // GPU device handle, stored at init.
|
||
samplers: [SAMPLER_PRESET_COUNT]^sdl.GPUSampler, // Lazily-created sampler objects, one per Sampler_Preset.
|
||
|
||
// -- Deferred release (processed once per frame at frame boundary) --
|
||
pending_texture_releases: [dynamic]Texture_Id, // Deferred GPU texture releases, processed next frame.
|
||
pending_text_releases: [dynamic]^sdl_ttf.Text, // Deferred TTF_Text destroys, processed next frame.
|
||
|
||
// -- Textures (registration is occasional, binding is per draw call) --
|
||
texture_slots: [dynamic]Texture_Slot, // Registered texture slots indexed by Texture_Id.
|
||
texture_free_list: [dynamic]u32, // Recycled slot indices available for reuse.
|
||
|
||
// -- Clay (once per frame in prepare_clay_batch) --
|
||
clay_memory: [^]u8, // Raw memory block backing Clay's internal arena.
|
||
clay_merge_open_stack: [dynamic]Clay_Merge_Candidate, // Pending Rectangle/Image primitives waiting for a matching Border to merge with.
|
||
|
||
// -- Text (occasional — font registration and text cache lookups) --
|
||
text_cache: Text_Cache, // Font registry, SDL_ttf engine, and cached TTF_Text objects.
|
||
|
||
// -- Resize tracking (cold — checked once per frame in resize_global) --
|
||
max_layers: int, // High-water marks for dynamic array shrink heuristic.
|
||
max_scissors: int,
|
||
max_shape_verts: int,
|
||
max_text_verts: int,
|
||
max_text_indices: int,
|
||
max_text_batches: int,
|
||
max_primitives: int,
|
||
max_sub_batches: int,
|
||
max_gaussian_blur_primitives: int,
|
||
|
||
// -- Init-only (coldest — set once at init, never written again) --
|
||
odin_context: runtime.Context, // Odin context captured at init for use in callbacks.
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Core types ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
// A 2D position in world space. Non-distinct alias for [2]f32 — bare literals like {100, 200}
|
||
// work at non-ambiguous call sites. See the package doc for coordinate-system and unit
|
||
// conventions.
|
||
Vec2 :: [2]f32
|
||
|
||
// An RGBA color with 8 bits per channel. Distinct type over [4]u8 so that proc-group
|
||
// overloads can disambiguate Color from other 4-byte structs. See the package doc for the
|
||
// memory layout and the premultiplied-over blending contract.
|
||
Color :: [4]u8
|
||
|
||
BLACK :: Color{0, 0, 0, 255}
|
||
WHITE :: Color{255, 255, 255, 255}
|
||
RED :: Color{255, 0, 0, 255}
|
||
GREEN :: Color{0, 255, 0, 255}
|
||
BLUE :: Color{0, 0, 255, 255}
|
||
BLANK :: Color{0, 0, 0, 0}
|
||
|
||
Rectangle :: struct {
|
||
x: f32,
|
||
y: f32,
|
||
width: f32,
|
||
height: f32,
|
||
}
|
||
|
||
// Per-corner rounding radii for rectangles, specified clockwise from top-left.
|
||
// All values are in logical pixels (pre-DPI-scaling).
|
||
Rectangle_Radii :: struct {
|
||
top_left: f32,
|
||
top_right: f32,
|
||
bottom_right: f32,
|
||
bottom_left: f32,
|
||
}
|
||
|
||
// A linear gradient between two colors along an arbitrary angle.
|
||
// `angle` is in degrees: 0 = left-to-right, 90 = top-to-bottom.
|
||
Linear_Gradient :: struct {
|
||
start_color: Color,
|
||
end_color: Color,
|
||
angle: f32,
|
||
}
|
||
|
||
// A radial gradient between two colors from center to edge.
|
||
Radial_Gradient :: struct {
|
||
inner_color: Color,
|
||
outer_color: Color,
|
||
}
|
||
|
||
// Sample a registered texture as the shape's fill source.
|
||
// `tint` modulates the sampled texels per-pixel (constant multiply); WHITE passes through
|
||
// unchanged. Translucent tints fade the texture; non-white tints recolor it.
|
||
// Zero-initialized fields are treated as defaults by the shape procs:
|
||
// tint == Color{} → WHITE
|
||
// uv_rect == Rectangle{} → {0, 0, 1, 1} (full texture)
|
||
// sampler == .Linear_Clamp (enum value 0)
|
||
Texture_Fill :: struct {
|
||
id: Texture_Id,
|
||
tint: Color,
|
||
uv_rect: Rectangle,
|
||
sampler: Sampler_Preset,
|
||
}
|
||
|
||
// Mutually exclusive fill sources for shape procs. Each shape proc accepts a Brush
|
||
// as its third positional parameter. Texture and gradient are mutually exclusive at
|
||
// the GPU level (they share the worst-case register path); outline is orthogonal and
|
||
// composes with any Brush variant.
|
||
Brush :: union {
|
||
Color,
|
||
Linear_Gradient,
|
||
Radial_Gradient,
|
||
Texture_Fill,
|
||
}
|
||
|
||
// Convert clay.Color ([4]c.float in 0–255 range) to Color.
|
||
color_from_clay :: #force_inline proc(clay_color: clay.Color) -> Color {
|
||
return Color{u8(clay_color[0]), u8(clay_color[1]), u8(clay_color[2]), u8(clay_color[3])}
|
||
}
|
||
|
||
// Convert Color to [4]f32 in 0.0–1.0 range. Useful for SDL interop (e.g. clear color).
|
||
color_to_f32 :: proc(color: Color) -> [4]f32 {
|
||
INV :: 1.0 / 255.0
|
||
return {f32(color[0]) * INV, f32(color[1]) * INV, f32(color[2]) * INV, f32(color[3]) * INV}
|
||
}
|
||
|
||
// Pre-multiply RGB channels by alpha. Required for any vertex written to the tessellated
|
||
// vertex stream (text path or `prepare_shape`-style submissions); see the package doc's
|
||
// "Color and blending" section for the full contract.
|
||
premultiply_color :: #force_inline proc(color: Color) -> Color {
|
||
a := u32(color[3])
|
||
return Color {
|
||
u8((u32(color[0]) * a + 127) / 255),
|
||
u8((u32(color[1]) * a + 127) / 255),
|
||
u8((u32(color[2]) * a + 127) / 255),
|
||
color[3],
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Frame layout types ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
//INTERNAL
|
||
Sub_Batch_Kind :: enum u8 {
|
||
Tessellated, // non-indexed, white texture or user texture, Core_2D_Mode.Tessellated
|
||
Text, // indexed, atlas texture, Core_2D_Mode.Text (vertices already in physical-pixel space)
|
||
SDF, // instanced unit quad, Core_2D_Mode.SDF
|
||
// instanced unit quad, backdrop subsystem V-composite (indexes Gaussian_Blur_Primitive).
|
||
// Bracket-scheduled per layer; see README.md § "Backdrop pipeline" for ordering semantics.
|
||
Backdrop,
|
||
}
|
||
|
||
//INTERNAL
|
||
Sub_Batch :: struct {
|
||
kind: Sub_Batch_Kind,
|
||
offset: u32, // Tessellated: vertex offset; Text: text_batch index; SDF/Backdrop: primitive index
|
||
count: u32, // Tessellated: vertex count; Text: always 1; SDF/Backdrop: primitive count
|
||
texture_id: Texture_Id,
|
||
sampler: Sampler_Preset,
|
||
// Backdrop only — Gaussian std-dev in logical pixels. Named with the
|
||
// distribution prefix because future kinds may want different sigma
|
||
// shapes (e.g. drop-shadow penumbra) without overloading this field.
|
||
gaussian_sigma: f32,
|
||
}
|
||
|
||
Layer :: struct {
|
||
bounds: Rectangle,
|
||
sub_batch_start: u32,
|
||
sub_batch_len: u32,
|
||
scissor_start: u32,
|
||
scissor_len: u32,
|
||
}
|
||
|
||
//INTERNAL
|
||
Scissor :: struct {
|
||
bounds: sdl.Rect,
|
||
sub_batch_start: u32,
|
||
sub_batch_len: u32,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Lifecycle ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
// Initialize the renderer. Returns false if GPU pipeline or text engine creation fails.
|
||
@(require_results)
|
||
init :: proc(
|
||
device: ^sdl.GPUDevice,
|
||
window: ^sdl.Window,
|
||
allocator := context.allocator,
|
||
odin_context := context,
|
||
) -> (
|
||
ok: bool,
|
||
) {
|
||
min_memory_size: c.size_t = cast(c.size_t)clay.MinMemorySize()
|
||
|
||
core, core_ok := create_core_2d(device, window)
|
||
if !core_ok {
|
||
return false
|
||
}
|
||
|
||
backdrop, backdrop_ok := create_backdrop(device, window)
|
||
if !backdrop_ok {
|
||
destroy_core_2d(device, &core)
|
||
return false
|
||
}
|
||
|
||
text_cache, text_ok := init_text_cache(device, allocator)
|
||
if !text_ok {
|
||
destroy_backdrop(device, &backdrop)
|
||
destroy_core_2d(device, &core)
|
||
return false
|
||
}
|
||
|
||
GLOB = Global {
|
||
layers = make([dynamic]Layer, 0, INITIAL_LAYER_SIZE, allocator = allocator),
|
||
scissors = make([dynamic]Scissor, 0, INITIAL_SCISSOR_SIZE, allocator = allocator),
|
||
tmp_shape_verts = make([dynamic]Vertex_2D, 0, BUFFER_INIT_SIZE, allocator = allocator),
|
||
tmp_text_verts = make([dynamic]Vertex_2D, 0, BUFFER_INIT_SIZE, allocator = allocator),
|
||
tmp_text_indices = make([dynamic]c.int, 0, BUFFER_INIT_SIZE, allocator = allocator),
|
||
tmp_text_batches = make([dynamic]Text_Batch, 0, BUFFER_INIT_SIZE, allocator = allocator),
|
||
tmp_primitives = make(
|
||
[dynamic]Core_2D_Primitive,
|
||
0,
|
||
BUFFER_INIT_SIZE,
|
||
allocator = allocator,
|
||
),
|
||
tmp_sub_batches = make([dynamic]Sub_Batch, 0, BUFFER_INIT_SIZE, allocator = allocator),
|
||
tmp_uncached_text = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator),
|
||
clay_merge_open_stack = make([dynamic]Clay_Merge_Candidate, 0, 16, allocator = allocator),
|
||
tmp_gaussian_blur_primitives = make(
|
||
[dynamic]Gaussian_Blur_Primitive,
|
||
0,
|
||
BUFFER_INIT_SIZE,
|
||
allocator = allocator,
|
||
),
|
||
device = device,
|
||
texture_slots = make([dynamic]Texture_Slot, 0, 16, allocator = allocator),
|
||
texture_free_list = make([dynamic]u32, 0, 16, allocator = allocator),
|
||
pending_texture_releases = make([dynamic]Texture_Id, 0, 16, allocator = allocator),
|
||
pending_text_releases = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator),
|
||
odin_context = odin_context,
|
||
dpi_scaling = sdl.GetWindowDisplayScale(window),
|
||
clay_memory = make([^]u8, min_memory_size, allocator = allocator),
|
||
core_2d = core,
|
||
backdrop = backdrop,
|
||
text_cache = text_cache,
|
||
}
|
||
|
||
// Reserve slot 0 for INVALID_TEXTURE
|
||
append(&GLOB.texture_slots, Texture_Slot{})
|
||
log.debug("Window DPI scaling:", GLOB.dpi_scaling)
|
||
arena := clay.CreateArenaWithCapacityAndMemory(min_memory_size, GLOB.clay_memory)
|
||
window_width, window_height: c.int
|
||
sdl.GetWindowSize(window, &window_width, &window_height)
|
||
|
||
clay.Initialize(arena, {f32(window_width), f32(window_height)}, {handler = clay_error_handler})
|
||
clay.SetMeasureTextFunction(measure_text_clay, nil)
|
||
return true
|
||
}
|
||
|
||
// TODO Either every x frames nuke max values in case of edge cases where max gets set very high
|
||
// or leave to application code to decide the right time for resize
|
||
resize_global :: proc() {
|
||
if len(GLOB.layers) > GLOB.max_layers do GLOB.max_layers = len(GLOB.layers)
|
||
shrink(&GLOB.layers, GLOB.max_layers)
|
||
if len(GLOB.scissors) > GLOB.max_scissors do GLOB.max_scissors = len(GLOB.scissors)
|
||
shrink(&GLOB.scissors, GLOB.max_scissors)
|
||
if len(GLOB.tmp_shape_verts) > GLOB.max_shape_verts do GLOB.max_shape_verts = len(GLOB.tmp_shape_verts)
|
||
shrink(&GLOB.tmp_shape_verts, GLOB.max_shape_verts)
|
||
if len(GLOB.tmp_text_verts) > GLOB.max_text_verts do GLOB.max_text_verts = len(GLOB.tmp_text_verts)
|
||
shrink(&GLOB.tmp_text_verts, GLOB.max_text_verts)
|
||
if len(GLOB.tmp_text_indices) > GLOB.max_text_indices do GLOB.max_text_indices = len(GLOB.tmp_text_indices)
|
||
shrink(&GLOB.tmp_text_indices, GLOB.max_text_indices)
|
||
if len(GLOB.tmp_text_batches) > GLOB.max_text_batches do GLOB.max_text_batches = len(GLOB.tmp_text_batches)
|
||
shrink(&GLOB.tmp_text_batches, GLOB.max_text_batches)
|
||
if len(GLOB.tmp_primitives) > GLOB.max_primitives do GLOB.max_primitives = len(GLOB.tmp_primitives)
|
||
shrink(&GLOB.tmp_primitives, GLOB.max_primitives)
|
||
if len(GLOB.tmp_sub_batches) > GLOB.max_sub_batches do GLOB.max_sub_batches = len(GLOB.tmp_sub_batches)
|
||
shrink(&GLOB.tmp_sub_batches, GLOB.max_sub_batches)
|
||
if len(GLOB.tmp_gaussian_blur_primitives) > GLOB.max_gaussian_blur_primitives do GLOB.max_gaussian_blur_primitives = len(GLOB.tmp_gaussian_blur_primitives)
|
||
shrink(&GLOB.tmp_gaussian_blur_primitives, GLOB.max_gaussian_blur_primitives)
|
||
}
|
||
|
||
destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) {
|
||
delete(GLOB.layers)
|
||
delete(GLOB.scissors)
|
||
delete(GLOB.tmp_shape_verts)
|
||
delete(GLOB.tmp_text_verts)
|
||
delete(GLOB.tmp_text_indices)
|
||
delete(GLOB.tmp_text_batches)
|
||
delete(GLOB.tmp_primitives)
|
||
delete(GLOB.tmp_sub_batches)
|
||
delete(GLOB.tmp_gaussian_blur_primitives)
|
||
for ttf_text in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(ttf_text)
|
||
delete(GLOB.tmp_uncached_text)
|
||
free(GLOB.clay_memory, allocator)
|
||
process_pending_texture_releases()
|
||
destroy_all_textures()
|
||
destroy_sampler_pool()
|
||
for ttf_text in GLOB.pending_text_releases do sdl_ttf.DestroyText(ttf_text)
|
||
delete(GLOB.pending_text_releases)
|
||
destroy_backdrop(device, &GLOB.backdrop)
|
||
destroy_core_2d(device, &GLOB.core_2d)
|
||
destroy_text_cache()
|
||
}
|
||
|
||
//INTERNAL
|
||
clear_global :: proc() {
|
||
// Process deferred texture releases from the previous frame
|
||
process_pending_texture_releases()
|
||
// Process deferred text releases from the previous frame
|
||
for ttf_text in GLOB.pending_text_releases do sdl_ttf.DestroyText(ttf_text)
|
||
clear(&GLOB.pending_text_releases)
|
||
|
||
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)
|
||
clear(&GLOB.layers)
|
||
clear(&GLOB.scissors)
|
||
clear(&GLOB.tmp_shape_verts)
|
||
clear(&GLOB.tmp_text_verts)
|
||
clear(&GLOB.tmp_text_indices)
|
||
clear(&GLOB.tmp_text_batches)
|
||
clear(&GLOB.tmp_primitives)
|
||
clear(&GLOB.tmp_sub_batches)
|
||
clear(&GLOB.tmp_gaussian_blur_primitives)
|
||
clear(&GLOB.clay_merge_open_stack)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Frame ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
// 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,
|
||
scissor_start = prev_layer.scissor_start + prev_layer.scissor_len,
|
||
scissor_len = 1,
|
||
}
|
||
append(&GLOB.layers, layer)
|
||
GLOB.curr_layer_index += 1
|
||
log.debug("Added new layer; curr index", GLOB.curr_layer_index)
|
||
|
||
scissor := Scissor {
|
||
sub_batch_start = u32(len(GLOB.tmp_sub_batches)),
|
||
bounds = sdl.Rect {
|
||
x = i32(bounds.x * GLOB.dpi_scaling),
|
||
y = i32(bounds.y * GLOB.dpi_scaling),
|
||
w = i32(bounds.width * GLOB.dpi_scaling),
|
||
h = i32(bounds.height * GLOB.dpi_scaling),
|
||
},
|
||
}
|
||
append(&GLOB.scissors, scissor)
|
||
return &GLOB.layers[GLOB.curr_layer_index]
|
||
}
|
||
|
||
// Sets up renderer to begin upload to the GPU. Returns starting `Layer` to begin processing primitives for.
|
||
begin :: proc(bounds: Rectangle) -> ^Layer {
|
||
// Cleanup
|
||
clear_global()
|
||
|
||
// Begin new layer + start a new scissor
|
||
scissor := Scissor {
|
||
bounds = sdl.Rect {
|
||
x = i32(bounds.x * GLOB.dpi_scaling),
|
||
y = i32(bounds.y * GLOB.dpi_scaling),
|
||
w = i32(bounds.width * GLOB.dpi_scaling),
|
||
h = i32(bounds.height * GLOB.dpi_scaling),
|
||
},
|
||
}
|
||
append(&GLOB.scissors, scissor)
|
||
|
||
layer := Layer {
|
||
bounds = bounds,
|
||
scissor_len = 1,
|
||
}
|
||
append(&GLOB.layers, layer)
|
||
return &GLOB.layers[GLOB.curr_layer_index]
|
||
}
|
||
|
||
// 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)
|
||
if cmd_buffer == nil {
|
||
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
|
||
// touch the backdrop pipeline's working textures.
|
||
has_backdrop := frame_has_backdrop()
|
||
|
||
// Upload primitives to GPU (vertices, indices, SDF prims, and backdrop prims share one
|
||
// copy pass so we pay the BeginGPUCopyPass / EndGPUCopyPass cost once per frame).
|
||
copy_pass := sdl.BeginGPUCopyPass(cmd_buffer)
|
||
upload(device, copy_pass)
|
||
if has_backdrop {
|
||
upload_backdrop_primitives(device, copy_pass)
|
||
}
|
||
sdl.EndGPUCopyPass(copy_pass)
|
||
|
||
swapchain_texture: ^sdl.GPUTexture
|
||
width, height: u32
|
||
if !sdl.WaitAndAcquireGPUSwapchainTexture(cmd_buffer, window, &swapchain_texture, &width, &height) {
|
||
log.panicf("Failed to acquire swapchain texture: %s", sdl.GetError())
|
||
}
|
||
|
||
if swapchain_texture == nil {
|
||
// Window is minimized or not visible — submit and skip this frame
|
||
if !sdl.SubmitGPUCommandBuffer(cmd_buffer) {
|
||
log.panicf("Failed to submit GPU command buffer (minimized window): %s", sdl.GetError())
|
||
}
|
||
return
|
||
}
|
||
|
||
render_texture := swapchain_texture
|
||
if has_backdrop {
|
||
ensure_backdrop_textures(device, sdl.GetGPUSwapchainTextureFormat(device, window), width, height)
|
||
render_texture = GLOB.backdrop.source_texture
|
||
}
|
||
|
||
// Premultiply clear color: the blend state is ONE, ONE_MINUS_SRC_ALPHA (premultiplied),
|
||
// so the clear color must also be premultiplied for correct background compositing.
|
||
clear_color_straight := color_to_f32(clear_color)
|
||
clear_alpha := clear_color_straight[3]
|
||
clear_color_f32 := [4]f32 {
|
||
clear_color_straight[0] * clear_alpha,
|
||
clear_color_straight[1] * clear_alpha,
|
||
clear_color_straight[2] * clear_alpha,
|
||
clear_alpha,
|
||
}
|
||
|
||
// Draw layers. One render pass per layer; sub-batches draw in submission order within each scissor.
|
||
for &layer, index in GLOB.layers {
|
||
draw_layer(device, window, cmd_buffer, render_texture, width, height, clear_color_f32, &layer)
|
||
}
|
||
|
||
// When we rendered into source_texture, copy it to the swapchain. Single
|
||
// CopyGPUTextureToTexture call per frame, only when backdrop content was present.
|
||
if has_backdrop {
|
||
copy_pass := sdl.BeginGPUCopyPass(cmd_buffer)
|
||
sdl.CopyGPUTextureToTexture(
|
||
copy_pass,
|
||
sdl.GPUTextureLocation{texture = GLOB.backdrop.source_texture},
|
||
sdl.GPUTextureLocation{texture = swapchain_texture},
|
||
width,
|
||
height,
|
||
1,
|
||
false,
|
||
)
|
||
sdl.EndGPUCopyPass(copy_pass)
|
||
}
|
||
|
||
if !sdl.SubmitGPUCommandBuffer(cmd_buffer) {
|
||
log.panicf("Failed to submit GPU command buffer: %s", sdl.GetError())
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Sub-batch dispatch ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
// Append a new sub-batch or extend the last one if same kind and contiguous.
|
||
//
|
||
// `gaussian_sigma` is only consulted for kind == .Backdrop; two .Backdrop sub-batches with
|
||
// different sigmas cannot coalesce because they require separate H+V blur passes in the
|
||
// bracket scheduler. Float equality is intentional — user-supplied literal sigmas (e.g.
|
||
// `sigma = 12`) produce bit-identical floats, and the worst case for two sigmas that differ
|
||
// only by a ulp is one extra pass pair (correct, just slightly suboptimal).
|
||
//INTERNAL
|
||
append_or_extend_sub_batch :: proc(
|
||
scissor: ^Scissor,
|
||
layer: ^Layer,
|
||
kind: Sub_Batch_Kind,
|
||
offset: u32,
|
||
count: u32,
|
||
texture_id: Texture_Id = INVALID_TEXTURE,
|
||
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 &&
|
||
kind != .Text &&
|
||
last.offset + last.count == offset &&
|
||
last.texture_id == texture_id &&
|
||
last.sampler == sampler &&
|
||
(kind != .Backdrop || last.gaussian_sigma == gaussian_sigma) {
|
||
last.count += count
|
||
return
|
||
}
|
||
}
|
||
append(
|
||
&GLOB.tmp_sub_batches,
|
||
Sub_Batch {
|
||
kind = kind,
|
||
offset = offset,
|
||
count = count,
|
||
texture_id = texture_id,
|
||
sampler = sampler,
|
||
gaussian_sigma = gaussian_sigma,
|
||
},
|
||
)
|
||
scissor.sub_batch_len += 1
|
||
layer.sub_batch_len += 1
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Clay ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
@(private = "file")
|
||
clay_error_handler :: proc "c" (errorData: clay.ErrorData) {
|
||
context = GLOB.odin_context
|
||
log.error("Clay error:", errorData.errorType, errorData.errorText)
|
||
}
|
||
|
||
@(private = "file")
|
||
measure_text_clay :: proc "c" (
|
||
text: clay.StringSlice,
|
||
config: ^clay.TextElementConfig,
|
||
user_data: rawptr,
|
||
) -> clay.Dimensions {
|
||
context = GLOB.odin_context
|
||
text := string(text.chars[:text.length])
|
||
c_text := strings.clone_to_cstring(text, context.temp_allocator)
|
||
defer delete(c_text, context.temp_allocator)
|
||
width, height: c.int
|
||
if !sdl_ttf.GetStringSize(get_font(config.fontId, config.fontSize), c_text, 0, &width, &height) {
|
||
log.panicf("Failed to measure text: %s", sdl.GetError())
|
||
}
|
||
|
||
return clay.Dimensions{width = f32(width) / GLOB.dpi_scaling, height = f32(height) / GLOB.dpi_scaling}
|
||
}
|
||
|
||
// Called for each Clay `RenderCommandType.Custom` render command that
|
||
// `prepare_clay_batch` encounters and which is NOT a levlib-managed variant
|
||
// (e.g. `Backdrop_Marker`).
|
||
//
|
||
// - `layer` is the layer the command belongs to (post-z-index promotion).
|
||
// - `bounds` is already translated into the active layer's coordinate system
|
||
// and pre-DPI, matching what the built-in shape procs expect.
|
||
// - `render_data` is Clay's `CustomRenderData` for the element, exposing
|
||
// `backgroundColor` and `cornerRadius`. Its `customData` field has been
|
||
// unwrapped from the `Clay_Custom` envelope: it points at the user's own
|
||
// data (the value the user wrote into the `rawptr` variant), not at the
|
||
// `Clay_Custom` itself. If the union was zero-init (no variant set) or
|
||
// `customData` was originally nil, the callback receives nil.
|
||
//
|
||
// The callback must not call `new_layer` or `prepare_clay_batch`.
|
||
Custom_Draw :: #type proc(layer: ^Layer, bounds: Rectangle, render_data: clay.CustomRenderData)
|
||
|
||
ClayBatch :: struct {
|
||
bounds: Rectangle,
|
||
cmds: clay.ClayArray(clay.RenderCommand),
|
||
}
|
||
|
||
// Discriminated sum of everything `clay.CustomElementConfig.customData` is allowed to point
|
||
// at. levlib-defined variants (currently just `Backdrop_Marker`) are recognized by
|
||
// `prepare_clay_batch` and routed to the appropriate internal path; the `rawptr` variant is
|
||
// the escape hatch for user-defined custom drawing — `prepare_clay_batch` unwraps it before
|
||
// invoking `custom_draw` so the callback sees the user's pointer in `render_data.customData`
|
||
// exactly as if no wrapper were involved.
|
||
//
|
||
// Contract: `customData`, when non-nil, MUST point at storage holding a `Clay_Custom`
|
||
// value. The user owns that storage; its lifetime must span the Clay layout call and the
|
||
// matching `prepare_clay_batch` call. Pointing `customData` at a bare user struct violates
|
||
// the contract — the dispatcher will read its first bytes as a union tag and either route
|
||
// the draw incorrectly or panic on type assertion. There is no recovery path; this is a
|
||
// strict-discipline API by design.
|
||
//
|
||
// Construction notes (Odin implicit-conversion rules):
|
||
// - Backdrop variant: `bd: Clay_Custom = Backdrop_Marker{...}` works directly.
|
||
// Variant-to-union conversion is implicit.
|
||
// - User pointer: `up: Clay_Custom = rawptr(&my_struct)` — the explicit `rawptr(...)` is
|
||
// required because Odin does not chain `^T -> rawptr -> Clay_Custom` implicitly. A bare
|
||
// `up: Clay_Custom = &my_struct` is a compile error.
|
||
Clay_Custom :: union {
|
||
Backdrop_Marker,
|
||
rawptr,
|
||
}
|
||
|
||
// Per-primitive parameters for a backdrop blur dispatched through the Clay integration.
|
||
// Embedded as a `Clay_Custom` variant; `prepare_clay_batch` walks the command stream,
|
||
// opens/closes a backdrop scope around contiguous backdrop runs, and feeds these to
|
||
// `backdrop_blur` via `dispatch_clay_backdrop`. The discriminant is the union tag — no
|
||
// in-band magic field needed (compiler-enforced).
|
||
Backdrop_Marker :: struct {
|
||
sigma: f32,
|
||
tint: Color,
|
||
radii: Rectangle_Radii,
|
||
feather_ppx: f32,
|
||
}
|
||
|
||
// One entry on the Clay merge stack. Pushed by `dispatch_clay_command` when emitting a
|
||
// Rectangle or an Image primitive, then popped by a matching Border to retroactively add
|
||
// the outline. See `try_dispatch_clay_border_merge` for the matching semantics.
|
||
//INTERNAL
|
||
Clay_Merge_Candidate :: struct {
|
||
primitive_index: u32, // Index into `GLOB.tmp_primitives` of the candidate primitive.
|
||
outer_bounds: Rectangle, // Clay's bounding box — keyed on for the bounds match check.
|
||
corner_radii: clay.CornerRadius, // Clay's corner radii — also keyed on for the match check.
|
||
image_data: Clay_Image_Data, // Only read when kind == .Fill_Texture (needed to refit UVs to inner_bounds).
|
||
kind: Clay_Merge_Candidate_Kind,
|
||
}
|
||
|
||
//INTERNAL
|
||
Clay_Merge_Candidate_Kind :: enum u8 {
|
||
// Solid Color brush. Used for Rectangle commands and for the bg primitive of an Image
|
||
// command that has `backgroundColor.a > 0`. Merge mutation: shrink shape + add outline.
|
||
Fill_Color,
|
||
// Texture_Fill brush. Used for the image primitive of an Image command with no bg, where
|
||
// `fit_params` returned `fit_rect == outer_bounds` (the image fully covers Clay's bounds).
|
||
// Merge mutation: shrink shape + add outline + refit UV against inner_bounds.
|
||
Fill_Texture,
|
||
}
|
||
|
||
// Returns true if this Clay render command represents a backdrop primitive — i.e. its
|
||
// `customData` points at a `Clay_Custom` whose active variant is `Backdrop_Marker`.
|
||
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
|
||
_, ok := (^Clay_Custom)(p).(Backdrop_Marker)
|
||
return ok
|
||
}
|
||
|
||
// Emit a Clay border drawn INSIDE `bounds` — the outer edge of each side aligns with
|
||
// `bounds`, the inner edge is `border_width.*` pixels inset. Matches Clay's layout model
|
||
// (CSS border-box) so the visible element occupies exactly Clay's allocated space.
|
||
//
|
||
// The fast path (uniform widths) uses `rectangle()` with the built-in SDF outline, which
|
||
// always extends outward from the shape it's given — we pre-shrink the shape by
|
||
// `border_width` so the outline lands precisely at Clay's bounds. The slow path (non-uniform
|
||
// widths) emits per-side rectangles and per-corner arcs directly, all positioned inside
|
||
// `bounds`. All-zero widths is a no-op.
|
||
//
|
||
// A corner is rounded iff its radius is positive AND both adjacent sides have positive
|
||
// width. Top corners take their thickness from `border_width.top`, bottom corners from
|
||
// `border_width.bottom`. When the two widths meeting at a corner differ there is a step at
|
||
// the side/corner junction (acceptable for the rare mixed-width case).
|
||
//
|
||
// When `border_width > corner_radius`, the inner corner clamps to zero (sharp inside, still
|
||
// rounded outside) — matches CSS-standard behavior.
|
||
//INTERNAL
|
||
clay_emit_partial_border :: proc(
|
||
layer: ^Layer,
|
||
bounds: Rectangle,
|
||
border_color: Color,
|
||
border_width: clay.BorderWidth,
|
||
corner_radii: clay.CornerRadius,
|
||
) {
|
||
// All-zero: nothing to draw.
|
||
if border_width.top == 0 && border_width.right == 0 && border_width.bottom == 0 && border_width.left == 0 {
|
||
return
|
||
}
|
||
|
||
// Convert side widths once (u16 -> f32) and cache for reuse.
|
||
width_top := f32(border_width.top)
|
||
width_right := f32(border_width.right)
|
||
width_bottom := f32(border_width.bottom)
|
||
width_left := f32(border_width.left)
|
||
|
||
// Fast path: all four sides have the same nonzero width. Pre-shrink the shape by the
|
||
// uniform width so the SDF outline (which always extends outward from the shape) lands
|
||
// exactly at Clay's `bounds` — the visible border ends up INSIDE Clay's allocation while
|
||
// the SDF mechanism keeps doing outward outlining. Single SDF primitive, exact curves,
|
||
// analytical AA.
|
||
if border_width.left == border_width.top &&
|
||
border_width.top == border_width.right &&
|
||
border_width.right == border_width.bottom {
|
||
uniform_width := width_top
|
||
inner_bounds := Rectangle {
|
||
x = bounds.x + uniform_width,
|
||
y = bounds.y + uniform_width,
|
||
width = bounds.width - 2 * uniform_width,
|
||
height = bounds.height - 2 * uniform_width,
|
||
}
|
||
inner_radii := Rectangle_Radii {
|
||
top_left = max(0, corner_radii.topLeft - uniform_width),
|
||
top_right = max(0, corner_radii.topRight - uniform_width),
|
||
bottom_right = max(0, corner_radii.bottomRight - uniform_width),
|
||
bottom_left = max(0, corner_radii.bottomLeft - uniform_width),
|
||
}
|
||
rectangle(
|
||
layer,
|
||
inner_bounds,
|
||
BLANK,
|
||
outline_color = border_color,
|
||
outline_width = uniform_width,
|
||
radii = inner_radii,
|
||
)
|
||
return
|
||
}
|
||
|
||
// A corner is drawn rounded only if its radius is positive AND both adjacent sides are present.
|
||
top_left_rounded := corner_radii.topLeft > 0 && border_width.top > 0 && border_width.left > 0
|
||
top_right_rounded := corner_radii.topRight > 0 && border_width.top > 0 && border_width.right > 0
|
||
bottom_left_rounded := corner_radii.bottomLeft > 0 && border_width.bottom > 0 && border_width.left > 0
|
||
bottom_right_rounded := corner_radii.bottomRight > 0 && border_width.bottom > 0 && border_width.right > 0
|
||
|
||
// Horizontal x-coordinates where the top/bottom side rectangles start/end. When the
|
||
// adjacent corner is rounded, the side stops at `bounds.x + radius` (where the corner
|
||
// arc takes over). When not rounded, the side runs to the bounds edge; the perpendicular
|
||
// side handles the inset to avoid overlap.
|
||
top_left_x: f32 = top_left_rounded ? bounds.x + corner_radii.topLeft : bounds.x
|
||
top_right_x: f32 =
|
||
top_right_rounded ? bounds.x + bounds.width - corner_radii.topRight : bounds.x + bounds.width
|
||
bottom_left_x: f32 = bottom_left_rounded ? bounds.x + corner_radii.bottomLeft : bounds.x
|
||
bottom_right_x: f32 =
|
||
bottom_right_rounded ? bounds.x + bounds.width - corner_radii.bottomRight : bounds.x + bounds.width
|
||
|
||
// Vertical y-coordinates where the left/right side rectangles start/end. When the
|
||
// adjacent corner is rounded, inset by the corner radius. When not rounded, inset by the
|
||
// adjacent horizontal width — the horizontal side owns the corner area (extending through
|
||
// it to the bounds edge), so the vertical side starts below it to avoid overdraw of
|
||
// translucent colors.
|
||
top_left_y: f32 = top_left_rounded ? bounds.y + corner_radii.topLeft : bounds.y + width_top
|
||
top_right_y: f32 = top_right_rounded ? bounds.y + corner_radii.topRight : bounds.y + width_top
|
||
bottom_left_y: f32 =
|
||
bottom_left_rounded ? bounds.y + bounds.height - corner_radii.bottomLeft : bounds.y + bounds.height - width_bottom
|
||
bottom_right_y: f32 =
|
||
bottom_right_rounded ? bounds.y + bounds.height - corner_radii.bottomRight : bounds.y + bounds.height - width_bottom
|
||
|
||
// Side rectangles drawn INSIDE `bounds`. Sharp corners, solid fill, no outline. Each
|
||
// gated on its own width — skipping zero-width sides saves the primitive upload.
|
||
if border_width.top > 0 {
|
||
top_side := Rectangle {
|
||
x = top_left_x,
|
||
y = bounds.y,
|
||
width = top_right_x - top_left_x,
|
||
height = width_top,
|
||
}
|
||
rectangle(layer, top_side, border_color)
|
||
}
|
||
if border_width.bottom > 0 {
|
||
bottom_side := Rectangle {
|
||
x = bottom_left_x,
|
||
y = bounds.y + bounds.height - width_bottom,
|
||
width = bottom_right_x - bottom_left_x,
|
||
height = width_bottom,
|
||
}
|
||
rectangle(layer, bottom_side, border_color)
|
||
}
|
||
if border_width.left > 0 {
|
||
left_side := Rectangle {
|
||
x = bounds.x,
|
||
y = top_left_y,
|
||
width = width_left,
|
||
height = bottom_left_y - top_left_y,
|
||
}
|
||
rectangle(layer, left_side, border_color)
|
||
}
|
||
if border_width.right > 0 {
|
||
right_side := Rectangle {
|
||
x = bounds.x + bounds.width - width_right,
|
||
y = top_right_y,
|
||
width = width_right,
|
||
height = bottom_right_y - top_right_y,
|
||
}
|
||
rectangle(layer, right_side, border_color)
|
||
}
|
||
|
||
// Corner arcs (90° quadrants) drawn INSIDE bounds: outer radius matches Clay's
|
||
// `corner_radii`, inner radius is the outer radius minus the relevant border thickness
|
||
// (clamped to 0 for thick borders — produces a filled pie slice when border > radius,
|
||
// matching CSS). Angle convention matches ring(): 0° = +x (right), 90° = +y (down),
|
||
// 180° = -x (left), 270° = -y (up).
|
||
if top_left_rounded {
|
||
radius := corner_radii.topLeft
|
||
inner_radius := max(0, radius - width_top)
|
||
center := Vec2{bounds.x + radius, bounds.y + radius}
|
||
ring(layer, center, inner_radius, radius, border_color, start_angle = 180, end_angle = 270)
|
||
}
|
||
if top_right_rounded {
|
||
radius := corner_radii.topRight
|
||
inner_radius := max(0, radius - width_top)
|
||
center := Vec2{bounds.x + bounds.width - radius, bounds.y + radius}
|
||
ring(layer, center, inner_radius, radius, border_color, start_angle = 270, end_angle = 360)
|
||
}
|
||
if bottom_right_rounded {
|
||
radius := corner_radii.bottomRight
|
||
inner_radius := max(0, radius - width_bottom)
|
||
center := Vec2{bounds.x + bounds.width - radius, bounds.y + bounds.height - radius}
|
||
ring(layer, center, inner_radius, radius, border_color, start_angle = 0, end_angle = 90)
|
||
}
|
||
if bottom_left_rounded {
|
||
radius := corner_radii.bottomLeft
|
||
inner_radius := max(0, radius - width_bottom)
|
||
center := Vec2{bounds.x + radius, bounds.y + bounds.height - radius}
|
||
ring(layer, center, inner_radius, radius, border_color, start_angle = 90, end_angle = 180)
|
||
}
|
||
}
|
||
|
||
// Try to retroactively merge this Border into a pending Rectangle/Image candidate on the
|
||
// merge stack. Returns true on success so the caller can skip the standalone Border emission.
|
||
//
|
||
// Clay emits a parent element's bg and border bracketing all the children's commands, so a
|
||
// simple "is the next command a Border?" check (the previous approach) only catches leaf
|
||
// elements. The stack approach lets us pair them across arbitrary nesting: every Rectangle/
|
||
// Image push registers itself; every Border pops down until it finds a geometric match.
|
||
//
|
||
// Pop semantics: non-matching candidates above the match are discarded — their elements had
|
||
// no border anyway, so their primitives stay in `tmp_primitives` as plain Rectangles. A
|
||
// Border that finds no match at all falls back to standalone `clay_emit_partial_border`.
|
||
//
|
||
// Predicates that decline a candidate:
|
||
// - non-uniform or zero border widths (can't be a single uniform outline)
|
||
// - translucent border (the unmerged path's bg-under-border blending differs)
|
||
// - mismatched bounds or cornerRadius (the candidate isn't from the same element)
|
||
//
|
||
// False-match risk: two unrelated elements with bit-identical bounds and corner radii.
|
||
// Requires geometric coincidence (rare in practice), and even when it fires, the misattributed
|
||
// outline still lands at the correct screen position with the correct color — the pixels
|
||
// match the unmerged ground truth for opaque borders (the only kind we merge).
|
||
//INTERNAL
|
||
try_dispatch_clay_border_merge :: proc(bounds: Rectangle, border_data: clay.BorderRenderData) -> bool {
|
||
border_width := border_data.width
|
||
uniform_nonzero :=
|
||
border_width.left == border_width.top &&
|
||
border_width.top == border_width.right &&
|
||
border_width.right == border_width.bottom &&
|
||
border_width.top > 0
|
||
if !uniform_nonzero do return false
|
||
if border_data.color[3] < 255 do return false
|
||
|
||
for len(GLOB.clay_merge_open_stack) > 0 {
|
||
candidate := pop(&GLOB.clay_merge_open_stack)
|
||
if candidate.outer_bounds != bounds do continue
|
||
if candidate.corner_radii != border_data.cornerRadius do continue
|
||
apply_clay_border_merge_to_primitive(candidate, border_data)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Mutates `tmp_primitives[candidate.primitive_index]` in place: shrinks the SDF shape by
|
||
// the uniform border width so the (outward) outline lands at the outer bounds, sets the
|
||
// outline flag and params, and — for `Fill_Texture` candidates — refits the texture's UV
|
||
// against `inner_bounds` so the image doesn't overflow into the border strip.
|
||
//
|
||
// The primitive's `bounds` field stays at the outer bounds: the rasterized quad already
|
||
// covers the area the outline now occupies. Skipping the bounds expansion that
|
||
// `apply_brush_and_outline` would normally do is intentional — expanding here would push the
|
||
// rasterized quad past Clay's outer edge.
|
||
//INTERNAL
|
||
apply_clay_border_merge_to_primitive :: proc(
|
||
candidate: Clay_Merge_Candidate,
|
||
border_data: clay.BorderRenderData,
|
||
) {
|
||
prim := &GLOB.tmp_primitives[candidate.primitive_index]
|
||
uniform_width := f32(border_data.width.top)
|
||
dpi_scale := GLOB.dpi_scaling
|
||
|
||
inner_half_width := candidate.outer_bounds.width * 0.5 - uniform_width
|
||
inner_half_height := candidate.outer_bounds.height * 0.5 - uniform_width
|
||
prim.params.rrect.half_size_ppx = {inner_half_width * dpi_scale, inner_half_height * dpi_scale}
|
||
prim.params.rrect.radii_ppx = {
|
||
max(0, candidate.corner_radii.topLeft - uniform_width) * dpi_scale,
|
||
max(0, candidate.corner_radii.topRight - uniform_width) * dpi_scale,
|
||
max(0, candidate.corner_radii.bottomRight - uniform_width) * dpi_scale,
|
||
max(0, candidate.corner_radii.bottomLeft - uniform_width) * dpi_scale,
|
||
}
|
||
|
||
// Set the outline bit in the packed flags field (low byte = Shape_Kind, bits 8+ = Shape_Flags).
|
||
prim.flags |= u32(transmute(u8)Shape_Flags{.Outline}) << 8
|
||
prim.effects.outline_color = color_from_clay(border_data.color)
|
||
prim.effects.outline_packed = pack_f16_pair(f16(uniform_width * dpi_scale), 0)
|
||
|
||
if candidate.kind == .Fill_Texture {
|
||
// The candidate was only pushed if its `fit_rect == outer_bounds` at emission time, so the
|
||
// image fills the rasterized quad. Refit UVs against `inner_bounds` so the image is scoped
|
||
// to the area inside the new outline rather than overflowing into the border strip.
|
||
inner_bounds := Rectangle {
|
||
x = candidate.outer_bounds.x + uniform_width,
|
||
y = candidate.outer_bounds.y + uniform_width,
|
||
width = candidate.outer_bounds.width - 2 * uniform_width,
|
||
height = candidate.outer_bounds.height - 2 * uniform_width,
|
||
}
|
||
uv_rect, _, _ := fit_params(candidate.image_data.fit, inner_bounds, candidate.image_data.texture_id)
|
||
prim.uv_rect = {uv_rect.x, uv_rect.y, uv_rect.width, uv_rect.height}
|
||
}
|
||
}
|
||
|
||
// 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)^
|
||
corner_radii_clay := render_data.cornerRadius
|
||
radii := Rectangle_Radii {
|
||
top_left = corner_radii_clay.topLeft,
|
||
top_right = corner_radii_clay.topRight,
|
||
bottom_right = corner_radii_clay.bottomRight,
|
||
bottom_left = corner_radii_clay.bottomLeft,
|
||
}
|
||
|
||
background_color := color_from_clay(render_data.backgroundColor)
|
||
uv_rect, sampler, fit_rect := fit_params(img_data.fit, bounds, img_data.texture_id)
|
||
|
||
if background_color.a > 0 {
|
||
// Bg behind image. Push the bg primitive as the merge candidate so a matching Border
|
||
// turns into a bg+border-merged primitive plus a separate image draw on top.
|
||
rectangle(layer, bounds, background_color, radii = radii)
|
||
bg_primitive_index := u32(len(GLOB.tmp_primitives) - 1)
|
||
rectangle(
|
||
layer,
|
||
fit_rect,
|
||
Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv_rect, sampler = sampler},
|
||
radii = radii,
|
||
)
|
||
append(
|
||
&GLOB.clay_merge_open_stack,
|
||
Clay_Merge_Candidate {
|
||
primitive_index = bg_primitive_index,
|
||
outer_bounds = bounds,
|
||
corner_radii = corner_radii_clay,
|
||
kind = .Fill_Color,
|
||
},
|
||
)
|
||
} else {
|
||
// No bg: the image itself can host the outline if its fit fully covers Clay's bounds.
|
||
// `Fit_Mode.Fit` with aspect mismatch returns a sub-rect, which can't host an outline
|
||
// (the rasterized quad wouldn't reach Clay's outer edge), so we skip pushing.
|
||
rectangle(
|
||
layer,
|
||
fit_rect,
|
||
Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv_rect, sampler = sampler},
|
||
radii = radii,
|
||
)
|
||
if fit_rect == bounds {
|
||
img_primitive_index := u32(len(GLOB.tmp_primitives) - 1)
|
||
append(
|
||
&GLOB.clay_merge_open_stack,
|
||
Clay_Merge_Candidate {
|
||
primitive_index = img_primitive_index,
|
||
outer_bounds = bounds,
|
||
corner_radii = corner_radii_clay,
|
||
image_data = img_data,
|
||
kind = .Fill_Texture,
|
||
},
|
||
)
|
||
}
|
||
}
|
||
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.OverlayColorStart, clay.RenderCommandType.OverlayColorEnd:
|
||
unimplemented("Clay overlays not supported yet...")
|
||
case clay.RenderCommandType.Rectangle:
|
||
render_data := render_command.renderData.rectangle
|
||
corner_radii_clay := render_data.cornerRadius
|
||
background_color := color_from_clay(render_data.backgroundColor)
|
||
radii := Rectangle_Radii {
|
||
top_left = corner_radii_clay.topLeft,
|
||
top_right = corner_radii_clay.topRight,
|
||
bottom_right = corner_radii_clay.bottomRight,
|
||
bottom_left = corner_radii_clay.bottomLeft,
|
||
}
|
||
rectangle(layer, bounds, background_color, radii = radii)
|
||
// Register this primitive as a merge candidate. If the element has a matching Border
|
||
// later in the stream (after its children's commands), `try_dispatch_clay_border_merge`
|
||
// will pop this candidate and mutate the primitive in-place to add the outline.
|
||
primitive_index := u32(len(GLOB.tmp_primitives) - 1)
|
||
append(
|
||
&GLOB.clay_merge_open_stack,
|
||
Clay_Merge_Candidate {
|
||
primitive_index = primitive_index,
|
||
outer_bounds = bounds,
|
||
corner_radii = corner_radii_clay,
|
||
kind = .Fill_Color,
|
||
},
|
||
)
|
||
case clay.RenderCommandType.Border:
|
||
render_data := render_command.renderData.border
|
||
if try_dispatch_clay_border_merge(bounds, render_data) do return
|
||
clay_emit_partial_border(
|
||
layer,
|
||
bounds,
|
||
color_from_clay(render_data.color),
|
||
render_data.width,
|
||
render_data.cornerRadius,
|
||
)
|
||
case clay.RenderCommandType.Custom:
|
||
// Copy the CustomRenderData by value so we can patch its `customData` field for the
|
||
// user callback without mutating Clay-owned memory. After unwrapping, the callback
|
||
// sees its own pointer in `render_data.customData`, identical to what it would see
|
||
// if `Clay_Custom` did not exist as an intermediary.
|
||
patched := render_command.renderData.custom
|
||
// Default to nil so a zero-init `Clay_Custom` (no variant set) and an originally-nil
|
||
// `customData` both surface to the callback as `customData = nil`.
|
||
patched.customData = nil
|
||
if custom_data_pointer := render_command.renderData.custom.customData; custom_data_pointer != nil {
|
||
switch custom_value in (^Clay_Custom)(custom_data_pointer)^ {
|
||
case Backdrop_Marker: // 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
|
||
// `Clay_Custom` variant tag 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,
|
||
)
|
||
case rawptr: patched.customData = custom_value
|
||
}
|
||
}
|
||
if custom_draw != nil {
|
||
custom_draw(layer, bounds, patched)
|
||
} else if patched.customData != nil {
|
||
log.panicf(
|
||
"Received clay render command of type custom with non-nil user data 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;
|
||
// - the command's `customData` points at a `Clay_Custom` whose active variant is
|
||
// `Backdrop_Marker` (the walker has already verified this via `is_clay_backdrop`).
|
||
//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,
|
||
}
|
||
// Type-asserting form (no `, ok`): panics loudly if the variant tag changed since
|
||
// `is_clay_backdrop`, which is the desired tripwire for a heap-corruption bug in
|
||
// user-managed customData.
|
||
marker := (^Clay_Custom)(cmd.renderData.custom.customData).(Backdrop_Marker)
|
||
backdrop_blur(
|
||
layer,
|
||
bounds,
|
||
gaussian_sigma = marker.sigma,
|
||
tint = marker.tint,
|
||
radii = marker.radii,
|
||
feather_ppx = marker.feather_ppx,
|
||
)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// Clear the merge stack at scope/stratum boundaries: any pending candidates from the
|
||
// pre-scope (or pre-transition) commands stay as plain primitives — they can't merge
|
||
// with Borders on the far side of the boundary because that would change draw order.
|
||
clear(&GLOB.clay_merge_open_stack)
|
||
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
|
||
|
||
// Start with a clean merge stack. The stack is also cleared by
|
||
// `flush_deferred_and_close_backdrop_scope` at every stratum boundary; both clears together
|
||
// ensure merge candidates never pair across a boundary that would shift draw order.
|
||
clear(&GLOB.clay_merge_open_stack)
|
||
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 {
|
||
// Rectangle/Image dispatches push merge candidates; Border dispatches pop the stack
|
||
// to retroactively add an outline to a matching candidate. See
|
||
// `try_dispatch_clay_border_merge` for the matching semantics.
|
||
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,
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Buffer ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
//INTERNAL
|
||
Buffer :: struct {
|
||
gpu: ^sdl.GPUBuffer,
|
||
transfer: ^sdl.GPUTransferBuffer,
|
||
size: u32,
|
||
}
|
||
|
||
//INTERNAL
|
||
@(require_results)
|
||
create_buffer :: proc(
|
||
device: ^sdl.GPUDevice,
|
||
size: u32,
|
||
gpu_usage: sdl.GPUBufferUsageFlags,
|
||
) -> (
|
||
buffer: Buffer,
|
||
ok: bool,
|
||
) {
|
||
gpu := sdl.CreateGPUBuffer(device, sdl.GPUBufferCreateInfo{usage = gpu_usage, size = size})
|
||
if gpu == nil {
|
||
log.errorf("Failed to create GPU buffer (size=%d): %s", size, sdl.GetError())
|
||
return buffer, false
|
||
}
|
||
transfer := sdl.CreateGPUTransferBuffer(
|
||
device,
|
||
sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size},
|
||
)
|
||
if transfer == nil {
|
||
sdl.ReleaseGPUBuffer(device, gpu)
|
||
log.errorf("Failed to create GPU transfer buffer (size=%d): %s", size, sdl.GetError())
|
||
return buffer, false
|
||
}
|
||
return Buffer{gpu, transfer, size}, true
|
||
}
|
||
|
||
//INTERNAL
|
||
grow_buffer_if_needed :: proc(
|
||
device: ^sdl.GPUDevice,
|
||
buffer: ^Buffer,
|
||
new_size: u32,
|
||
gpu_usage: sdl.GPUBufferUsageFlags,
|
||
) {
|
||
if new_size > buffer.size {
|
||
log.debug("Resizing buffer from", buffer.size, "to", new_size)
|
||
destroy_buffer(device, buffer)
|
||
buffer.gpu = sdl.CreateGPUBuffer(device, sdl.GPUBufferCreateInfo{usage = gpu_usage, size = new_size})
|
||
if buffer.gpu == nil {
|
||
log.panicf("Failed to grow GPU buffer (new_size=%d): %s", new_size, sdl.GetError())
|
||
}
|
||
buffer.transfer = sdl.CreateGPUTransferBuffer(
|
||
device,
|
||
sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = new_size},
|
||
)
|
||
if buffer.transfer == nil {
|
||
log.panicf("Failed to grow GPU transfer buffer (new_size=%d): %s", new_size, sdl.GetError())
|
||
}
|
||
buffer.size = new_size
|
||
}
|
||
}
|
||
|
||
//INTERNAL
|
||
destroy_buffer :: proc(device: ^sdl.GPUDevice, buffer: ^Buffer) {
|
||
sdl.ReleaseGPUBuffer(device, buffer.gpu)
|
||
sdl.ReleaseGPUTransferBuffer(device, buffer.transfer)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Math ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
//INTERNAL
|
||
ortho_rh :: proc(left: f32, right: f32, bottom: f32, top: f32, near: f32, far: f32) -> matrix[4, 4]f32 {
|
||
return matrix[4, 4]f32{
|
||
2.0 / (right - left), 0.0, 0.0, -(right + left) / (right - left),
|
||
0.0, 2.0 / (top - bottom), 0.0, -(top + bottom) / (top - bottom),
|
||
0.0, 0.0, -2.0 / (far - near), -(far + near) / (far - near),
|
||
0.0, 0.0, 0.0, 1.0,
|
||
}
|
||
}
|
||
|
||
// 2x3 affine transform for 2D pivot-rotation.
|
||
// Used internally by rotation-aware drawing procs.
|
||
Transform_2D :: struct {
|
||
m00, m01: f32, // row 0: rotation/scale
|
||
m10, m11: f32, // row 1: rotation/scale
|
||
tx, ty: f32, // translation
|
||
}
|
||
|
||
// Build a pivot-rotation transform.
|
||
//
|
||
// Semantics (raylib-style):
|
||
// The point whose local coordinates equal `origin` lands at `pos` in world space.
|
||
// The rest of the shape rotates around that pivot.
|
||
//
|
||
// Formula: p_world = pos + R(θ) · (p_local - origin)
|
||
//
|
||
// Parameters:
|
||
// pos – world-space position where the pivot lands.
|
||
// origin – pivot point in local space (measured from the shape's natural reference point).
|
||
// rotation_deg – rotation in degrees, counter-clockwise.
|
||
//
|
||
build_pivot_rotation :: proc(position: Vec2, origin: Vec2, rotation_deg: f32) -> Transform_2D {
|
||
radians := math.to_radians(rotation_deg)
|
||
cos_angle := math.cos(radians)
|
||
sin_angle := math.sin(radians)
|
||
return build_pivot_rotation_sc(position, origin, cos_angle, sin_angle)
|
||
}
|
||
|
||
// Variant of build_pivot_rotation that accepts pre-computed cos/sin values,
|
||
// avoiding redundant trigonometry when the caller has already computed them.
|
||
build_pivot_rotation_sc :: #force_inline proc(
|
||
position: Vec2,
|
||
origin: Vec2,
|
||
cos_angle, sin_angle: f32,
|
||
) -> Transform_2D {
|
||
return Transform_2D {
|
||
m00 = cos_angle,
|
||
m01 = -sin_angle,
|
||
m10 = sin_angle,
|
||
m11 = cos_angle,
|
||
tx = position.x - (cos_angle * origin.x - sin_angle * origin.y),
|
||
ty = position.y - (sin_angle * origin.x + cos_angle * origin.y),
|
||
}
|
||
}
|
||
|
||
// Apply the transform to a local-space point, producing a world-space point.
|
||
apply_transform :: #force_inline proc(transform: Transform_2D, point: Vec2) -> Vec2 {
|
||
return {
|
||
transform.m00 * point.x + transform.m01 * point.y + transform.tx,
|
||
transform.m10 * point.x + transform.m11 * point.y + transform.ty,
|
||
}
|
||
}
|
||
|
||
// Fast-path check callers use BEFORE building a transform.
|
||
// Returns true if either the origin is non-zero or rotation is non-zero,
|
||
// meaning a transform actually needs to be computed.
|
||
needs_transform :: #force_inline proc(origin: Vec2, rotation: f32) -> bool {
|
||
return origin != {0, 0} || rotation != 0
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// ----- Anchors ------------
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
// Return Vec2 pixel offsets for use as the `origin` parameter of draw calls.
|
||
// Composable with normal vector +/- arithmetic.
|
||
//
|
||
// Text anchor helpers are in text.odin (they depend on measure_text / SDL_ttf).
|
||
|
||
// Returns uniform radii (all corners the same) as a fraction of the shorter side.
|
||
// `roundness` is clamped to [0, 1]; 0 = sharp corners, 1 = fully rounded (stadium or circle).
|
||
uniform_radii :: #force_inline proc(rect: Rectangle, roundness: f32) -> Rectangle_Radii {
|
||
cr := min(rect.width, rect.height) * clamp(roundness, 0, 1) * 0.5
|
||
return {cr, cr, cr, cr}
|
||
}
|
||
|
||
//----- Rectangle anchors (origin measured from rectangle's top-left) ----------------------------------
|
||
|
||
center_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {rectangle.width * 0.5, rectangle.height * 0.5}
|
||
}
|
||
|
||
top_left_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {0, 0}
|
||
}
|
||
|
||
top_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {rectangle.width * 0.5, 0}
|
||
}
|
||
|
||
top_right_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {rectangle.width, 0}
|
||
}
|
||
|
||
left_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {0, rectangle.height * 0.5}
|
||
}
|
||
|
||
right_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {rectangle.width, rectangle.height * 0.5}
|
||
}
|
||
|
||
bottom_left_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {0, rectangle.height}
|
||
}
|
||
|
||
bottom_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {rectangle.width * 0.5, rectangle.height}
|
||
}
|
||
|
||
bottom_right_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 {
|
||
return {rectangle.width, rectangle.height}
|
||
}
|
||
|
||
//----- Triangle anchors (origin measured from AABB top-left) ----------------------------------
|
||
|
||
center_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
bounds_min := Vec2{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)}
|
||
return (v1 + v2 + v3) / 3 - bounds_min
|
||
}
|
||
|
||
top_left_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
return {0, 0}
|
||
}
|
||
|
||
top_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
min_x := min(v1.x, v2.x, v3.x)
|
||
max_x := max(v1.x, v2.x, v3.x)
|
||
return {(max_x - min_x) * 0.5, 0}
|
||
}
|
||
|
||
top_right_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
min_x := min(v1.x, v2.x, v3.x)
|
||
max_x := max(v1.x, v2.x, v3.x)
|
||
return {max_x - min_x, 0}
|
||
}
|
||
|
||
left_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
min_y := min(v1.y, v2.y, v3.y)
|
||
max_y := max(v1.y, v2.y, v3.y)
|
||
return {0, (max_y - min_y) * 0.5}
|
||
}
|
||
|
||
right_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
bounds_min := Vec2{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)}
|
||
bounds_max := Vec2{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)}
|
||
return {bounds_max.x - bounds_min.x, (bounds_max.y - bounds_min.y) * 0.5}
|
||
}
|
||
|
||
bottom_left_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
min_y := min(v1.y, v2.y, v3.y)
|
||
max_y := max(v1.y, v2.y, v3.y)
|
||
return {0, max_y - min_y}
|
||
}
|
||
|
||
bottom_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
bounds_min := Vec2{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)}
|
||
bounds_max := Vec2{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)}
|
||
return {(bounds_max.x - bounds_min.x) * 0.5, bounds_max.y - bounds_min.y}
|
||
}
|
||
|
||
bottom_right_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 {
|
||
bounds_min := Vec2{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)}
|
||
bounds_max := Vec2{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)}
|
||
return bounds_max - bounds_min
|
||
}
|
||
|
||
//----- Procedure groups ----------------------------------
|
||
|
||
center_of :: proc {
|
||
center_of_rectangle,
|
||
center_of_triangle,
|
||
center_of_text,
|
||
}
|
||
|
||
top_left_of :: proc {
|
||
top_left_of_rectangle,
|
||
top_left_of_triangle,
|
||
top_left_of_text,
|
||
}
|
||
|
||
top_of :: proc {
|
||
top_of_rectangle,
|
||
top_of_triangle,
|
||
top_of_text,
|
||
}
|
||
|
||
top_right_of :: proc {
|
||
top_right_of_rectangle,
|
||
top_right_of_triangle,
|
||
top_right_of_text,
|
||
}
|
||
|
||
left_of :: proc {
|
||
left_of_rectangle,
|
||
left_of_triangle,
|
||
left_of_text,
|
||
}
|
||
|
||
right_of :: proc {
|
||
right_of_rectangle,
|
||
right_of_triangle,
|
||
right_of_text,
|
||
}
|
||
|
||
bottom_left_of :: proc {
|
||
bottom_left_of_rectangle,
|
||
bottom_left_of_triangle,
|
||
bottom_left_of_text,
|
||
}
|
||
|
||
bottom_of :: proc {
|
||
bottom_of_rectangle,
|
||
bottom_of_triangle,
|
||
bottom_of_text,
|
||
}
|
||
|
||
bottom_right_of :: proc {
|
||
bottom_right_of_rectangle,
|
||
bottom_right_of_triangle,
|
||
bottom_right_of_text,
|
||
}
|