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