Files
levlib/draw/draw.odin
T
2026-05-11 20:00:55 -07:00

1755 lines
72 KiB
Odin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 0255 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.01.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,
}