Files
levlib/draw/draw.odin
T
2026-05-01 18:01:48 -07:00

1334 lines
51 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.
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_PX :: 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.
// -- 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.
//
// 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 position parameters in the draw API (center, origin, start_position, end_position, etc.)
// use this coordinate system.
//
// Units are logical pixels (pre-DPI-scaling). The renderer multiplies by dpi_scaling internally
// before uploading to the GPU. A Vec2{100, 50} refers to the same visual location regardless of
// display DPI.
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.
//
// Channel order: R, G, B, A (indices 0, 1, 2, 3). Alpha 255 is fully opaque, 0 is fully
// transparent. This matches the GPU-side layout: the shader unpacks via unpackUnorm4x8 which
// reads the bytes in memory order as R, G, B, A and normalizes each to [0, 1].
//
// When used in the Core_2D_Primitive or Gaussian_Blur_Primitive structs (e.g. .color), the 4 bytes
// are stored as a u32 in native byte order and unpacked by the shader.
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. The tessellated vertex path and text path require
// premultiplied colors because the blend state is ONE, ONE_MINUS_SRC_ALPHA and the
// tessellated fragment shader passes vertex color through without further modification.
// Users who construct Vertex_2D structs manually for prepare_shape must premultiply their colors.
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.Tessellated
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.
//
// MSAA is intentionally NOT supported. SDF text and shapes compute coverage analytically via
// `smoothstep`, so they don't benefit from multisampling. Tessellated user geometry submitted
// via `prepare_shape` is not anti-aliased — if you need AA on tessellated content, render it
// to your own offscreen target and submit it as a texture. RAD Debugger and the SBC target
// (Mali Valhall, where MSAA's per-tile bandwidth multiplier is expensive) drove this decision.
@(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),
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)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Frame ------------
// ---------------------------------------------------------------------------------------------------------------------
// 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]
}
// 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]
}
// Open a backdrop scope on `layer`. All subsequent draws on `layer` until the matching
// `end_backdrop` must be backdrop primitives (currently only `backdrop_blur`). Non-backdrop
// draws inside a scope, or backdrop draws outside one, panic.
//
// Bracket scheduling: each scope produces one bracket at render time. Within the scope,
// per-sigma sub-batch coalescing still applies (two contiguous backdrop_blur calls with
// the same sigma share an instanced composite draw and a single H+V blur pass pair).
//
// Multiple begin/end pairs per layer are allowed: each pair is its own bracket, and
// non-backdrop draws between pairs render in their submission position relative to the
// brackets. Use this for layered frost effects.
begin_backdrop :: proc(layer: ^Layer) {
if GLOB.open_backdrop_layer != nil {
log.panicf("begin_backdrop called while a scope is already open on layer %p", GLOB.open_backdrop_layer)
}
GLOB.open_backdrop_layer = layer
}
// Close the backdrop scope opened by `begin_backdrop`. Must be called on the same layer that
// the scope was opened on; the layer pointer mismatch is a hard error rather than a silent
// recovery to surface integration bugs early.
end_backdrop :: proc(layer: ^Layer) {
if GLOB.open_backdrop_layer != layer {
log.panicf("end_backdrop on wrong layer (open=%p, ended=%p)", GLOB.open_backdrop_layer, layer)
}
GLOB.open_backdrop_layer = nil
}
// Convenience wrapper for the common case of a backdrop scope tied to a block. Use with
// defer-style block scoping:
//
// {
// draw.backdrop_scope(layer)
// draw.backdrop_blur(layer, ...)
// } // end_backdrop fires automatically
@(deferred_in = end_backdrop)
backdrop_scope :: #force_inline proc(layer: ^Layer) {
begin_backdrop(layer)
}
// Render primitives. clear_color is the background fill before any layers are drawn.
end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = DFT_CLEAR_COLOR) {
cmd_buffer := sdl.AcquireGPUCommandBuffer(device)
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())
}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- 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.
//
// - `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`, `cornerRadius`, and the `customData` pointer the caller
// attached to `clay.CustomElementConfig.customData`.
//
// 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),
}
// Magic-number-tagged struct that user app data points at via Clay's customData field.
// `prepare_clay_batch` recognizes these and routes them through a backdrop scope automatically.
// The user populates a `Backdrop_Marker`, points `clay.CustomElementConfig.customData` at it,
// and the integration walks the command stream, opening/closing scopes around contiguous
// backdrop runs. Magic-number sentinel chosen over a separate userData flag so the marker
// type stays self-describing in core dumps and in any non-Odin debugger view of the heap.
Backdrop_Marker :: struct {
magic: u32,
sigma: f32,
tint: Color,
radii: Rectangle_Radii,
feather_px: f32,
}
// 'BDPT' in big-endian ASCII. Picked for greppability and to be obviously non-zero in
// uninitialized memory; user code that forgets to set the magic field gets routed through
// the regular custom_draw path and surfaces as "my custom draw never fired," not as a
// silent backdrop schedule.
BACKDROP_MARKER_MAGIC :: u32(0x42445054)
// Returns true if this Clay render command represents a backdrop primitive.
// Identified by a magic-number sentinel in the first 4 bytes of customData.
is_clay_backdrop :: proc(cmd: ^clay.RenderCommand) -> bool {
if cmd.commandType != .Custom do return false
p := cmd.renderData.custom.customData
if p == nil do return false
return (^Backdrop_Marker)(p).magic == BACKDROP_MARKER_MAGIC
}
// Dispatch a single non-backdrop Clay render command to the appropriate `draw` primitive.
// Extracted from the main `prepare_clay_batch` walk so that the deferred-buffer flush path
// can replay commands accumulated during an open backdrop scope without duplicating the
// per-command lowering code.
//INTERNAL
dispatch_clay_command :: proc(
layer: ^Layer,
render_command: ^clay.RenderCommand,
custom_draw: Custom_Draw,
temp_allocator: runtime.Allocator,
) {
// Translate bounding box of the primitive by the layer position
bounds := Rectangle {
x = render_command.boundingBox.x + layer.bounds.x,
y = render_command.boundingBox.y + layer.bounds.y,
width = render_command.boundingBox.width,
height = render_command.boundingBox.height,
}
switch render_command.commandType {
case clay.RenderCommandType.None:
log.errorf(
"Received render command with type None. This generally means we're in some kind of fucked up state.",
)
case clay.RenderCommandType.Text:
render_data := render_command.renderData.text
txt := string(render_data.stringContents.chars[:render_data.stringContents.length])
c_text := strings.clone_to_cstring(txt, temp_allocator)
defer delete(c_text, temp_allocator)
// Clay render-command IDs are derived via Clay's internal HashNumber (Jenkins-family)
// and namespaced with .Clay so they can never collide with user-provided custom text IDs.
sdl_text := cache_get_or_update(
Cache_Key{render_command.id, .Clay},
c_text,
get_font(render_data.fontId, render_data.fontSize),
)
prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)})
case clay.RenderCommandType.Image:
// Any texture
render_data := render_command.renderData.image
if render_data.imageData == nil do return
img_data := (^Clay_Image_Data)(render_data.imageData)^
cr := render_data.cornerRadius
radii := Rectangle_Radii {
top_left = cr.topLeft,
top_right = cr.topRight,
bottom_right = cr.bottomRight,
bottom_left = cr.bottomLeft,
}
// Background color behind the image (Clay allows it)
bg := color_from_clay(render_data.backgroundColor)
if bg.a > 0 {
rectangle(layer, bounds, bg, radii = radii)
}
// Compute fit UVs
uv, sampler, inner := fit_params(img_data.fit, bounds, img_data.texture_id)
// Draw the image
rectangle(
layer,
inner,
Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv, sampler = sampler},
radii = radii,
)
case clay.RenderCommandType.ScissorStart:
if bounds.width == 0 || bounds.height == 0 do return
curr_scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
if curr_scissor.sub_batch_len != 0 {
// Scissor has some content, need to make a new scissor
new := Scissor {
sub_batch_start = curr_scissor.sub_batch_start + curr_scissor.sub_batch_len,
bounds = sdl.Rect {
c.int(bounds.x * GLOB.dpi_scaling),
c.int(bounds.y * GLOB.dpi_scaling),
c.int(bounds.width * GLOB.dpi_scaling),
c.int(bounds.height * GLOB.dpi_scaling),
},
}
append(&GLOB.scissors, new)
layer.scissor_len += 1
} else {
curr_scissor.bounds = sdl.Rect {
c.int(bounds.x * GLOB.dpi_scaling),
c.int(bounds.y * GLOB.dpi_scaling),
c.int(bounds.width * GLOB.dpi_scaling),
c.int(bounds.height * GLOB.dpi_scaling),
}
}
case clay.RenderCommandType.ScissorEnd:
case clay.RenderCommandType.Rectangle:
render_data := render_command.renderData.rectangle
cr := render_data.cornerRadius
color := color_from_clay(render_data.backgroundColor)
radii := Rectangle_Radii {
top_left = cr.topLeft,
top_right = cr.topRight,
bottom_right = cr.bottomRight,
bottom_left = cr.bottomLeft,
}
rectangle(layer, bounds, color, radii = radii)
case clay.RenderCommandType.Border:
render_data := render_command.renderData.border
cr := render_data.cornerRadius
color := color_from_clay(render_data.color)
thickness := f32(render_data.width.top)
radii := Rectangle_Radii {
top_left = cr.topLeft,
top_right = cr.topRight,
bottom_right = cr.bottomRight,
bottom_left = cr.bottomLeft,
}
rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii)
case clay.RenderCommandType.Custom: if is_clay_backdrop(render_command) {
// The walker pre-filters backdrops into `dispatch_clay_backdrop` and never feeds
// them here; reaching this branch means either the walker logic is broken or the
// `customData` pointee mutated between the walker's `is_clay_backdrop` check and
// this re-check (heap corruption / lifetime bug in user-managed customData
// memory). Both are renderer-level bugs that warrant a hard failure rather than a
// silently-dropped panel.
log.panicf(
"backdrop marker reached dispatch_clay_command; either the prepare_clay_batch walker is misrouting commands or the customData pointee at %p was mutated mid-frame",
render_command.renderData.custom.customData,
)
} else if custom_draw != nil {
custom_draw(layer, bounds, render_command.renderData.custom)
} else {
log.panicf("Received clay render command of type custom but no custom_draw proc provided.")
}
}
}
// Dispatch a single backdrop Clay render command to `backdrop_blur` on the active layer.
// Caller guarantees a backdrop scope is open on `layer` so the underlying
// `append_or_extend_sub_batch` contract assertion is satisfied.
//INTERNAL
dispatch_clay_backdrop :: proc(layer: ^Layer, cmd: ^clay.RenderCommand) {
bounds := Rectangle {
x = cmd.boundingBox.x + layer.bounds.x,
y = cmd.boundingBox.y + layer.bounds.y,
width = cmd.boundingBox.width,
height = cmd.boundingBox.height,
}
marker := (^Backdrop_Marker)(cmd.renderData.custom.customData)
backdrop_blur(
layer,
bounds,
gaussian_sigma = marker.sigma,
tint = marker.tint,
radii = marker.radii,
feather_px = marker.feather_px,
)
}
// Close the in-flight backdrop scope (if open) and replay every command accumulated in the
// deferred index buffer. Ordering: end_backdrop first so deferred non-backdrop draws land
// at submission position relative to the bracket they followed (the bracket is now closed,
// so these draws render after it). Used at every zIndex transition and at end of stream.
//INTERNAL
flush_deferred_and_close_backdrop_scope :: proc(
layer: ^Layer,
batch: ^ClayBatch,
deferred_indices: ^[dynamic]i32,
backdrop_scope_open: ^bool,
custom_draw: Custom_Draw,
temp_allocator: runtime.Allocator,
) {
if backdrop_scope_open^ {
end_backdrop(layer)
backdrop_scope_open^ = false
}
for index in deferred_indices^ {
cmd := clay.RenderCommandArray_Get(&batch.cmds, index)
dispatch_clay_command(layer, cmd, custom_draw, temp_allocator)
}
clear(deferred_indices)
}
// Process Clay render commands into shape, text, and backdrop primitives.
//
// Single-walk dispatcher with a deferred buffer. The walk does three things per command:
// 1. zIndex transitions: close the in-flight scope, flush any deferred non-backdrop
// commands into the current layer, then open a new layer seeded with `base_layer.bounds`
// (NOT the bumping element's bounds — Clay's floating elements with `clipTo = .None`
// should not be over-clipped, and `clipTo = .AttachedParent` floating elements get a
// Clay-emitted ScissorStart immediately afterward that narrows correctly).
// 2. Backdrop commands: open a scope on first encounter (extending it on subsequent ones),
// then dispatch the backdrop_blur call.
// 3. Non-backdrop commands during an open scope: append to the deferred buffer for replay
// after the scope closes. The buffer holds command indices, not pointers, so it stays
// valid even if the underlying ClayArray reallocates.
// At end of stream, flush whatever remains.
prepare_clay_batch :: proc(
base_layer: ^Layer,
batch: ^ClayBatch,
mouse_wheel_delta: [2]f32,
frame_time: f32 = 0,
custom_draw: Custom_Draw = nil,
temp_allocator := context.temp_allocator,
) {
mouse_pos: [2]f32
mouse_flags := sdl.GetMouseState(&mouse_pos.x, &mouse_pos.y)
// Update clay internals
clay.SetPointerState(
clay.Vector2{mouse_pos.x - base_layer.bounds.x, mouse_pos.y - base_layer.bounds.y},
.LEFT in mouse_flags,
)
clay.UpdateScrollContainers(true, mouse_wheel_delta, frame_time)
layer := base_layer
command_count := int(batch.cmds.length)
deferred_indices := make([dynamic]i32, 0, 16, temp_allocator)
backdrop_scope_open := false
// Seed from GLOB.clay_z_index so multi-batch frames preserve the original semantics: a
// later call to `prepare_clay_batch` doesn't re-trigger layer splits for zIndex values
// the previous batch already saw.
previous_z_index := GLOB.clay_z_index
for i in 0 ..< command_count {
cmd := clay.RenderCommandArray_Get(&batch.cmds, i32(i))
// zIndex transition: close out current stratum, create new layer, continue.
if cmd.zIndex > previous_z_index {
log.debug("Higher zIndex found, creating new layer & setting z_index to", cmd.zIndex)
flush_deferred_and_close_backdrop_scope(
layer,
batch,
&deferred_indices,
&backdrop_scope_open,
custom_draw,
temp_allocator,
)
layer = new_layer(layer, base_layer.bounds)
previous_z_index = cmd.zIndex
// Keep GLOB.clay_z_index in sync for any external readers (debug tooling, etc.).
GLOB.clay_z_index = cmd.zIndex
}
if is_clay_backdrop(cmd) {
if !backdrop_scope_open {
begin_backdrop(layer)
backdrop_scope_open = true
}
dispatch_clay_backdrop(layer, cmd)
} else if backdrop_scope_open {
append(&deferred_indices, i32(i))
} else {
dispatch_clay_command(layer, cmd, custom_draw, temp_allocator)
}
}
// End-of-stream: flush whatever remains.
flush_deferred_and_close_backdrop_scope(
layer,
batch,
&deferred_indices,
&backdrop_scope_open,
custom_draw,
temp_allocator,
)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- 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,
}