diff --git a/draw/README.md b/draw/README.md index e02a7c3..1505177 100644 --- a/draw/README.md +++ b/draw/README.md @@ -5,7 +5,7 @@ Clay UI integration. ## Current state -The renderer uses a single unified `Pipeline_2D_Base` (`TRIANGLELIST` pipeline) with two submission +The renderer uses a single unified `Core_2D` (`TRIANGLELIST` pipeline) with two submission modes dispatched by a push constant: - **Mode 0 (Tessellated):** Vertex buffer contains real geometry. Used for text (indexed draws into @@ -15,10 +15,10 @@ modes dispatched by a push constant: shader premultiplies the texture sample (`t.rgb *= t.a`) and computes `out = color * t`. - **Mode 1 (SDF):** A static 6-vertex unit-quad buffer is drawn instanced, with per-primitive - `Base_2D_Primitive` structs (96 bytes each) uploaded each frame to a GPU storage buffer. The vertex + `Core_2D_Primitive` structs (96 bytes each) uploaded each frame to a GPU storage buffer. The vertex shader reads `primitives[gl_InstanceIndex]`, computes world-space position from unit quad corners + primitive bounds. The fragment shader dispatches on `Shape_Kind` (encoded in the low byte of - `Base_2D_Primitive.flags`) to evaluate one of four signed distance functions: + `Core_2D_Primitive.flags`) to evaluate one of four signed distance functions: - **RRect** (kind 1) — `sdRoundedBox` with per-corner radii. Covers rectangles (sharp or rounded), circles (uniform radii = half-size), and line segments / capsules (rotated RRect with uniform radii = half-thickness). Covers filled, outlined, textured, and gradient-filled variants. @@ -28,9 +28,9 @@ modes dispatched by a push constant: normals. Covers full rings, partial arcs, and pie slices (`inner_radius = 0`). All SDF shapes support fill, outline, solid color, 2-color linear gradients, 2-color radial -gradients, and texture fills via `Shape_Flags` (see `pipeline_2d_base.odin`). The texture UV rect +gradients, and texture fills via `Shape_Flags` (see `core_2d.odin`). The texture UV rect (`uv_rect: [4]f32`) and the gradient/outline parameters (`effects: Gradient_Outline`) live in their -own 16-byte slots in `Base_2D_Primitive`, so a primitive can carry texture and outline simultaneously. +own 16-byte slots in `Core_2D_Primitive`, so a primitive can carry texture and outline simultaneously. Gradient and texture remain mutually exclusive at the fill-source level (a Brush variant chooses one or the other) since they share the worst-case fragment-shader register path. @@ -433,19 +433,19 @@ our design: ### Main pipeline: SDF + tessellated (unified) The main pipeline serves two submission modes through a single `TRIANGLELIST` pipeline and a single -vertex input layout, distinguished by a `mode` field in the `Vertex_Uniforms` push constant -(`Draw_Mode.Tessellated = 0`, `Draw_Mode.SDF = 1`), pushed per draw call via `push_globals`. The +vertex input layout, distinguished by a `mode` field in the `Vertex_Uniforms_2D` push constant +(`Core_2D_Mode.Tessellated = 0`, `Core_2D_Mode.SDF = 1`), pushed per draw call via `push_globals`. The vertex shader branches on this uniform to select the tessellated or SDF code path. - **Tessellated mode** (`mode = 0`): direct vertex buffer with explicit geometry. Used for text (SDL_ttf atlas sampling), triangles, triangle fans/strips, single-pixel points, and any user-provided raw vertex geometry. - **SDF mode** (`mode = 1`): shared unit-quad vertex buffer + GPU storage buffer of - `Base_2D_Primitive` structs, drawn instanced. Used for all shapes with closed-form signed distance + `Core_2D_Primitive` structs, drawn instanced. Used for all shapes with closed-form signed distance functions. Both modes use the same fragment shader. The fragment shader checks `Shape_Kind` (low byte of -`Base_2D_Primitive.flags`): kind 0 (`Solid`) is the tessellated path, which premultiplies the texture +`Core_2D_Primitive.flags`): kind 0 (`Solid`) is the tessellated path, which premultiplies the texture sample and computes `out = color * t`; kinds 1–4 dispatch to one of four SDF functions (RRect, NGon, Ellipse, Ring_Arc) and apply gradient/texture/outline/solid color based on `Shape_Flags` bits. @@ -454,7 +454,7 @@ Ellipse, Ring_Arc) and apply gradient/texture/outline/solid color based on `Shap CPU-side adaptive tessellation for curved shapes (the current approach) has three problems: 1. **Vertex bandwidth.** A rounded rectangle with four corner arcs produces ~250 vertices × 20 bytes - = 5 KB. An SDF rounded rectangle is one `Base_2D_Primitive` struct (96 bytes) plus 4 shared + = 5 KB. An SDF rounded rectangle is one `Core_2D_Primitive` struct (96 bytes) plus 4 shared unit-quad vertices. That is roughly a 50× reduction per shape. 2. **Quality.** Tessellated curves are piecewise-linear approximations. At high DPI or under @@ -486,7 +486,7 @@ SDF primitives are submitted via a GPU storage buffer indexed by `gl_InstanceInd shader, rather than encoding per-primitive data redundantly in vertex attributes. This follows the pattern used by both Zed GPUI and vger-rs. -Each SDF shape is described by a single `Base_2D_Primitive` struct (96 bytes) in the storage +Each SDF shape is described by a single `Core_2D_Primitive` struct (96 bytes) in the storage buffer. The vertex shader reads `primitives[gl_InstanceIndex]`, computes the quad corner position from the unit vertex and the primitive's bounds, and passes shape parameters to the fragment shader via `flat` interpolated varyings. @@ -501,10 +501,10 @@ in a draw call has the same mode — so it is effectively free on all modern GPU #### Shape kinds and SDF dispatch -The fragment shader dispatches on `Shape_Kind` (low byte of `Base_2D_Primitive.flags`) to evaluate -one of four signed distance functions. The `Shape_Kind` enum and per-kind `*_Params` structs are -defined in `pipeline_2d_base.odin`. CPU-side drawing procs in `shapes.odin` build the appropriate -`Base_2D_Primitive` and set the kind automatically: +The fragment shader dispatches on `Shape_Kind` (low byte of `Core_2D_Primitive.flags`) to evaluate +one of four signed distance functions. The `Shape_Kind` enum, per-kind `*_Params` structs, and +CPU-side drawing procs all live in `core_2d.odin`. The drawing procs build the appropriate +`Core_2D_Primitive` and set the kind automatically: Each user-facing shape proc accepts a `Brush` union (color, linear gradient, radial gradient, or textured fill) as its fill source, plus optional outline parameters. The procs map to SDF @@ -522,7 +522,7 @@ kinds as follows: | `ring` (pie slice) | `Ring_Arc` | Annular radial SDF | `inner_radius = 0`, angular clipping via `start/end_angle` | The `Shape_Flags` bit set controls per-primitive rendering mode (outline, gradient, texture, rotation, -arc geometry). See the `Shape_Flag` enum in `pipeline_2d_base.odin` for the authoritative flag +arc geometry). See the `Shape_Flag` enum in `core_2d.odin` for the authoritative flag definitions and bit assignments. **What stays tessellated:** @@ -662,7 +662,7 @@ for the factor-selection table and rationale). #### Submission-order trade-off Within Pass A and Pass B, sub-batches render in the user's submission order. What the bracket model -sacrifices is *interleaved* ordering between backdrop and non-backdrop content within a single +sacrifices is _interleaved_ ordering between backdrop and non-backdrop content within a single layer. A non-backdrop sub-batch submitted between two backdrops still renders in Pass B (after the bracket), not at its submission position. Worked example: @@ -675,7 +675,7 @@ draw.gaussian_blur(layer, panelB, sigma=12) // 4 Backdrop → Bracket draw.text(layer, "label", ...) // 5 Text → Pass B (drawn ON TOP of both panels) ``` -In this layer, panelB does *not* see card_red — even though card_red was submitted before panelB — +In this layer, panelB does _not_ see card_red — even though card_red was submitted before panelB — because both backdrops sample `source_texture` as it stood at the bracket entry, which is after Pass A and before card_red has rendered. card_red ends up on top of panelA, not underneath it. @@ -709,7 +709,7 @@ abstraction. This matches the cost/complexity envelope of iOS `UIVisualEffectVie The vertex struct is unchanged from the current 20-byte layout: ``` -Vertex :: struct { +Vertex_2D :: struct { position: [2]f32, // 0: screen-space position uv: [2]f32, // 8: atlas UV (text) or unused (shapes) color: Color, // 16: u8x4, GPU-normalized to float @@ -721,10 +721,10 @@ draws, `position` carries actual world-space geometry. For SDF draws, `position` corners (0,0 to 1,1) and the vertex shader computes world-space position from the storage-buffer primitive's bounds. -The `Base_2D_Primitive` struct for SDF shapes lives in the storage buffer, not in vertex attributes: +The `Core_2D_Primitive` struct for SDF shapes lives in the storage buffer, not in vertex attributes: ``` -Base_2D_Primitive :: struct { +Core_2D_Primitive :: struct { bounds: [4]f32, // 0: min_x, min_y, max_x, max_y color: Color, // 16: u8x4, unpacked in shader via unpackUnorm4x8 flags: u32, // 20: low byte = Shape_Kind, bits 8+ = Shape_Flags @@ -738,7 +738,7 @@ Base_2D_Primitive :: struct { ``` `Shape_Params` is a `#raw_union` over `RRect_Params`, `NGon_Params`, `Ellipse_Params`, and -`Ring_Arc_Params` (plus a `raw: [8]f32` view), defined in `pipeline_2d_base.odin`. Each SDF kind +`Ring_Arc_Params` (plus a `raw: [8]f32` view), defined in `core_2d.odin`. Each SDF kind writes its own params variant; the fragment shader reads the appropriate fields based on `Shape_Kind`. `Gradient_Outline` is a 16-byte struct containing `gradient_color: Color`, `outline_color: Color`, `gradient_dir_sc: u32` (packed f16 cos/sin pair), and `outline_packed: u32` (packed f16 outline diff --git a/draw/backdrop.odin b/draw/backdrop.odin index 729f571..f7437db 100644 --- a/draw/backdrop.odin +++ b/draw/backdrop.odin @@ -5,151 +5,79 @@ import "core:math" import "core:mem" import sdl "vendor:sdl3" -// Adaptive downsample design (Flutter-style). +// This file hosts the backdrop subsystem: any visual effect that samples the current +// framebuffer as input. Today the only implemented effect is Gaussian blur (frosted glass); +// future effects (refraction, mirror, etc.) will live here too. // -// The bracket picks a downsample factor per-sigma-group, not as a global constant. The choice -// is driven by Flutter's `CalculateScale` formula in -// impeller/entity/contents/filters/gaussian_blur_filter_contents.cc (originally from Skia's -// GrBlurUtils): downsample so that the sigma in working-resolution pixels stays in the -// 2..4 range. This keeps the kernel reach wide enough to hide high-frequency artifacts from -// the bilinear upsample at the composite, while keeping the kernel's discrete tap count -// small (≤3σ reach → ≈12 paired taps). +// The file is split into two top-level sections: // -// The full table, in physical pixels (sigma_logical * dpi_scaling): +// 1. Shared backdrop infrastructure — bracket coordination, source_texture lifecycle, +// sub-batch scanners. These are general to any backdrop effect: every backdrop effect +// needs a snapshot of the framebuffer (source_texture) and needs to participate in the +// bracket render-pass-boundary scheduling. When a second effect is added, its +// per-effect resources go in their own section like the Gaussian blur one below; this +// shared section stays. // -// sigma_phys ≤ 4 → factor = 1 (no downsample; source is sampled directly) -// sigma_phys ≤ 8 → factor = 2 -// sigma_phys > 8 → factor = 4 (capped) +// 2. Gaussian blur — the only effect implemented today. Owns its own PSOs, working +// textures (downsample / h_blur), per-primitive storage layout, kernel math, and +// bracket-runner inner loop. None of this is shared with future backdrop effects: a +// refraction shader would have its own PSO, its own primitive struct, and likely +// wouldn't need the downsample/h_blur intermediates at all. // -// Capped at factor=4 to favor visual quality over bandwidth at the high end. Larger factors -// (8 and 16) would lose more high-frequency detail than the kernel can mask even with the -// H+V split, and the bandwidth saving is small (the work region also shrinks quadratically, -// so most of the savings are already captured at factor=4). -// -// Working textures are sized at full swapchain resolution to support factor=1. Larger factors -// just write to a smaller sub-rect via viewport-limited rendering. Memory cost: full-res -// working textures (2 textures, RGBA8) is roughly 16 MB at 1080p, 64 MB at 4K. On modern -// GPUs this is well within budget; on Mali Valhall SBCs it's negligible against unified- -// memory headroom. -// -// The shaders read the factor as a uniform. The downsample shader has three paths (factor=1 -// identity, factor=2 single bilinear tap, factor>=4 four bilinear taps with offsets scaling -// by factor/4). The V-composite mode of backdrop_blur.frag uses inv_downsample_factor to -// scale full-res frag coords down to working-res UV. - -// Maximum number of (weight, offset) pairs in a single blur kernel. Each pair represents -// the linear-sampling pair adjustment (one bilinear fetch covering two adjacent texels); -// pair[0] is the center weight with offset 0. With 32 pairs we cover up to 63 input texels -// (1 center + 31 paired symmetric taps × 2 texels each), enough for sigma values well past -// the 4..24 typical UI range. Must match MAX_KERNEL_PAIRS in shaders/source/backdrop_blur.frag. -MAX_BACKDROP_KERNEL_PAIRS :: 32 - -// Backdrop_Primitive is the GPU-side per-primitive storage layout. Mirrors the GLSL std430 -// struct in shaders/source/backdrop_blur.vert. Field order is chosen so std430 alignment -// rules pack the struct to a clean 48-byte natural layout (no implicit padding): vec4 -// members come first (16-byte aligned at any offset), then vec2, then scalars. The total is -// a multiple of 16 so the std430 array stride matches size_of(...) exactly. -// -// Backdrop primitives are RRect-only: rectangles, rounded rectangles, and circles -// (via uniform_radii) are all expressible. Rotation is intentionally omitted — backdrop -// sampling is in screen space, so a rotated mask over a stationary blur sample would look -// visually wrong. iOS, CSS backdrop-filter, and Flutter BackdropFilter all enforce this -// implicitly; we enforce it explicitly by leaving no rotation field. -// -// Outline is also intentionally omitted. A specialized edge effect (e.g. liquid-glass-style -// refraction outlines) would be implemented as a dedicated primitive type with its own -// pipeline rather than tacked onto this one as a flag bit. -Backdrop_Primitive :: struct { - bounds: [4]f32, // 0: 16 — world-space quad (min_xy, max_xy) - radii: [4]f32, // 16: 16 — per-corner radii in physical pixels (BR, TR, BL, TL) - half_size: [2]f32, // 32: 8 — RRect half extents (physical px) - half_feather: f32, // 40: 4 — feather_px * 0.5 (SDF anti-aliasing) - color: Color, // 44: 4 — tint, packed RGBA u8x4 -} -#assert(size_of(Backdrop_Primitive) == 48) +// The `Backdrop` struct currently holds resources from both categories; field-group +// comments inside it mark which are which. When a second effect lands the struct will be +// split, but doing that pre-emptively means inventing a per-effect dispatch protocol on +// speculation. Better to keep the conflation visible (and labeled) until concrete needs +// shape the design. // --------------------------------------------------------------------------------------------------------------------- -// ----- Uniform blocks ---------------- +// ----- Shared backdrop infrastructure ------------ // --------------------------------------------------------------------------------------------------------------------- -// Vertex uniforms for the unified blur PSO (mode 0 = H-blur, mode 1 = V-composite). -// Matches the GLSL Uniforms block in shaders/source/backdrop_blur.vert. The downsample -// PSO has no vertex uniforms. -Backdrop_Vert_Uniforms :: struct { - projection: matrix[4, 4]f32, // 0: 64 — screen-space ortho (mode 1 only; mode 0 ignores) - dpi_scale: f32, // 64: 4 - mode: u32, // 68: 4 — 0 = H-blur fullscreen tri; 1 = V-composite instanced quads - _pad0: [2]f32, // 72: 8 — std140 vec4 alignment pad -} +//INTERNAL +Backdrop :: struct { + // -- Shared across all backdrop effects -- -// Fragment uniforms for the downsample PSO. Matches Uniforms block in -// shaders/source/backdrop_downsample.frag. -Backdrop_Downsample_Frag_Uniforms :: struct { - inv_source_size: [2]f32, // 0: 8 — 1.0 / source_texture pixel dimensions (full-res) - downsample_factor: u32, // 8: 4 — 1, 2, or 4 (selects identity / 1-tap / 4-tap path in shader) - _pad0: u32, // 12: 4 -} + // When any backdrop draw exists this frame, the entire frame renders into source_texture + // instead of the swapchain. Acts as the bracket's snapshot input by virtue of already + // containing the pre-bracket frame. Copied to the swapchain at frame end. + source_texture: ^sdl.GPUTexture, -// Fragment uniforms for the unified blur PSO (mode 0 + mode 1). Matches the GLSL Uniforms -// block in shaders/source/backdrop_blur.frag. The kernel array holds the linear-sampling -// pair coefficients computed CPU-side via `compute_blur_kernel`. -Backdrop_Frag_Uniforms :: struct { - inv_working_size: [2]f32, // 0: 8 — 1.0 / working-resolution texture dimensions - pair_count: u32, // 8: 4 — number of (weight, offset) pairs; pair[0] is center - mode: u32, // 12: 4 — 0 = H-blur, 1 = V-composite (must match vert mode) - direction: [2]f32, // 16: 8 — (1,0) for H-blur, (0,1) for V-composite - inv_downsample_factor: f32, // 24: 4 — 1.0 / downsample_factor (mode 1 only; mode 0 ignores) - _pad0: f32, // 28: 4 - kernel: [MAX_BACKDROP_KERNEL_PAIRS][4]f32, // 32: 512 — .x = weight, .y = offset (texels) -} + // Cached pixel dimensions for resize-detection in `ensure_backdrop_textures`. + cached_width: u32, + cached_height: u32, -// --------------------------------------------------------------------------------------------------------------------- -// ----- Pipeline --------------- -// --------------------------------------------------------------------------------------------------------------------- + // Linear-clamp sampler used for sampling source_texture (and Gaussian blur's working + // textures). Linear filtering is required by the Gaussian linear-sampling pair trick; + // any future backdrop effect that samples source_texture with bilinear interpolation + // can reuse this sampler. Clamp avoids edge-bleed at work-region boundaries. + sampler: ^sdl.GPUSampler, + + // -- Gaussian blur effect -- -Pipeline_2D_Backdrop :: struct { // Two graphics pipelines. The downsample PSO is a single-bilinear-sample fullscreen pass; // the blur PSO is mode-branched (H-blur fullscreen + V-composite instanced) and shares // one shader program for both modes via a uniform `mode` selector. downsample_pipeline: ^sdl.GPUGraphicsPipeline, blur_pipeline: ^sdl.GPUGraphicsPipeline, - // Per-instance Backdrop_Primitive storage buffer. Grows on demand via grow_buffer_if_needed. + // Per-instance Gaussian_Blur_Primitive storage buffer. Grows on demand via grow_buffer_if_needed. // All backdrop primitives across all layers in a frame share this single buffer; sub-batches // reference into it by offset. primitive_buffer: Buffer, // Working textures, allocated once at swapchain resolution and recreated only on resize. - // All three are sized at full swapchain resolution and single-sample. Larger downsample - // factors fill only a sub-rect via viewport-limited rendering (see file-header comment). - // source_texture — when any backdrop draw exists this frame, the entire frame renders - // here instead of the swapchain. Copied to the swapchain at frame - // end. Acts as the bracket's snapshot input by virtue of already - // containing the pre-bracket frame. + // Both are sized at full swapchain resolution and single-sample. Larger downsample + // factors fill only a sub-rect via viewport-limited rendering (see file-header comment + // on adaptive downsampling in the Gaussian blur section below). // downsample_texture — written by the downsample PSO. Read by the blur PSO in mode 0. // h_blur_texture — written by the blur PSO in mode 0. Read by the blur PSO in mode 1. - source_texture: ^sdl.GPUTexture, downsample_texture: ^sdl.GPUTexture, h_blur_texture: ^sdl.GPUTexture, - - // Cached pixel dimensions for resize-detection in `ensure_backdrop_textures`. - cached_width: u32, - cached_height: u32, - - // Linear-clamp sampler used for all backdrop sampling. Linear filtering is required by the - // linear-sampling pair trick (one bilinear fetch covers two adjacent texels). Clamp avoids - // edge-bleed at the work-region boundary. - sampler: ^sdl.GPUSampler, } -@(private) -create_pipeline_2d_backdrop :: proc( - device: ^sdl.GPUDevice, - window: ^sdl.Window, -) -> ( - pipeline: Pipeline_2D_Backdrop, - ok: bool, -) { +//INTERNAL +create_backdrop :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window) -> (pipeline: Backdrop, ok: bool) { // On failure, clean up any partially-created resources. defer if !ok { if pipeline.sampler != nil do sdl.ReleaseGPUSampler(device, pipeline.sampler) @@ -307,10 +235,10 @@ create_pipeline_2d_backdrop :: proc( return pipeline, false } - //----- Storage buffer for Backdrop_Primitive instances ------------- + //----- Storage buffer for Gaussian_Blur_Primitive instances ------------- pipeline.primitive_buffer = create_buffer( device, - size_of(Backdrop_Primitive) * BUFFER_INIT_SIZE, + size_of(Gaussian_Blur_Primitive) * BUFFER_INIT_SIZE, sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ}, ) or_return @@ -331,12 +259,12 @@ create_pipeline_2d_backdrop :: proc( return pipeline, false } - log.debug("Done creating backdrop pipeline") + log.debug("Done creating backdrop subsystem") return pipeline, true } -@(private) -destroy_pipeline_2d_backdrop :: proc(device: ^sdl.GPUDevice, pipeline: ^Pipeline_2D_Backdrop) { +//INTERNAL +destroy_backdrop :: proc(device: ^sdl.GPUDevice, pipeline: ^Backdrop) { if pipeline.h_blur_texture != nil do sdl.ReleaseGPUTexture(device, pipeline.h_blur_texture) if pipeline.downsample_texture != nil do sdl.ReleaseGPUTexture(device, pipeline.downsample_texture) if pipeline.source_texture != nil do sdl.ReleaseGPUTexture(device, pipeline.source_texture) @@ -346,20 +274,22 @@ destroy_pipeline_2d_backdrop :: proc(device: ^sdl.GPUDevice, pipeline: ^Pipeline if pipeline.downsample_pipeline != nil do sdl.ReleaseGPUGraphicsPipeline(device, pipeline.downsample_pipeline) } -// --------------------------------------------------------------------------------------------------------------------- -// ----- Working texture management ---- -// --------------------------------------------------------------------------------------------------------------------- +//----- Working texture management ---------------------------------- // Allocate (or reallocate, on resize) the three working textures that the backdrop bracket // uses. All three are sized at full swapchain resolution, single-sample, share the swapchain // format, and need {.COLOR_TARGET, .SAMPLER} usage so they can be written by render passes // and read by subsequent passes. // +// `source_texture` is shared infrastructure (used by every backdrop effect). +// `downsample_texture` and `h_blur_texture` are Gaussian-blur-specific intermediates; a +// future backdrop effect with no downsample/blur prep would skip them. +// // Recreates on dimension change only — same-size frames hit the early-out and skip GPU // resource churn. -@(private) +//INTERNAL ensure_backdrop_textures :: proc(device: ^sdl.GPUDevice, format: sdl.GPUTextureFormat, width, height: u32) { - pipeline := &GLOB.pipeline_2d_backdrop + pipeline := &GLOB.backdrop if pipeline.source_texture != nil && pipeline.cached_width == width && pipeline.cached_height == height { return } @@ -449,10 +379,138 @@ ensure_backdrop_textures :: proc(device: ^sdl.GPUDevice, format: sdl.GPUTextureF pipeline.cached_height = height } +//----- Frame / layer scanners ---------------------------------- + +// Returns true if any sub-batch in any layer this frame is .Backdrop kind. Called once at the +// top of `end()` to decide whether to route the whole frame to source_texture. +// O(total sub-batches) but with an early-exit on the first hit, so typical cost is tiny. +//INTERNAL +frame_has_backdrop :: proc() -> bool { + for &batch in GLOB.tmp_sub_batches { + if batch.kind == .Backdrop do return true + } + return false +} + +// Returns the absolute index of the first .Backdrop sub-batch in the layer's sub-batch range, +// or -1 if the layer has no backdrops. The index is into GLOB.tmp_sub_batches (not relative to +// layer.sub_batch_start), to match how draw_layer's render-range helpers consume it. +//INTERNAL +find_first_backdrop_in_layer :: proc(layer: ^Layer) -> int { + for i in 0 ..< layer.sub_batch_len { + abs_idx := layer.sub_batch_start + i + if GLOB.tmp_sub_batches[abs_idx].kind == .Backdrop do return int(abs_idx) + } + return -1 +} + // --------------------------------------------------------------------------------------------------------------------- -// ----- Kernel computation ------------ +// ----- Gaussian blur ------------ // --------------------------------------------------------------------------------------------------------------------- +// Adaptive downsample design (Flutter-style). +// +// The bracket picks a downsample factor per-sigma-group, not as a global constant. The choice +// is driven by Flutter's `CalculateScale` formula in +// impeller/entity/contents/filters/gaussian_blur_filter_contents.cc (originally from Skia's +// GrBlurUtils): downsample so that the sigma in working-resolution pixels stays in the +// 2..4 range. This keeps the kernel reach wide enough to hide high-frequency artifacts from +// the bilinear upsample at the composite, while keeping the kernel's discrete tap count +// small (≤3σ reach → ≈12 paired taps). +// +// The full table, in physical pixels (sigma_logical * dpi_scaling): +// +// sigma_phys ≤ 4 → factor = 1 (no downsample; source is sampled directly) +// sigma_phys ≤ 8 → factor = 2 +// sigma_phys > 8 → factor = 4 (capped) +// +// Capped at factor=4 to favor visual quality over bandwidth at the high end. Larger factors +// (8 and 16) would lose more high-frequency detail than the kernel can mask even with the +// H+V split, and the bandwidth saving is small (the work region also shrinks quadratically, +// so most of the savings are already captured at factor=4). +// +// Working textures are sized at full swapchain resolution to support factor=1. Larger factors +// just write to a smaller sub-rect via viewport-limited rendering. Memory cost: full-res +// working textures (2 textures, RGBA8) is roughly 16 MB at 1080p, 64 MB at 4K. On modern +// GPUs this is well within budget; on Mali Valhall SBCs it's negligible against unified- +// memory headroom. +// +// The shaders read the factor as a uniform. The downsample shader has three paths (factor=1 +// identity, factor=2 single bilinear tap, factor>=4 four bilinear taps with offsets scaling +// by factor/4). The V-composite mode of backdrop_blur.frag uses inv_downsample_factor to +// scale full-res frag coords down to working-res UV. + +//----- GPU types ---------------------------------- + +// Maximum number of (weight, offset) pairs in a single blur kernel. Each pair represents +// the linear-sampling pair adjustment (one bilinear fetch covering two adjacent texels); +// pair[0] is the center weight with offset 0. With 32 pairs we cover up to 63 input texels +// (1 center + 31 paired symmetric taps × 2 texels each), enough for sigma values well past +// the 4..24 typical UI range. Must match MAX_KERNEL_PAIRS in shaders/source/backdrop_blur.frag. +//INTERNAL +MAX_GAUSSIAN_BLUR_KERNEL_PAIRS :: 32 + +// Gaussian_Blur_Primitive is the GPU-side per-primitive storage layout. Mirrors the GLSL std430 +// struct in shaders/source/backdrop_blur.vert. Field order is chosen so std430 alignment +// rules pack the struct to a clean 48-byte natural layout (no implicit padding): vec4 +// members come first (16-byte aligned at any offset), then vec2, then scalars. The total is +// a multiple of 16 so the std430 array stride matches size_of(...) exactly. +// +// Gaussian blur primitives are RRect-only: rectangles, rounded rectangles, and circles +// (via uniform_radii) are all expressible. Rotation is intentionally omitted — backdrop +// sampling is in screen space, so a rotated mask over a stationary blur sample would look +// visually wrong. iOS, CSS backdrop-filter, and Flutter BackdropFilter all enforce this +// implicitly; we enforce it explicitly by leaving no rotation field. +// +// Outline is also intentionally omitted. A specialized edge effect (e.g. liquid-glass-style +// refraction outlines) would be implemented as a dedicated primitive type with its own +// pipeline rather than tacked onto this one as a flag bit. +//INTERNAL +Gaussian_Blur_Primitive :: struct { + bounds: [4]f32, // 0: 16 — world-space quad (min_xy, max_xy) + radii: [4]f32, // 16: 16 — per-corner radii in physical pixels (BR, TR, BL, TL) + half_size: [2]f32, // 32: 8 — RRect half extents (physical px) + half_feather: f32, // 40: 4 — feather_px * 0.5 (SDF anti-aliasing) + color: Color, // 44: 4 — tint, packed RGBA u8x4 +} +#assert(size_of(Gaussian_Blur_Primitive) == 48) + +// Vertex uniforms for the unified blur PSO (mode 0 = H-blur, mode 1 = V-composite). +// Matches the GLSL Uniforms block in shaders/source/backdrop_blur.vert. The downsample +// PSO has no vertex uniforms. +//INTERNAL +Gaussian_Blur_Vert_Uniforms :: struct { + projection: matrix[4, 4]f32, // 0: 64 — screen-space ortho (mode 1 only; mode 0 ignores) + dpi_scale: f32, // 64: 4 + mode: u32, // 68: 4 — 0 = H-blur fullscreen tri; 1 = V-composite instanced quads + _pad0: [2]f32, // 72: 8 — std140 vec4 alignment pad +} + +// Fragment uniforms for the downsample PSO. Matches Uniforms block in +// shaders/source/backdrop_downsample.frag. +//INTERNAL +Gaussian_Blur_Downsample_Frag_Uniforms :: struct { + inv_source_size: [2]f32, // 0: 8 — 1.0 / source_texture pixel dimensions (full-res) + downsample_factor: u32, // 8: 4 — 1, 2, or 4 (selects identity / 1-tap / 4-tap path in shader) + _pad0: u32, // 12: 4 +} + +// Fragment uniforms for the unified blur PSO (mode 0 + mode 1). Matches the GLSL Uniforms +// block in shaders/source/backdrop_blur.frag. The kernel array holds the linear-sampling +// pair coefficients computed CPU-side via `compute_blur_kernel`. +//INTERNAL +Gaussian_Blur_Frag_Uniforms :: struct { + inv_working_size: [2]f32, // 0: 8 — 1.0 / working-resolution texture dimensions + pair_count: u32, // 8: 4 — number of (weight, offset) pairs; pair[0] is center + mode: u32, // 12: 4 — 0 = H-blur, 1 = V-composite (must match vert mode) + direction: [2]f32, // 16: 8 — (1,0) for H-blur, (0,1) for V-composite + inv_downsample_factor: f32, // 24: 4 — 1.0 / downsample_factor (mode 1 only; mode 0 ignores) + _pad0: f32, // 28: 4 + kernel: [MAX_GAUSSIAN_BLUR_KERNEL_PAIRS][4]f32, // 32: 512 — .x = weight, .y = offset (texels) +} + +//----- Kernel computation ---------------------------------- + // Compute Gaussian blur kernel weights with the linear-sampling pair adjustment. // Adapted from RAD Debugger's r_d3d11_g_blur_shader_src CPU-side coefficient generation // and Daniel Rákos's "Efficient Gaussian blur with linear sampling" article. @@ -480,18 +538,23 @@ ensure_backdrop_textures :: proc(device: ^sdl.GPUDevice, format: sdl.GPUTextureF // from it, rather than the inverse (RAD Debugger's algorithm passes a tap count and derives // `stdev = (blur_count-1)/2`). Taking σ directly matches what callers expect when they read // "gaussian_sigma" — passing tap count under that name was a footgun. -@(private) -compute_blur_kernel :: proc(sigma: f32, kernel: ^[MAX_BACKDROP_KERNEL_PAIRS][4]f32) -> (pair_count: u32) { +//INTERNAL +compute_blur_kernel :: proc( + sigma: f32, + kernel: ^[MAX_GAUSSIAN_BLUR_KERNEL_PAIRS][4]f32, +) -> ( + pair_count: u32, +) { if sigma <= 0 { kernel[0] = {1, 0, 0, 0} return 1 } // Per-side discrete tap count: ceil(3*sigma) + 1 (center + 3σ reach on each side). - // Cap at the storage budget. With MAX_BACKDROP_KERNEL_PAIRS=32 each pair collapses 2 + // Cap at the storage budget. With MAX_GAUSSIAN_BLUR_KERNEL_PAIRS=32 each pair collapses 2 // discrete taps via linear-sampling, so max discrete taps per side = 1 + 31*2 = 63. discrete_taps := u32(math.ceil(3 * sigma)) + 1 - max_taps := u32(MAX_BACKDROP_KERNEL_PAIRS - 1) * 2 + 1 + max_taps := u32(MAX_GAUSSIAN_BLUR_KERNEL_PAIRS - 1) * 2 + 1 if discrete_taps > max_taps do discrete_taps = max_taps if discrete_taps < 2 { // Sigma was so small that 3σ < 1 texel; degenerate to a sharp sample. @@ -501,7 +564,7 @@ compute_blur_kernel :: proc(sigma: f32, kernel: ^[MAX_BACKDROP_KERNEL_PAIRS][4]f // Compute discrete weights[i] = exp(-i² / (2σ²)). The inv_root prefactor cancels in the // final normalization, so we skip it. - weights: [MAX_BACKDROP_KERNEL_PAIRS * 2]f32 = {} + weights: [MAX_GAUSSIAN_BLUR_KERNEL_PAIRS * 2]f32 = {} two_sigma_sq := 2 * sigma * sigma total: f32 = 0 for i in 0 ..< discrete_taps { @@ -535,38 +598,9 @@ compute_blur_kernel :: proc(sigma: f32, kernel: ^[MAX_BACKDROP_KERNEL_PAIRS][4]f return pair_count } -// --------------------------------------------------------------------------------------------------------------------- -// ----- Uniform push helpers ---------- -// --------------------------------------------------------------------------------------------------------------------- - -// Push the Backdrop_Vert_Uniforms block to the vertex stage at slot 0. -@(private) -push_backdrop_vert_globals :: proc(cmd_buffer: ^sdl.GPUCommandBuffer, width: f32, height: f32, mode: u32) { - uniforms := Backdrop_Vert_Uniforms { - projection = ortho_rh(left = 0.0, top = 0.0, right = width, bottom = height, near = -1.0, far = 1.0), - dpi_scale = GLOB.dpi_scaling, - mode = mode, - } - sdl.PushGPUVertexUniformData(cmd_buffer, 0, &uniforms, size_of(Backdrop_Vert_Uniforms)) -} - -// Push the Backdrop_Downsample_Frag_Uniforms block to the fragment stage at slot 0. -@(private) -push_backdrop_downsample_frag_globals :: proc( - cmd_buffer: ^sdl.GPUCommandBuffer, - source_width, source_height: u32, - downsample_factor: u32, -) { - uniforms := Backdrop_Downsample_Frag_Uniforms { - inv_source_size = {1.0 / f32(source_width), 1.0 / f32(source_height)}, - downsample_factor = downsample_factor, - } - sdl.PushGPUFragmentUniformData(cmd_buffer, 0, &uniforms, size_of(Backdrop_Downsample_Frag_Uniforms)) -} - // Pick a downsample factor for a given sigma. See the file-header comment for the table and // rationale. Returned values: {1, 2, 4}. -@(private) +//INTERNAL compute_backdrop_downsample_factor :: proc(sigma_logical: f32) -> u32 { sigma_phys := sigma_logical * GLOB.dpi_scaling switch { @@ -576,80 +610,76 @@ compute_backdrop_downsample_factor :: proc(sigma_logical: f32) -> u32 { } } -// Push the Backdrop_Frag_Uniforms block (kernel + pass mode/direction) to the fragment stage at slot 0. -@(private) -push_backdrop_blur_frag_globals :: proc( - cmd_buffer: ^sdl.GPUCommandBuffer, - uniforms: ^Backdrop_Frag_Uniforms, -) { - sdl.PushGPUFragmentUniformData(cmd_buffer, 0, uniforms, size_of(Backdrop_Frag_Uniforms)) +//----- Uniform push helpers ---------------------------------- + +// Push the Gaussian_Blur_Vert_Uniforms block to the vertex stage at slot 0. +//INTERNAL +push_backdrop_vert_globals :: proc(cmd_buffer: ^sdl.GPUCommandBuffer, width: f32, height: f32, mode: u32) { + uniforms := Gaussian_Blur_Vert_Uniforms { + projection = ortho_rh(left = 0.0, top = 0.0, right = width, bottom = height, near = -1.0, far = 1.0), + dpi_scale = GLOB.dpi_scaling, + mode = mode, + } + sdl.PushGPUVertexUniformData(cmd_buffer, 0, &uniforms, size_of(Gaussian_Blur_Vert_Uniforms)) } -// --------------------------------------------------------------------------------------------------------------------- -// ----- Storage-buffer upload --------- -// --------------------------------------------------------------------------------------------------------------------- +// Push the Gaussian_Blur_Downsample_Frag_Uniforms block to the fragment stage at slot 0. +//INTERNAL +push_backdrop_downsample_frag_globals :: proc( + cmd_buffer: ^sdl.GPUCommandBuffer, + source_width, source_height: u32, + downsample_factor: u32, +) { + uniforms := Gaussian_Blur_Downsample_Frag_Uniforms { + inv_source_size = {1.0 / f32(source_width), 1.0 / f32(source_height)}, + downsample_factor = downsample_factor, + } + sdl.PushGPUFragmentUniformData(cmd_buffer, 0, &uniforms, size_of(Gaussian_Blur_Downsample_Frag_Uniforms)) +} -// Upload all Backdrop_Primitive instances staged this frame to the backdrop pipeline's storage -// buffer. Mirrors the SDF primitive upload in pipeline_2d_base.odin's `upload`. Called from +// Push the Gaussian_Blur_Frag_Uniforms block (kernel + pass mode/direction) to the fragment stage at slot 0. +//INTERNAL +push_backdrop_blur_frag_globals :: proc( + cmd_buffer: ^sdl.GPUCommandBuffer, + uniforms: ^Gaussian_Blur_Frag_Uniforms, +) { + sdl.PushGPUFragmentUniformData(cmd_buffer, 0, uniforms, size_of(Gaussian_Blur_Frag_Uniforms)) +} + +//----- Storage-buffer upload ---------------------------------- + +// Upload all Gaussian_Blur_Primitive instances staged this frame to the backdrop subsystem's storage +// buffer. Mirrors the SDF primitive upload in core_2d.odin's `upload`. Called from // `end()` inside the same copy pass that uploads vertices/indices/SDF primitives. -@(private) +//INTERNAL upload_backdrop_primitives :: proc(device: ^sdl.GPUDevice, pass: ^sdl.GPUCopyPass) { - prim_count := u32(len(GLOB.tmp_backdrop_primitives)) + prim_count := u32(len(GLOB.tmp_gaussian_blur_primitives)) if prim_count == 0 do return - prim_size := prim_count * size_of(Backdrop_Primitive) + prim_size := prim_count * size_of(Gaussian_Blur_Primitive) grow_buffer_if_needed( device, - &GLOB.pipeline_2d_backdrop.primitive_buffer, + &GLOB.backdrop.primitive_buffer, prim_size, sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ}, ) - prim_array := sdl.MapGPUTransferBuffer(device, GLOB.pipeline_2d_backdrop.primitive_buffer.transfer, false) + prim_array := sdl.MapGPUTransferBuffer(device, GLOB.backdrop.primitive_buffer.transfer, false) if prim_array == nil { log.panicf("Failed to map backdrop primitive transfer buffer: %s", sdl.GetError()) } - mem.copy(prim_array, raw_data(GLOB.tmp_backdrop_primitives), int(prim_size)) - sdl.UnmapGPUTransferBuffer(device, GLOB.pipeline_2d_backdrop.primitive_buffer.transfer) + mem.copy(prim_array, raw_data(GLOB.tmp_gaussian_blur_primitives), int(prim_size)) + sdl.UnmapGPUTransferBuffer(device, GLOB.backdrop.primitive_buffer.transfer) sdl.UploadToGPUBuffer( pass, - sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.pipeline_2d_backdrop.primitive_buffer.transfer}, - sdl.GPUBufferRegion{buffer = GLOB.pipeline_2d_backdrop.primitive_buffer.gpu, offset = 0, size = prim_size}, + sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.backdrop.primitive_buffer.transfer}, + sdl.GPUBufferRegion{buffer = GLOB.backdrop.primitive_buffer.gpu, offset = 0, size = prim_size}, false, ) } -// --------------------------------------------------------------------------------------------------------------------- -// ----- Frame / layer scanners -------- -// --------------------------------------------------------------------------------------------------------------------- - -// Returns true if any sub-batch in any layer this frame is .Backdrop kind. Called once at the -// top of `end()` to decide whether to route the whole frame to source_texture. -// O(total sub-batches) but with an early-exit on the first hit, so typical cost is tiny. -@(private) -frame_has_backdrop :: proc() -> bool { - for &batch in GLOB.tmp_sub_batches { - if batch.kind == .Backdrop do return true - } - return false -} - -// Returns the absolute index of the first .Backdrop sub-batch in the layer's sub-batch range, -// or -1 if the layer has no backdrops. The index is into GLOB.tmp_sub_batches (not relative to -// layer.sub_batch_start), to match how draw_layer's render-range helpers consume it. -@(private) -find_first_backdrop_in_layer :: proc(layer: ^Layer) -> int { - for i in 0 ..< layer.sub_batch_len { - abs_idx := layer.sub_batch_start + i - if GLOB.tmp_sub_batches[abs_idx].kind == .Backdrop do return int(abs_idx) - } - return -1 -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- Bracket scheduler ------------- -// --------------------------------------------------------------------------------------------------------------------- +//----- Bracket scheduler ---------------------------------- // Compute the union AABB of the backdrop primitives in a contiguous-same-sigma sub-batch run // (one "sigma group"), expanded by 6 sigmas of blur reach (the kernel weight beyond 3σ is @@ -661,7 +691,7 @@ find_first_backdrop_in_layer :: proc(layer: ^Layer) -> int { // Per-group (rather than per-layer) because the adaptive downsample picks a different factor // per sigma, and the kernel reach is also per-sigma. A tighter region per group means less // fragment work in the downsample and H-blur passes. -@(private) +//INTERNAL compute_backdrop_group_work_region :: proc( group_start, group_end: u32, sigma_logical: f32, @@ -680,7 +710,7 @@ compute_backdrop_group_work_region :: proc( batch := GLOB.tmp_sub_batches[i] if batch.kind != .Backdrop do continue for p in batch.offset ..< batch.offset + batch.count { - prim := GLOB.tmp_backdrop_primitives[p] + prim := GLOB.tmp_gaussian_blur_primitives[p] // prim.bounds is in logical pixels (world space). if !has_any { min_x = prim.bounds[0] @@ -751,13 +781,13 @@ compute_backdrop_group_work_region :: proc( // On exit, source_texture contains the pre-bracket contents plus all backdrop primitives // composited on top. The caller then runs Pass B (post-bracket non-backdrop sub-batches) on // source_texture with LOAD. -@(private) +//INTERNAL run_backdrop_bracket :: proc( cmd_buffer: ^sdl.GPUCommandBuffer, layer: ^Layer, swapchain_width, swapchain_height: u32, ) { - pipeline := &GLOB.pipeline_2d_backdrop + pipeline := &GLOB.backdrop full_viewport := sdl.GPUViewport { x = 0, @@ -852,7 +882,7 @@ run_backdrop_bracket :: proc( // Convert the user's logical-pixel sigma into the kernel's working space. // sigma_working_texels = sigma_logical * dpi_scaling / downsample_factor. effective_sigma := sigma * GLOB.dpi_scaling / f32(downsample_factor) - frag_uniforms := Backdrop_Frag_Uniforms { + frag_uniforms := Gaussian_Blur_Frag_Uniforms { inv_working_size = inv_working_size, inv_downsample_factor = 1.0 / f32(downsample_factor), } @@ -1002,24 +1032,20 @@ run_backdrop_bracket :: proc( } } -// --------------------------------------------------------------------------------------------------------------------- -// ----- Primitive builders ------------ -// --------------------------------------------------------------------------------------------------------------------- +//----- Primitive builders ---------------------------------- -// Internal -// -// Build a Backdrop_Primitive with bounds, radii, and feather computed from rectangle +// Build a Gaussian_Blur_Primitive with bounds, radii, and feather computed from rectangle // geometry. The caller sets `color` (tint) on the returned primitive before submitting. // -// No rotation, no outline — backdrop primitives are intentionally limited to axis-aligned +// No rotation, no outline — gaussian blur primitives are intentionally limited to axis-aligned // RRects. Rotation breaks screen-space blur sampling visually; outline would be a specialized // edge effect that belongs in its own primitive type. -@(private) +//INTERNAL build_backdrop_primitive :: proc( rect: Rectangle, radii: Rectangle_Radii, feather_px: f32, -) -> Backdrop_Primitive { +) -> Gaussian_Blur_Primitive { max_radius := min(rect.width, rect.height) * 0.5 clamped_top_left := clamp(radii.top_left, 0, max_radius) clamped_top_right := clamp(radii.top_right, 0, max_radius) @@ -1035,7 +1061,7 @@ build_backdrop_primitive :: proc( center_x := rect.x + half_width center_y := rect.y + half_height - return Backdrop_Primitive { + return Gaussian_Blur_Primitive { bounds = { center_x - half_width - padding, center_y - half_height - padding, @@ -1057,13 +1083,13 @@ build_backdrop_primitive :: proc( } } -// Internal — append a Backdrop_Primitive to the staging array and emit a .Backdrop sub-batch +// Append a Gaussian_Blur_Primitive to the staging array and emit a .Backdrop sub-batch // carrying the requested gaussian_sigma. Sub-batch coalescing in append_or_extend_sub_batch // will merge contiguous backdrops that share a sigma into a single instanced draw. -@(private) -prepare_backdrop_primitive :: proc(layer: ^Layer, prim: Backdrop_Primitive, gaussian_sigma: f32) { - offset := u32(len(GLOB.tmp_backdrop_primitives)) - append(&GLOB.tmp_backdrop_primitives, prim) +//INTERNAL +prepare_backdrop_primitive :: proc(layer: ^Layer, prim: Gaussian_Blur_Primitive, gaussian_sigma: f32) { + offset := u32(len(GLOB.tmp_gaussian_blur_primitives)) + append(&GLOB.tmp_gaussian_blur_primitives, prim) scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] append_or_extend_sub_batch( scissor, @@ -1075,9 +1101,7 @@ prepare_backdrop_primitive :: proc(layer: ^Layer, prim: Backdrop_Primitive, gaus ) } -// --------------------------------------------------------------------------------------------------------------------- -// ----- Public API -------------------- -// --------------------------------------------------------------------------------------------------------------------- +//----- Public API ---------------------------------- // Draw a rectangle whose interior samples a Gaussian-blurred snapshot of the framebuffer // behind it. RRect-only — covers rectangles, rounded rectangles, and circles via diff --git a/draw/core_2d.odin b/draw/core_2d.odin new file mode 100644 index 0000000..b441911 --- /dev/null +++ b/draw/core_2d.odin @@ -0,0 +1,1601 @@ +package draw + +import "core:c" +import "core:log" +import "core:math" +import "core:mem" +import sdl "vendor:sdl3" +import sdl_ttf "vendor:sdl3/ttf" + +//----- Vertex layout ---------------------------------- + +// Vertex layout for tessellated and text geometry. +// IMPORTANT: `color` must be premultiplied alpha (RGB channels pre-scaled by alpha). +// The tessellated fragment shader passes vertex color through directly — it does NOT +// premultiply. The blend state is ONE, ONE_MINUS_SRC_ALPHA (premultiplied-over). +// Use `premultiply_color` when constructing vertices manually for `prepare_shape`. +Vertex_2D :: struct { + position: Vec2, + uv: [2]f32, + color: Color, +} + +//INTERNAL +Text_Batch :: struct { + atlas_texture: ^sdl.GPUTexture, + vertex_start: u32, + vertex_count: u32, + index_start: u32, + index_count: u32, +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Primitive types ------------ +// --------------------------------------------------------------------------------------------------------------------- + +// The SDF path evaluates one of four signed distance functions per primitive, dispatched +// by Shape_Kind encoded in the low byte of Core_2D_Primitive.flags: +// +// RRect — rounded rectangle with per-corner radii (sdRoundedBox). Also covers circles +// (uniform radii = half-size), capsule-style line segments (rotated, max rounding), +// and other RRect-reducible shapes. +// NGon — regular polygon with N sides and optional rounding. +// Ellipse — approximate ellipse (non-exact SDF, suitable for UI but not for shape merging). +// Ring_Arc — annular ring with optional angular clipping. Covers full rings, partial arcs, +// pie slices (inner_radius = 0), and loading spinners. +//INTERNAL +Shape_Kind :: enum u8 { + Solid = 0, // tessellated path (mode marker; not a real SDF kind) + RRect = 1, + NGon = 2, + Ellipse = 3, + Ring_Arc = 4, +} + +//INTERNAL +Shape_Flag :: enum u8 { + Textured, // bit 0: sample texture using uv_rect (mutually exclusive with Gradient via Brush union) + Gradient, // bit 1: 2-color gradient using effects.gradient_color as end/outer color + Gradient_Radial, // bit 2: if set with Gradient, radial from center; else linear at angle + Outline, // bit 3: outer outline band using effects.outline_color; CPU expands bounds by outline_width + Rotated, // bit 4: shape has non-zero rotation; rotation_sc contains packed sin/cos + Arc_Narrow, // bit 5: ring arc span ≤ π — intersect half-planes. Neither Arc bit = full ring. + Arc_Wide, // bit 6: ring arc span > π — union half-planes. Neither Arc bit = full ring. +} + +//INTERNAL +Shape_Flags :: bit_set[Shape_Flag;u8] + +//INTERNAL +RRect_Params :: struct { + half_size: [2]f32, + radii: [4]f32, + half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d) + _: f32, +} + +//INTERNAL +NGon_Params :: struct { + radius: f32, + sides: f32, + half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d) + _: [5]f32, +} + +//INTERNAL +Ellipse_Params :: struct { + radii: [2]f32, + half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d) + _: [5]f32, +} + +//INTERNAL +Ring_Arc_Params :: struct { + inner_radius: f32, // inner radius in physical pixels (0 for pie slice) + outer_radius: f32, // outer radius in physical pixels + normal_start: [2]f32, // pre-computed outward normal of start edge: (sin(start), -cos(start)) + normal_end: [2]f32, // pre-computed outward normal of end edge: (-sin(end), cos(end)) + half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d) + _: f32, +} + +//INTERNAL +Shape_Params :: struct #raw_union { + rrect: RRect_Params, + ngon: NGon_Params, + ellipse: Ellipse_Params, + ring_arc: Ring_Arc_Params, + raw: [8]f32, +} +#assert(size_of(Shape_Params) == 32) + +// GPU-side storage for 2-color gradient parameters and/or outline parameters. +// Packed into 16 bytes. Independent from uv_rect — texture and outline can coexist. +// The shader reads gradient_color and outline_color via unpackUnorm4x8. +// gradient_dir_sc stores the pre-computed gradient direction as (cos, sin) in f16 pair +// via unpackHalf2x16. outline_packed stores outline_width as f16 via unpackHalf2x16. +//INTERNAL +Gradient_Outline :: struct { + gradient_color: Color, // 0: end (linear) or outer (radial) gradient color + outline_color: Color, // 4: outline band color + gradient_dir_sc: u32, // 8: packed f16 pair: low = cos(angle), high = sin(angle) — pre-computed gradient direction + outline_packed: u32, // 12: packed f16 pair: low = outline_width (f16, physical pixels), high = reserved +} +#assert(size_of(Gradient_Outline) == 16) + +// GPU layout: 96 bytes, std430-compatible. The shader declares this as a storage buffer struct. +// The low byte of `flags` encodes the Shape_Kind (0 = tessellated, 1-4 = SDF kinds). +// Bits 8-15 encode Shape_Flags (Textured, Gradient, Gradient_Radial, Outline, Rotated, Arc_Narrow, Arc_Wide). +// rotation_sc stores pre-computed sin/cos of the rotation angle as a packed f16 pair, +// avoiding per-pixel trigonometry in the fragment shader. Only read when .Rotated is set. +// +// Named Core_2D_Primitive (not just Primitive) to disambiguate from Gaussian_Blur_Primitive +// (and any future per-effect primitive types) in backdrop.odin. Each path/effect's primitive +// type has its own GPU layout and fragment-shader contract; pairing each with its own +// primitive type keeps cross-references unambiguous when grepping the codebase. +//INTERNAL +Core_2D_Primitive :: struct { + bounds: [4]f32, // 0: min_x, min_y, max_x, max_y (world-space, pre-DPI) + color: Color, // 16: u8x4, fill color / gradient start color / texture tint + flags: u32, // 20: low byte = Shape_Kind, bits 8+ = Shape_Flags + rotation_sc: u32, // 24: packed f16 pair: low = sin(angle), high = cos(angle). Requires .Rotated flag. + _pad: f32, // 28: reserved for future use + params: Shape_Params, // 32: per-kind shape parameters (raw union, 32 bytes) + uv_rect: [4]f32, // 64: texture UV coordinates (u_min, v_min, u_max, v_max). Read when .Textured. + effects: Gradient_Outline, // 80: gradient and/or outline parameters. Read when .Gradient and/or .Outline. +} +#assert(size_of(Core_2D_Primitive) == 96) + +// Pack shape kind and flags into the Core_2D_Primitive.flags field. The low byte encodes the +// Shape_Kind (which also serves as the SDF mode marker — kind > 0 means SDF path). The +// tessellated path leaves the field at 0 (Solid kind, set by vertex shader zero-initialization). +//INTERNAL +pack_kind_flags :: #force_inline proc(kind: Shape_Kind, flags: Shape_Flags) -> u32 { + return u32(kind) | (u32(transmute(u8)flags) << 8) +} + +// Pack two f16 values into a single u32 for GPU consumption via unpackHalf2x16. +// Used to pack gradient_dir_sc (cos/sin) and outline_packed (width/reserved) in Gradient_Outline. +//INTERNAL +pack_f16_pair :: #force_inline proc(low, high: f16) -> u32 { + return u32(transmute(u16)low) | (u32(transmute(u16)high) << 16) +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Subsystem lifecycle ------------ +// --------------------------------------------------------------------------------------------------------------------- + +//INTERNAL +Core_2D :: struct { + sdl_pipeline: ^sdl.GPUGraphicsPipeline, + vertex_buffer: Buffer, + index_buffer: Buffer, + unit_quad_buffer: ^sdl.GPUBuffer, + primitive_buffer: Buffer, + white_texture: ^sdl.GPUTexture, + sampler: ^sdl.GPUSampler, +} + +// MSAA is not supported by levlib (see init's doc comment in draw.odin); the PSO is hard-wired +// to single-sample. SDF text and shapes provide analytical AA via smoothstep; tessellated user +// geometry is not anti-aliased. +//INTERNAL +create_core_2d :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window) -> (core_2d: Core_2D, ok: bool) { + // On failure, clean up any partially-created resources + defer if !ok { + if core_2d.sampler != nil do sdl.ReleaseGPUSampler(device, core_2d.sampler) + if core_2d.white_texture != nil do sdl.ReleaseGPUTexture(device, core_2d.white_texture) + if core_2d.unit_quad_buffer != nil do sdl.ReleaseGPUBuffer(device, core_2d.unit_quad_buffer) + if core_2d.primitive_buffer.gpu != nil do destroy_buffer(device, &core_2d.primitive_buffer) + if core_2d.index_buffer.gpu != nil do destroy_buffer(device, &core_2d.index_buffer) + if core_2d.vertex_buffer.gpu != nil do destroy_buffer(device, &core_2d.vertex_buffer) + if core_2d.sdl_pipeline != nil do sdl.ReleaseGPUGraphicsPipeline(device, core_2d.sdl_pipeline) + } + + active_shader_formats := sdl.GetGPUShaderFormats(device) + if PLATFORM_SHADER_FORMAT_FLAG not_in active_shader_formats { + log.errorf( + "draw: no embedded shader matches active GPU formats; this build supports %v but device reports %v", + PLATFORM_SHADER_FORMAT, + active_shader_formats, + ) + return core_2d, false + } + + log.debug("Loaded", len(BASE_VERT_2D_RAW), "vert bytes") + log.debug("Loaded", len(BASE_FRAG_2D_RAW), "frag bytes") + + vert_info := sdl.GPUShaderCreateInfo { + code_size = len(BASE_VERT_2D_RAW), + code = raw_data(BASE_VERT_2D_RAW), + entrypoint = SHADER_ENTRY, + format = {PLATFORM_SHADER_FORMAT_FLAG}, + stage = .VERTEX, + num_uniform_buffers = 1, + num_storage_buffers = 1, + } + + frag_info := sdl.GPUShaderCreateInfo { + code_size = len(BASE_FRAG_2D_RAW), + code = raw_data(BASE_FRAG_2D_RAW), + entrypoint = SHADER_ENTRY, + format = {PLATFORM_SHADER_FORMAT_FLAG}, + stage = .FRAGMENT, + num_samplers = 1, + } + + vert_shader := sdl.CreateGPUShader(device, vert_info) + if vert_shader == nil { + log.errorf("Could not create draw vertex shader: %s", sdl.GetError()) + return core_2d, false + } + + frag_shader := sdl.CreateGPUShader(device, frag_info) + if frag_shader == nil { + sdl.ReleaseGPUShader(device, vert_shader) + log.errorf("Could not create draw fragment shader: %s", sdl.GetError()) + return core_2d, false + } + + vertex_attributes: [3]sdl.GPUVertexAttribute = { + // position (GLSL location 0) + sdl.GPUVertexAttribute{buffer_slot = 0, location = 0, format = .FLOAT2, offset = 0}, + // uv (GLSL location 1) + sdl.GPUVertexAttribute{buffer_slot = 0, location = 1, format = .FLOAT2, offset = size_of([2]f32)}, + // color (GLSL location 2, u8x4 normalized to float by GPU) + sdl.GPUVertexAttribute{buffer_slot = 0, location = 2, format = .UBYTE4_NORM, offset = size_of([2]f32) * 2}, + } + + pipeline_info := sdl.GPUGraphicsPipelineCreateInfo { + vertex_shader = vert_shader, + fragment_shader = frag_shader, + primitive_type = .TRIANGLELIST, + multisample_state = sdl.GPUMultisampleState{sample_count = ._1}, + target_info = sdl.GPUGraphicsPipelineTargetInfo { + color_target_descriptions = &sdl.GPUColorTargetDescription { + format = sdl.GetGPUSwapchainTextureFormat(device, window), + // Premultiplied-alpha blending: src outputs RGB pre-multiplied by alpha, + // so src factor is ONE (not SRC_ALPHA). This eliminates the per-pixel + // divide in the outline path and is the standard blend mode used by + // Skia, Flutter, and GPUI. + blend_state = sdl.GPUColorTargetBlendState { + enable_blend = true, + enable_color_write_mask = true, + src_color_blendfactor = .ONE, + dst_color_blendfactor = .ONE_MINUS_SRC_ALPHA, + color_blend_op = .ADD, + src_alpha_blendfactor = .ONE, + dst_alpha_blendfactor = .ONE_MINUS_SRC_ALPHA, + alpha_blend_op = .ADD, + color_write_mask = sdl.GPUColorComponentFlags{.R, .G, .B, .A}, + }, + }, + num_color_targets = 1, + }, + vertex_input_state = sdl.GPUVertexInputState { + vertex_buffer_descriptions = &sdl.GPUVertexBufferDescription { + slot = 0, + input_rate = .VERTEX, + pitch = size_of(Vertex_2D), + }, + num_vertex_buffers = 1, + vertex_attributes = raw_data(vertex_attributes[:]), + num_vertex_attributes = 3, + }, + } + + core_2d.sdl_pipeline = sdl.CreateGPUGraphicsPipeline(device, pipeline_info) + // Shaders are no longer needed regardless of pipeline creation success + sdl.ReleaseGPUShader(device, vert_shader) + sdl.ReleaseGPUShader(device, frag_shader) + if core_2d.sdl_pipeline == nil { + log.errorf("Failed to create draw graphics pipeline: %s", sdl.GetError()) + return core_2d, false + } + + // Create vertex buffer + vert_buf_ok: bool + core_2d.vertex_buffer, vert_buf_ok = create_buffer( + device, + size_of(Vertex_2D) * BUFFER_INIT_SIZE, + sdl.GPUBufferUsageFlags{.VERTEX}, + ) + if !vert_buf_ok do return core_2d, false + + // Create index buffer (used by text) + idx_buf_ok: bool + core_2d.index_buffer, idx_buf_ok = create_buffer( + device, + size_of(c.int) * BUFFER_INIT_SIZE, + sdl.GPUBufferUsageFlags{.INDEX}, + ) + if !idx_buf_ok do return core_2d, false + + // Create primitive storage buffer (used by SDF instanced drawing) + prim_buf_ok: bool + core_2d.primitive_buffer, prim_buf_ok = create_buffer( + device, + size_of(Core_2D_Primitive) * BUFFER_INIT_SIZE, + sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ}, + ) + if !prim_buf_ok do return core_2d, false + + // Create static 6-vertex unit quad buffer (two triangles, TRIANGLELIST) + core_2d.unit_quad_buffer = sdl.CreateGPUBuffer( + device, + sdl.GPUBufferCreateInfo{usage = {.VERTEX}, size = 6 * size_of(Vertex_2D)}, + ) + if core_2d.unit_quad_buffer == nil { + log.errorf("Failed to create unit quad buffer: %s", sdl.GetError()) + return core_2d, false + } + + // Create 1x1 white pixel texture + core_2d.white_texture = sdl.CreateGPUTexture( + device, + sdl.GPUTextureCreateInfo { + type = .D2, + format = .R8G8B8A8_UNORM, + usage = {.SAMPLER}, + width = 1, + height = 1, + layer_count_or_depth = 1, + num_levels = 1, + sample_count = ._1, + }, + ) + if core_2d.white_texture == nil { + log.errorf("Failed to create white pixel texture: %s", sdl.GetError()) + return core_2d, false + } + + // Upload white pixel and unit quad data in a single command buffer + white_pixel := Color{255, 255, 255, 255} + white_transfer_buf := sdl.CreateGPUTransferBuffer( + device, + sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(white_pixel)}, + ) + if white_transfer_buf == nil { + log.errorf("Failed to create white pixel transfer buffer: %s", sdl.GetError()) + return core_2d, false + } + defer sdl.ReleaseGPUTransferBuffer(device, white_transfer_buf) + + white_ptr := sdl.MapGPUTransferBuffer(device, white_transfer_buf, false) + if white_ptr == nil { + log.errorf("Failed to map white pixel transfer buffer: %s", sdl.GetError()) + return core_2d, false + } + mem.copy(white_ptr, &white_pixel, size_of(white_pixel)) + sdl.UnmapGPUTransferBuffer(device, white_transfer_buf) + + quad_verts := [6]Vertex_2D { + {position = {0, 0}}, + {position = {1, 0}}, + {position = {0, 1}}, + {position = {0, 1}}, + {position = {1, 0}}, + {position = {1, 1}}, + } + quad_transfer_buf := sdl.CreateGPUTransferBuffer( + device, + sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(quad_verts)}, + ) + if quad_transfer_buf == nil { + log.errorf("Failed to create unit quad transfer buffer: %s", sdl.GetError()) + return core_2d, false + } + defer sdl.ReleaseGPUTransferBuffer(device, quad_transfer_buf) + + quad_ptr := sdl.MapGPUTransferBuffer(device, quad_transfer_buf, false) + if quad_ptr == nil { + log.errorf("Failed to map unit quad transfer buffer: %s", sdl.GetError()) + return core_2d, false + } + mem.copy(quad_ptr, &quad_verts, size_of(quad_verts)) + sdl.UnmapGPUTransferBuffer(device, quad_transfer_buf) + + upload_cmd_buffer := sdl.AcquireGPUCommandBuffer(device) + if upload_cmd_buffer == nil { + log.errorf("Failed to acquire command buffer for init upload: %s", sdl.GetError()) + return core_2d, false + } + upload_pass := sdl.BeginGPUCopyPass(upload_cmd_buffer) + + sdl.UploadToGPUTexture( + upload_pass, + sdl.GPUTextureTransferInfo{transfer_buffer = white_transfer_buf}, + sdl.GPUTextureRegion{texture = core_2d.white_texture, w = 1, h = 1, d = 1}, + false, + ) + + sdl.UploadToGPUBuffer( + upload_pass, + sdl.GPUTransferBufferLocation{transfer_buffer = quad_transfer_buf}, + sdl.GPUBufferRegion{buffer = core_2d.unit_quad_buffer, offset = 0, size = size_of(quad_verts)}, + false, + ) + + sdl.EndGPUCopyPass(upload_pass) + if !sdl.SubmitGPUCommandBuffer(upload_cmd_buffer) { + log.errorf("Failed to submit init upload command buffer: %s", sdl.GetError()) + return core_2d, false + } + + log.debug("White pixel texture and unit quad buffer created and uploaded") + + // Create sampler (shared by shapes and text) + core_2d.sampler = sdl.CreateGPUSampler( + device, + sdl.GPUSamplerCreateInfo { + min_filter = .LINEAR, + mag_filter = .LINEAR, + mipmap_mode = .LINEAR, + address_mode_u = .CLAMP_TO_EDGE, + address_mode_v = .CLAMP_TO_EDGE, + address_mode_w = .CLAMP_TO_EDGE, + }, + ) + if core_2d.sampler == nil { + log.errorf("Could not create GPU sampler: %s", sdl.GetError()) + return core_2d, false + } + + log.debug("Done creating core 2D subsystem") + return core_2d, true +} + +//INTERNAL +destroy_core_2d :: proc(device: ^sdl.GPUDevice, core: ^Core_2D) { + destroy_buffer(device, &core.vertex_buffer) + destroy_buffer(device, &core.index_buffer) + destroy_buffer(device, &core.primitive_buffer) + if core.unit_quad_buffer != nil { + sdl.ReleaseGPUBuffer(device, core.unit_quad_buffer) + } + sdl.ReleaseGPUTexture(device, core.white_texture) + sdl.ReleaseGPUSampler(device, core.sampler) + sdl.ReleaseGPUGraphicsPipeline(device, core.sdl_pipeline) +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Upload and render ------------ +// --------------------------------------------------------------------------------------------------------------------- + +//----- Vertex uniforms ---------------------------------- + +//INTERNAL +Core_2D_Mode :: enum u32 { + Tessellated = 0, + SDF = 1, +} + +//INTERNAL +Vertex_Uniforms_2D :: struct { + projection: matrix[4, 4]f32, + scale: f32, + mode: Core_2D_Mode, +} + +// Push projection, dpi scale, and rendering mode as a single uniform block (slot 0). +//INTERNAL +push_globals :: proc( + cmd_buffer: ^sdl.GPUCommandBuffer, + width: f32, + height: f32, + mode: Core_2D_Mode = .Tessellated, +) { + globals := Vertex_Uniforms_2D { + projection = ortho_rh( + left = 0.0, + top = 0.0, + right = f32(width), + bottom = f32(height), + near = -1.0, + far = 1.0, + ), + scale = GLOB.dpi_scaling, + mode = mode, + } + + sdl.PushGPUVertexUniformData(cmd_buffer, 0, &globals, size_of(Vertex_Uniforms_2D)) +} + +//----- Per-frame upload ---------------------------------- + +//INTERNAL +upload :: proc(device: ^sdl.GPUDevice, pass: ^sdl.GPUCopyPass) { + // Upload vertices (shapes then text into one buffer) + shape_vert_count := u32(len(GLOB.tmp_shape_verts)) + text_vert_count := u32(len(GLOB.tmp_text_verts)) + total_vert_count := shape_vert_count + text_vert_count + + if total_vert_count > 0 { + total_vert_size := total_vert_count * size_of(Vertex_2D) + shape_vert_size := shape_vert_count * size_of(Vertex_2D) + text_vert_size := text_vert_count * size_of(Vertex_2D) + + grow_buffer_if_needed( + device, + &GLOB.core_2d.vertex_buffer, + total_vert_size, + sdl.GPUBufferUsageFlags{.VERTEX}, + ) + + vert_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.vertex_buffer.transfer, false) + if vert_array == nil { + log.panicf("Failed to map vertex transfer buffer: %s", sdl.GetError()) + } + if shape_vert_size > 0 { + mem.copy(vert_array, raw_data(GLOB.tmp_shape_verts), int(shape_vert_size)) + } + if text_vert_size > 0 { + mem.copy( + rawptr(uintptr(vert_array) + uintptr(shape_vert_size)), + raw_data(GLOB.tmp_text_verts), + int(text_vert_size), + ) + } + sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.vertex_buffer.transfer) + + sdl.UploadToGPUBuffer( + pass, + sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.vertex_buffer.transfer}, + sdl.GPUBufferRegion{buffer = GLOB.core_2d.vertex_buffer.gpu, offset = 0, size = total_vert_size}, + false, + ) + } + + // Upload text indices + index_count := u32(len(GLOB.tmp_text_indices)) + if index_count > 0 { + index_size := index_count * size_of(c.int) + + grow_buffer_if_needed(device, &GLOB.core_2d.index_buffer, index_size, sdl.GPUBufferUsageFlags{.INDEX}) + + idx_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.index_buffer.transfer, false) + if idx_array == nil { + log.panicf("Failed to map index transfer buffer: %s", sdl.GetError()) + } + mem.copy(idx_array, raw_data(GLOB.tmp_text_indices), int(index_size)) + sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.index_buffer.transfer) + + sdl.UploadToGPUBuffer( + pass, + sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.index_buffer.transfer}, + sdl.GPUBufferRegion{buffer = GLOB.core_2d.index_buffer.gpu, offset = 0, size = index_size}, + false, + ) + } + + // Upload SDF primitives + prim_count := u32(len(GLOB.tmp_primitives)) + if prim_count > 0 { + prim_size := prim_count * size_of(Core_2D_Primitive) + + grow_buffer_if_needed( + device, + &GLOB.core_2d.primitive_buffer, + prim_size, + sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ}, + ) + + prim_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.primitive_buffer.transfer, false) + if prim_array == nil { + log.panicf("Failed to map primitive transfer buffer: %s", sdl.GetError()) + } + mem.copy(prim_array, raw_data(GLOB.tmp_primitives), int(prim_size)) + sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.primitive_buffer.transfer) + + sdl.UploadToGPUBuffer( + pass, + sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.primitive_buffer.transfer}, + sdl.GPUBufferRegion{buffer = GLOB.core_2d.primitive_buffer.gpu, offset = 0, size = prim_size}, + false, + ) + } +} + +//----- Layer dispatch ---------------------------------- + +//INTERNAL +draw_layer :: proc( + device: ^sdl.GPUDevice, + window: ^sdl.Window, + cmd_buffer: ^sdl.GPUCommandBuffer, + render_texture: ^sdl.GPUTexture, + swapchain_width: u32, + swapchain_height: u32, + clear_color: [4]f32, + layer: ^Layer, +) { + if layer.sub_batch_len == 0 { + if !GLOB.cleared { + pass := sdl.BeginGPURenderPass( + cmd_buffer, + &sdl.GPUColorTargetInfo { + texture = render_texture, + clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]}, + load_op = .CLEAR, + store_op = .STORE, + }, + 1, + nil, + ) + sdl.EndGPURenderPass(pass) + GLOB.cleared = true + } + return + } + + bracket_start_abs := find_first_backdrop_in_layer(layer) + layer_end_abs := int(layer.sub_batch_start + layer.sub_batch_len) + + if bracket_start_abs < 0 { + // Fast path: no backdrop in this layer; render the whole sub-batch range in one pass. + render_layer_sub_batch_range( + cmd_buffer, + render_texture, + swapchain_width, + swapchain_height, + clear_color, + layer, + int(layer.sub_batch_start), + layer_end_abs, + ) + return + } + + // Bracketed layer: Pass A → backdrop bracket → Pass B. + // See README.md § "Backdrop pipeline" for the full ordering semantics. + render_layer_sub_batch_range( + cmd_buffer, + render_texture, + swapchain_width, + swapchain_height, + clear_color, + layer, + int(layer.sub_batch_start), + bracket_start_abs, + ) + + run_backdrop_bracket(cmd_buffer, layer, swapchain_width, swapchain_height) + + // Pass B: render the [bracket_start_abs, layer_end_abs) range. .Backdrop sub-batches in + // this range are dispatched by the bracket above and ignored here (the .Backdrop case in + // the inner switch is a no-op). LOAD is implied because Pass A or the bracket's V- + // composite has already touched render_texture. + render_layer_sub_batch_range( + cmd_buffer, + render_texture, + swapchain_width, + swapchain_height, + clear_color, + layer, + bracket_start_abs, + layer_end_abs, + ) +} + +// Render a sub-range of a layer's sub-batches in a single render pass. Iterates the layer's +// scissors and walks each scissor's sub-batches, dispatching by kind. The `range_start_abs` +// and `range_end_abs` parameters are absolute indices into GLOB.tmp_sub_batches; only sub- +// batches within `[range_start_abs, range_end_abs)` are drawn. +// +// .Backdrop sub-batches in the range are always silently skipped — they are dispatched by +// run_backdrop_bracket, not here. The empty .Backdrop case in the inner switch enforces this. +// +// Render-pass setup mirrors the original draw_layer: clear-or-load based on GLOB.cleared, +// pipeline + storage + index buffer bound up front, then per-batch state tracking. After this +// proc returns, GLOB.cleared is guaranteed true. +// +// If the range is empty after filtering (no eligible sub-batches at all), this proc still +// honors the no-clear-yet contract by issuing a clear-only pass when needed; otherwise it +// returns without opening a render pass. +//INTERNAL +render_layer_sub_batch_range :: proc( + cmd_buffer: ^sdl.GPUCommandBuffer, + render_texture: ^sdl.GPUTexture, + swapchain_width: u32, + swapchain_height: u32, + clear_color: [4]f32, + layer: ^Layer, + range_start_abs: int, + range_end_abs: int, +) { + if range_start_abs >= range_end_abs { + // Empty range. If we still owe a clear, do a clear-only pass; otherwise nothing to do. + if !GLOB.cleared { + pass := sdl.BeginGPURenderPass( + cmd_buffer, + &sdl.GPUColorTargetInfo { + texture = render_texture, + clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]}, + load_op = .CLEAR, + store_op = .STORE, + }, + 1, + nil, + ) + sdl.EndGPURenderPass(pass) + GLOB.cleared = true + } + return + } + + render_pass := sdl.BeginGPURenderPass( + cmd_buffer, + &sdl.GPUColorTargetInfo { + texture = render_texture, + clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]}, + load_op = GLOB.cleared ? .LOAD : .CLEAR, + store_op = .STORE, + }, + 1, + nil, + ) + GLOB.cleared = true + + sdl.BindGPUGraphicsPipeline(render_pass, GLOB.core_2d.sdl_pipeline) + + // Bind storage buffer (read by vertex shader in SDF mode) + sdl.BindGPUVertexStorageBuffers(render_pass, 0, ([^]^sdl.GPUBuffer)(&GLOB.core_2d.primitive_buffer.gpu), 1) + + // Always bind index buffer — harmless if no indexed draws are issued + sdl.BindGPUIndexBuffer( + render_pass, + sdl.GPUBufferBinding{buffer = GLOB.core_2d.index_buffer.gpu, offset = 0}, + ._32BIT, + ) + + // Shorthand aliases for frequently-used pipeline resources + main_vert_buf := GLOB.core_2d.vertex_buffer.gpu + unit_quad := GLOB.core_2d.unit_quad_buffer + white_texture := GLOB.core_2d.white_texture + sampler := GLOB.core_2d.sampler + width := f32(swapchain_width) + height := f32(swapchain_height) + + // Initial GPU state: tessellated mode, main vertex buffer, no atlas bound yet + push_globals(cmd_buffer, width, height, .Tessellated) + sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1) + + current_mode: Core_2D_Mode = .Tessellated + current_vert_buf := main_vert_buf + current_atlas: ^sdl.GPUTexture + current_sampler := sampler + + // Text vertices live after shape vertices in the GPU vertex buffer + text_vertex_gpu_base := u32(len(GLOB.tmp_shape_verts)) + + for &scissor in GLOB.scissors[layer.scissor_start:][:layer.scissor_len] { + // Intersect this scissor's sub-batch span with the requested range. + scissor_start := int(scissor.sub_batch_start) + scissor_end := scissor_start + int(scissor.sub_batch_len) + effective_start := max(scissor_start, range_start_abs) + effective_end := min(scissor_end, range_end_abs) + if effective_start >= effective_end do continue + + sdl.SetGPUScissor(render_pass, scissor.bounds) + + for abs_idx in effective_start ..< effective_end { + batch := &GLOB.tmp_sub_batches[abs_idx] + switch batch.kind { + case .Tessellated: + if current_mode != .Tessellated { + push_globals(cmd_buffer, width, height, .Tessellated) + current_mode = .Tessellated + } + if current_vert_buf != main_vert_buf { + sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1) + current_vert_buf = main_vert_buf + } + // Determine texture and sampler for this batch + batch_texture: ^sdl.GPUTexture = white_texture + batch_sampler: ^sdl.GPUSampler = sampler + if batch.texture_id != INVALID_TEXTURE { + if bound_texture := texture_gpu_handle(batch.texture_id); bound_texture != nil { + batch_texture = bound_texture + } + batch_sampler = get_sampler(batch.sampler) + } + if current_atlas != batch_texture || current_sampler != batch_sampler { + sdl.BindGPUFragmentSamplers( + render_pass, + 0, + &sdl.GPUTextureSamplerBinding{texture = batch_texture, sampler = batch_sampler}, + 1, + ) + current_atlas = batch_texture + current_sampler = batch_sampler + } + sdl.DrawGPUPrimitives(render_pass, batch.count, 1, batch.offset, 0) + + case .Text: + if current_mode != .Tessellated { + push_globals(cmd_buffer, width, height, .Tessellated) + current_mode = .Tessellated + } + if current_vert_buf != main_vert_buf { + sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1) + current_vert_buf = main_vert_buf + } + text_batch := &GLOB.tmp_text_batches[batch.offset] + if current_atlas != text_batch.atlas_texture { + sdl.BindGPUFragmentSamplers( + render_pass, + 0, + &sdl.GPUTextureSamplerBinding{texture = text_batch.atlas_texture, sampler = sampler}, + 1, + ) + current_atlas = text_batch.atlas_texture + } + sdl.DrawGPUIndexedPrimitives( + render_pass, + text_batch.index_count, + 1, + text_batch.index_start, + i32(text_vertex_gpu_base + text_batch.vertex_start), + 0, + ) + + case .SDF: + if current_mode != .SDF { + push_globals(cmd_buffer, width, height, .SDF) + current_mode = .SDF + } + if current_vert_buf != unit_quad { + sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = unit_quad, offset = 0}, 1) + current_vert_buf = unit_quad + } + // Determine texture and sampler for this batch + batch_texture: ^sdl.GPUTexture = white_texture + batch_sampler: ^sdl.GPUSampler = sampler + if batch.texture_id != INVALID_TEXTURE { + if bound_texture := texture_gpu_handle(batch.texture_id); bound_texture != nil { + batch_texture = bound_texture + } + batch_sampler = get_sampler(batch.sampler) + } + if current_atlas != batch_texture || current_sampler != batch_sampler { + sdl.BindGPUFragmentSamplers( + render_pass, + 0, + &sdl.GPUTextureSamplerBinding{texture = batch_texture, sampler = batch_sampler}, + 1, + ) + current_atlas = batch_texture + current_sampler = batch_sampler + } + sdl.DrawGPUPrimitives(render_pass, 6, batch.count, 0, batch.offset) + + case .Backdrop: + // Always a no-op here. Backdrop sub-batches are dispatched by run_backdrop_bracket; + // when this proc encounters one (only possible in Pass B, since Pass A and the no- + // backdrop fast path both stop their range before any .Backdrop index), we skip it. + } + } + } + + sdl.EndGPURenderPass(render_pass) +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Submission helpers ------------ +// --------------------------------------------------------------------------------------------------------------------- + +// Submit shape vertices (colored triangles) to the given layer for rendering. +// TODO: Should probably be renamed to better match tesselated naming conventions in the library. +prepare_shape :: proc(layer: ^Layer, vertices: []Vertex_2D) { + if len(vertices) == 0 do return + offset := u32(len(GLOB.tmp_shape_verts)) + append(&GLOB.tmp_shape_verts, ..vertices) + scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + append_or_extend_sub_batch(scissor, layer, .Tessellated, offset, u32(len(vertices))) +} + +// Submit an SDF primitive to the given layer for rendering. Requires the caller to build a +// Core_2D_Primitive directly, which is the internal GPU-layout struct. +//INTERNAL +prepare_sdf_primitive :: proc(layer: ^Layer, prim: Core_2D_Primitive) { + offset := u32(len(GLOB.tmp_primitives)) + append(&GLOB.tmp_primitives, prim) + scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1) +} + +// Submit an SDF primitive with optional texture binding. +// The texture-aware counterpart of `prepare_sdf_primitive`; lets shape procs route a +// texture_id and sampler into the sub-batch without growing the public API. +//INTERNAL +prepare_sdf_primitive_ex :: proc( + layer: ^Layer, + prim: Core_2D_Primitive, + texture_id: Texture_Id = INVALID_TEXTURE, + sampler: Sampler_Preset = DFT_SAMPLER, +) { + offset := u32(len(GLOB.tmp_primitives)) + append(&GLOB.tmp_primitives, prim) + scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1, texture_id, sampler) +} + +// Submit a text element to the given layer for rendering. +// Copies SDL_ttf vertices directly (with baked position) and copies indices for indexed drawing. +//INTERNAL +prepare_text :: proc(layer: ^Layer, text: Text) { + data := sdl_ttf.GetGPUTextDrawData(text.sdl_text) + if data == nil { + return // nil is normal for empty text + } + + scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + + // Snap base position to integer physical pixels to avoid atlas sub-pixel + // sampling blur (and the off-by-one bottom-row clip that comes with it). + base_x := math.round(text.position[0] * GLOB.dpi_scaling) + base_y := math.round(text.position[1] * GLOB.dpi_scaling) + + // Premultiply text color once — reused across all glyph vertices. + pm_color := premultiply_color(text.color) + + for data != nil { + vertex_start := u32(len(GLOB.tmp_text_verts)) + index_start := u32(len(GLOB.tmp_text_indices)) + + // Copy vertices with baked position offset + for i in 0 ..< data.num_vertices { + pos := data.xy[i] + uv := data.uv[i] + append( + &GLOB.tmp_text_verts, + Vertex_2D{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = pm_color}, + ) + } + + // Copy indices directly + append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices]) + + batch_idx := u32(len(GLOB.tmp_text_batches)) + append( + &GLOB.tmp_text_batches, + Text_Batch { + atlas_texture = data.atlas_texture, + vertex_start = vertex_start, + vertex_count = u32(data.num_vertices), + index_start = index_start, + index_count = u32(data.num_indices), + }, + ) + + // Each atlas chunk is a separate sub-batch (different atlas textures can't coalesce) + append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1) + + data = data.next + } +} + +// Submit a text element with a 2D affine transform applied to vertices. +// Used by the high-level `text` proc when rotation or a non-zero origin is specified. +// NOTE: xform must be in physical (DPI-scaled) pixel space — the caller pre-scales +// pos and origin by GLOB.dpi_scaling before building the transform. +//INTERNAL +prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform_2D) { + data := sdl_ttf.GetGPUTextDrawData(text.sdl_text) + if data == nil { + return + } + + scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + + // Premultiply text color once — reused across all glyph vertices. + pm_color := premultiply_color(text.color) + + for data != nil { + vertex_start := u32(len(GLOB.tmp_text_verts)) + index_start := u32(len(GLOB.tmp_text_indices)) + + for i in 0 ..< data.num_vertices { + pos := data.xy[i] + uv := data.uv[i] + // SDL_ttf gives glyph positions in physical pixels relative to text origin. + // The transform is already in physical-pixel space (caller pre-scaled), + // so we apply directly — no per-vertex DPI divide/multiply. + append( + &GLOB.tmp_text_verts, + Vertex_2D{position = apply_transform(transform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = pm_color}, + ) + } + + append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices]) + + batch_idx := u32(len(GLOB.tmp_text_batches)) + append( + &GLOB.tmp_text_batches, + Text_Batch { + atlas_texture = data.atlas_texture, + vertex_start = vertex_start, + vertex_count = u32(data.num_vertices), + index_start = index_start, + index_count = u32(data.num_indices), + }, + ) + + append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1) + + data = data.next + } +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Primitive builders ------------ +// --------------------------------------------------------------------------------------------------------------------- + +//----- Internal helpers ---------------------------------- + +// Resolve Texture_Fill zero-initialized fields to their defaults. +// Odin structs zero-initialize; Color{} and Rectangle{} are all-zero which is not a +// useful tint or UV rect. This proc substitutes sensible defaults for zero values. +//INTERNAL +resolve_texture_defaults :: #force_inline proc( + tf: Texture_Fill, +) -> ( + tint: Color, + uv: Rectangle, + sampler: Sampler_Preset, +) { + tint = tf.tint == Color{} ? DFT_TINT : tf.tint + uv = tf.uv_rect == Rectangle{} ? DFT_UV_RECT : tf.uv_rect + sampler = tf.sampler + return +} + +// Compute the visual center of a center-parametrized shape after applying +// Convention B origin semantics: `center` is where the origin-point lands in +// world space; the visual center is offset by -origin and then rotated around +// the landing point. +// visual_center = center + R(θ) · (-origin) +// When θ=0: visual_center = center - origin (pure positioning shift). +// When origin={0,0}: visual_center = center (no change). +//INTERNAL +compute_pivot_center :: proc(center: Vec2, origin: Vec2, sin_angle, cos_angle: f32) -> Vec2 { + if origin == {0, 0} do return center + return( + center + + {cos_angle * (-origin.x) - sin_angle * (-origin.y), sin_angle * (-origin.x) + cos_angle * (-origin.y)} \ + ) +} + +// Compute the AABB half-extents of a rectangle with half-size (half_width, half_height) rotated by the given cos/sin. +//INTERNAL +rotated_aabb_half_extents :: proc(half_width, half_height, cos_angle, sin_angle: f32) -> [2]f32 { + cos_abs := abs(cos_angle) + sin_abs := abs(sin_angle) + return {half_width * cos_abs + half_height * sin_abs, half_width * sin_abs + half_height * cos_abs} +} + +// Pack sin/cos into the Core_2D_Primitive.rotation_sc field as two f16 values. +//INTERNAL +pack_rotation_sc :: #force_inline proc(sin_angle, cos_angle: f32) -> u32 { + return pack_f16_pair(f16(sin_angle), f16(cos_angle)) +} + +//----- Shape builders ---------------------------------- + +// Build an RRect Core_2D_Primitive with bounds, params, and rotation computed from rectangle geometry. +// The caller sets color, flags, and uv fields on the returned primitive before submitting. +//INTERNAL +build_rrect_primitive :: proc( + rect: Rectangle, + radii: Rectangle_Radii, + origin: Vec2, + rotation: f32, + feather_px: f32, +) -> Core_2D_Primitive { + max_radius := min(rect.width, rect.height) * 0.5 + clamped_top_left := clamp(radii.top_left, 0, max_radius) + clamped_top_right := clamp(radii.top_right, 0, max_radius) + clamped_bottom_right := clamp(radii.bottom_right, 0, max_radius) + clamped_bottom_left := clamp(radii.bottom_left, 0, max_radius) + + half_feather := feather_px * 0.5 + padding := half_feather / GLOB.dpi_scaling + dpi_scale := GLOB.dpi_scaling + + half_width := rect.width * 0.5 + half_height := rect.height * 0.5 + center_x := rect.x + half_width - origin.x + center_y := rect.y + half_height - origin.y + sin_angle: f32 = 0 + cos_angle: f32 = 1 + has_rotation := false + + if needs_transform(origin, rotation) { + rotation_radians := math.to_radians(rotation) + sin_angle, cos_angle = math.sincos(rotation_radians) + has_rotation = rotation != 0 + transform := build_pivot_rotation_sc({rect.x + origin.x, rect.y + origin.y}, origin, cos_angle, sin_angle) + new_center := apply_transform(transform, {half_width, half_height}) + center_x = new_center.x + center_y = new_center.y + } + + bounds_half_width, bounds_half_height := half_width, half_height + if has_rotation { + expanded := rotated_aabb_half_extents(half_width, half_height, cos_angle, sin_angle) + bounds_half_width = expanded.x + bounds_half_height = expanded.y + } + + prim := Core_2D_Primitive { + bounds = { + center_x - bounds_half_width - padding, + center_y - bounds_half_height - padding, + center_x + bounds_half_width + padding, + center_y + bounds_half_height + padding, + }, + rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0, + } + prim.params.rrect = RRect_Params { + half_size = {half_width * dpi_scale, half_height * dpi_scale}, + radii = { + clamped_bottom_right * dpi_scale, + clamped_top_right * dpi_scale, + clamped_bottom_left * dpi_scale, + clamped_top_left * dpi_scale, + }, + half_feather = half_feather, + } + return prim +} + +// Build an RRect Core_2D_Primitive for a circle (fully-rounded square RRect). +// The caller sets color, flags, and uv fields on the returned primitive before submitting. +//INTERNAL +build_circle_primitive :: proc( + center: Vec2, + radius: f32, + origin: Vec2, + rotation: f32, + feather_px: f32, +) -> Core_2D_Primitive { + half_feather := feather_px * 0.5 + padding := half_feather / GLOB.dpi_scaling + dpi_scale := GLOB.dpi_scaling + + actual_center := center + if origin != {0, 0} { + sin_a, cos_a := math.sincos(math.to_radians(rotation)) + actual_center = compute_pivot_center(center, origin, sin_a, cos_a) + } + + prim := Core_2D_Primitive { + bounds = { + actual_center.x - radius - padding, + actual_center.y - radius - padding, + actual_center.x + radius + padding, + actual_center.y + radius + padding, + }, + } + scaled_radius := radius * dpi_scale + prim.params.rrect = RRect_Params { + half_size = {scaled_radius, scaled_radius}, + radii = {scaled_radius, scaled_radius, scaled_radius, scaled_radius}, + half_feather = half_feather, + } + return prim +} + +// Build an Ellipse Core_2D_Primitive with bounds, params, and rotation computed from ellipse geometry. +// The caller sets color, flags, and uv fields on the returned primitive before submitting. +//INTERNAL +build_ellipse_primitive :: proc( + center: Vec2, + radius_horizontal, radius_vertical: f32, + origin: Vec2, + rotation: f32, + feather_px: f32, +) -> Core_2D_Primitive { + half_feather := feather_px * 0.5 + padding := half_feather / GLOB.dpi_scaling + dpi_scale := GLOB.dpi_scaling + + actual_center := center + sin_angle: f32 = 0 + cos_angle: f32 = 1 + has_rotation := false + + if needs_transform(origin, rotation) { + rotation_radians := math.to_radians(rotation) + sin_angle, cos_angle = math.sincos(rotation_radians) + actual_center = compute_pivot_center(center, origin, sin_angle, cos_angle) + has_rotation = rotation != 0 + } + + bound_horizontal, bound_vertical := radius_horizontal, radius_vertical + if has_rotation { + expanded := rotated_aabb_half_extents(radius_horizontal, radius_vertical, cos_angle, sin_angle) + bound_horizontal = expanded.x + bound_vertical = expanded.y + } + + prim := Core_2D_Primitive { + bounds = { + actual_center.x - bound_horizontal - padding, + actual_center.y - bound_vertical - padding, + actual_center.x + bound_horizontal + padding, + actual_center.y + bound_vertical + padding, + }, + rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0, + } + prim.params.ellipse = Ellipse_Params { + radii = {radius_horizontal * dpi_scale, radius_vertical * dpi_scale}, + half_feather = half_feather, + } + return prim +} + +// Build an NGon Core_2D_Primitive with bounds, params, and rotation computed from polygon geometry. +// The caller sets color, flags, and uv fields on the returned primitive before submitting. +//INTERNAL +build_polygon_primitive :: proc( + center: Vec2, + sides: int, + radius: f32, + origin: Vec2, + rotation: f32, + feather_px: f32, +) -> Core_2D_Primitive { + half_feather := feather_px * 0.5 + padding := half_feather / GLOB.dpi_scaling + dpi_scale := GLOB.dpi_scaling + + actual_center := center + if origin != {0, 0} && rotation != 0 { + sin_a, cos_a := math.sincos(math.to_radians(rotation)) + actual_center = compute_pivot_center(center, origin, sin_a, cos_a) + } + + rotation_radians := math.to_radians(rotation) + sin_rot, cos_rot := math.sincos(rotation_radians) + + prim := Core_2D_Primitive { + bounds = { + actual_center.x - radius - padding, + actual_center.y - radius - padding, + actual_center.x + radius + padding, + actual_center.y + radius + padding, + }, + rotation_sc = rotation != 0 ? pack_rotation_sc(sin_rot, cos_rot) : 0, + } + prim.params.ngon = NGon_Params { + radius = radius * math.cos(math.PI / f32(sides)) * dpi_scale, + sides = f32(sides), + half_feather = half_feather, + } + return prim +} + +// Build a Ring_Arc Core_2D_Primitive with bounds and params computed from ring/arc geometry. +// Pre-computes the angular boundary normals on the CPU so the fragment shader needs +// no per-pixel sin/cos. The radial SDF uses max(inner-r, r-outer) which correctly +// handles pie slices (inner_radius = 0) and full rings. +// The caller sets color, flags, and uv fields on the returned primitive before submitting. +//INTERNAL +build_ring_arc_primitive :: proc( + center: Vec2, + inner_radius, outer_radius: f32, + start_angle: f32, + end_angle: f32, + origin: Vec2, + rotation: f32, + feather_px: f32, +) -> ( + Core_2D_Primitive, + Shape_Flags, +) { + half_feather := feather_px * 0.5 + padding := half_feather / GLOB.dpi_scaling + dpi_scale := GLOB.dpi_scaling + + actual_center := center + rotation_offset: f32 = 0 + if needs_transform(origin, rotation) { + sin_a, cos_a := math.sincos(math.to_radians(rotation)) + actual_center = compute_pivot_center(center, origin, sin_a, cos_a) + rotation_offset = math.to_radians(rotation) + } + + start_rad := math.to_radians(start_angle) + rotation_offset + end_rad := math.to_radians(end_angle) + rotation_offset + + // Normalize arc span to [0, 2π] + arc_span := end_rad - start_rad + if arc_span < 0 { + arc_span += 2 * math.PI + } + + // Pre-compute edge normals and arc flags on CPU — no per-pixel trig needed. + // arc_flags: {} = full ring, {.Arc_Narrow} = span ≤ π (intersect), {.Arc_Wide} = span > π (union) + arc_flags: Shape_Flags = {} + normal_start: [2]f32 = {} + normal_end: [2]f32 = {} + + if arc_span < 2 * math.PI - 0.001 { + sin_start, cos_start := math.sincos(start_rad) + sin_end, cos_end := math.sincos(end_rad) + normal_start = {sin_start, -cos_start} + normal_end = {-sin_end, cos_end} + arc_flags = arc_span <= math.PI ? {.Arc_Narrow} : {.Arc_Wide} + } + + prim := Core_2D_Primitive { + bounds = { + actual_center.x - outer_radius - padding, + actual_center.y - outer_radius - padding, + actual_center.x + outer_radius + padding, + actual_center.y + outer_radius + padding, + }, + } + prim.params.ring_arc = Ring_Arc_Params { + inner_radius = inner_radius * dpi_scale, + outer_radius = outer_radius * dpi_scale, + normal_start = normal_start, + normal_end = normal_end, + half_feather = half_feather, + } + return prim, arc_flags +} + +//----- Brush and outline ---------------------------------- + +// Apply brush fill and outline to a primitive, then submit it. +// Dispatches to the correct sub-batch based on the Brush variant. +// All parameters (outline_width) are in logical pixels, matching the rest of the public API. +// The helper converts to physical pixels for GPU packing internally. +//INTERNAL +apply_brush_and_outline :: proc( + layer: ^Layer, + prim: ^Core_2D_Primitive, + kind: Shape_Kind, + brush: Brush, + outline_color: Color, + outline_width: f32, + extra_flags: Shape_Flags = {}, +) { + flags: Shape_Flags = extra_flags + + // Fill — determined by the Brush variant. + texture_id := INVALID_TEXTURE + sampler := DFT_SAMPLER + + switch b in brush { + case Color: prim.color = b + case Linear_Gradient: + flags += {.Gradient} + prim.color = b.start_color + prim.effects.gradient_color = b.end_color + rad := math.to_radians(b.angle) + sin_a, cos_a := math.sincos(rad) + prim.effects.gradient_dir_sc = pack_f16_pair(f16(cos_a), f16(sin_a)) + case Radial_Gradient: + flags += {.Gradient, .Gradient_Radial} + prim.color = b.inner_color + prim.effects.gradient_color = b.outer_color + case Texture_Fill: + flags += {.Textured} + tint, uv, sam := resolve_texture_defaults(b) + prim.color = tint + prim.uv_rect = {uv.x, uv.y, uv.width, uv.height} + texture_id = b.id + sampler = sam + } + + // Outline — orthogonal to all Brush variants. + if outline_width > 0 { + flags += {.Outline} + prim.effects.outline_color = outline_color + prim.effects.outline_packed = pack_f16_pair(f16(outline_width * GLOB.dpi_scaling), 0) + // Expand bounds to contain the outline (bounds are in logical pixels) + prim.bounds[0] -= outline_width + prim.bounds[1] -= outline_width + prim.bounds[2] += outline_width + prim.bounds[3] += outline_width + } + + // Set .Rotated flag if rotation_sc was populated by the build proc + if prim.rotation_sc != 0 { + flags += {.Rotated} + } + + prim.flags = pack_kind_flags(kind, flags) + prepare_sdf_primitive_ex(layer, prim^, texture_id, sampler) +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Public draw procs ------------ +// --------------------------------------------------------------------------------------------------------------------- + +// Draw a filled rectangle via SDF with optional per-corner rounding radii. +// Use `uniform_radii(rect, roundness)` to compute uniform radii from a 0–1 fraction. +// +// Origin semantics: +// `origin` is a local offset from the rect's top-left corner that selects both the positioning +// anchor and the rotation pivot. `rect.x, rect.y` specifies where that anchor point lands in +// world space. When `origin = {0, 0}` (default), `rect.x, rect.y` is the top-left corner. +// Rotation always occurs around the anchor point. +rectangle :: proc( + layer: ^Layer, + rect: Rectangle, + brush: Brush, + outline_color: Color = {}, + outline_width: f32 = 0, + radii: Rectangle_Radii = {}, + origin: Vec2 = {}, + rotation: f32 = 0, + feather_px: f32 = DFT_FEATHER_PX, +) { + prim := build_rrect_primitive(rect, radii, origin, rotation, feather_px) + apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width) +} + +// Draw a filled circle via SDF (emitted as a fully-rounded RRect). +// +// Origin semantics (Convention B): +// `origin` is a local offset from the shape's center that selects both the positioning anchor +// and the rotation pivot. The `center` parameter specifies where that anchor point lands in +// world space. When `origin = {0, 0}` (default), `center` is the visual center. +// When `origin = {r, 0}`, the point `r` pixels to the right of the shape center lands at +// `center`, shifting the shape left by `r`. +circle :: proc( + layer: ^Layer, + center: Vec2, + radius: f32, + brush: Brush, + outline_color: Color = {}, + outline_width: f32 = 0, + origin: Vec2 = {}, + rotation: f32 = 0, + feather_px: f32 = DFT_FEATHER_PX, +) { + prim := build_circle_primitive(center, radius, origin, rotation, feather_px) + apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width) +} + +// Draw a filled ellipse via SDF. +// Origin semantics: see `circle`. +ellipse :: proc( + layer: ^Layer, + center: Vec2, + radius_horizontal, radius_vertical: f32, + brush: Brush, + outline_color: Color = {}, + outline_width: f32 = 0, + origin: Vec2 = {}, + rotation: f32 = 0, + feather_px: f32 = DFT_FEATHER_PX, +) { + prim := build_ellipse_primitive(center, radius_horizontal, radius_vertical, origin, rotation, feather_px) + apply_brush_and_outline(layer, &prim, .Ellipse, brush, outline_color, outline_width) +} + +// Draw a filled regular polygon via SDF. +// `sides` must be >= 3. The polygon is inscribed in a circle of the given `radius`. +// Origin semantics: see `circle`. +polygon :: proc( + layer: ^Layer, + center: Vec2, + sides: int, + radius: f32, + brush: Brush, + outline_color: Color = {}, + outline_width: f32 = 0, + origin: Vec2 = {}, + rotation: f32 = 0, + feather_px: f32 = DFT_FEATHER_PX, +) { + if sides < 3 do return + + prim := build_polygon_primitive(center, sides, radius, origin, rotation, feather_px) + apply_brush_and_outline(layer, &prim, .NGon, brush, outline_color, outline_width) +} + +// Draw a ring, arc, or pie slice via SDF. +// Full ring by default. Pass start_angle/end_angle (degrees) for partial arcs. +// Use inner_radius = 0 for pie slices (sectors). +// Origin semantics: see `circle`. +ring :: proc( + layer: ^Layer, + center: Vec2, + inner_radius, outer_radius: f32, + brush: Brush, + outline_color: Color = {}, + outline_width: f32 = 0, + start_angle: f32 = 0, + end_angle: f32 = DFT_CIRC_END_ANGLE, + origin: Vec2 = {}, + rotation: f32 = 0, + feather_px: f32 = DFT_FEATHER_PX, +) { + prim, arc_flags := build_ring_arc_primitive( + center, + inner_radius, + outer_radius, + start_angle, + end_angle, + origin, + rotation, + feather_px, + ) + apply_brush_and_outline(layer, &prim, .Ring_Arc, brush, outline_color, outline_width, arc_flags) +} + +// Draw a line segment via SDF (emitted as a rotated capsule-shaped RRect). +// Round caps are produced by setting corner radii equal to half the thickness. +line :: proc( + layer: ^Layer, + start_position, end_position: Vec2, + brush: Brush, + thickness: f32 = DFT_STROKE_THICKNESS, + outline_color: Color = {}, + outline_width: f32 = 0, + feather_px: f32 = DFT_FEATHER_PX, +) { + delta_x := end_position.x - start_position.x + delta_y := end_position.y - start_position.y + seg_length := math.sqrt(delta_x * delta_x + delta_y * delta_y) + if seg_length < 0.0001 do return + rotation_radians := math.atan2(delta_y, delta_x) + sin_angle, cos_angle := math.sincos(rotation_radians) + + center_x := (start_position.x + end_position.x) * 0.5 + center_y := (start_position.y + end_position.y) * 0.5 + + half_length := seg_length * 0.5 + half_thickness := thickness * 0.5 + cap_radius := half_thickness + + half_feather := feather_px * 0.5 + padding := half_feather / GLOB.dpi_scaling + dpi_scale := GLOB.dpi_scaling + + // Expand bounds for rotation + bounds_half := rotated_aabb_half_extents(half_length + cap_radius, half_thickness, cos_angle, sin_angle) + + prim := Core_2D_Primitive { + bounds = { + center_x - bounds_half.x - padding, + center_y - bounds_half.y - padding, + center_x + bounds_half.x + padding, + center_y + bounds_half.y + padding, + }, + rotation_sc = pack_rotation_sc(sin_angle, cos_angle), + } + prim.params.rrect = RRect_Params { + half_size = {(half_length + cap_radius) * dpi_scale, half_thickness * dpi_scale}, + radii = { + cap_radius * dpi_scale, + cap_radius * dpi_scale, + cap_radius * dpi_scale, + cap_radius * dpi_scale, + }, + half_feather = half_feather, + } + apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width) +} + +// Draw a line strip via decomposed SDF line segments. +line_strip :: proc( + layer: ^Layer, + points: []Vec2, + brush: Brush, + thickness: f32 = DFT_STROKE_THICKNESS, + outline_color: Color = {}, + outline_width: f32 = 0, + feather_px: f32 = DFT_FEATHER_PX, +) { + if len(points) < 2 do return + for i in 0 ..< len(points) - 1 { + line(layer, points[i], points[i + 1], brush, thickness, outline_color, outline_width, feather_px) + } +} diff --git a/draw/draw.odin b/draw/draw.odin index 51c4cb3..1011ab9 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -10,6 +10,11 @@ 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") @@ -29,10 +34,18 @@ when ODIN_OS == .Darwin { 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 ----- @@ -48,64 +61,70 @@ 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, // Tessellated shape vertices staged for GPU upload. - tmp_text_verts: [dynamic]Vertex, // Text vertices staged for GPU upload. - tmp_text_indices: [dynamic]c.int, // Text index buffer staged for GPU upload. - tmp_text_batches: [dynamic]TextBatch, // Text atlas batch metadata for indexed drawing. - tmp_primitives: [dynamic]Base_2D_Primitive, // SDF primitives staged for GPU storage buffer upload (base 2D pipeline). - 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_backdrop_primitives: [dynamic]Backdrop_Primitive, // Backdrop 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. + 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. + 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. - // -- Pipeline (accessed every draw_layer call) -- - pipeline_2d_base: Pipeline_2D_Base, // The unified 2D GPU pipeline (shaders, buffers, samplers). - pipeline_2d_backdrop: Pipeline_2D_Backdrop, // Frosted-glass backdrop blur pipeline (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. + // -- 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. + 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. + 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_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. + 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_backdrop_primitives: int, + 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. + odin_context: runtime.Context, // Odin context captured at init for use in callbacks. } // --------------------------------------------------------------------------------------------------------------------- -// ----- Core types -------------------- +// ----- Core types ------------ // --------------------------------------------------------------------------------------------------------------------- // A 2D position in world space. Non-distinct alias for [2]f32 — bare literals like {100, 200} @@ -128,7 +147,7 @@ Vec2 :: [2]f32 // 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 Base_2D_Primitive or Backdrop_Primitive structs (e.g. .color), the 4 bytes +// 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 @@ -139,6 +158,13 @@ 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 { @@ -201,7 +227,7 @@ color_to_f32 :: proc(color: Color) -> [4]f32 { // 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 structs manually for prepare_shape must premultiply their colors. +// 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 { @@ -212,22 +238,21 @@ premultiply_color :: #force_inline proc(color: Color) -> Color { } } -Rectangle :: struct { - x: f32, - y: f32, - width: f32, - height: f32, -} +// --------------------------------------------------------------------------------------------------------------------- +// ----- Frame layout types ------------ +// --------------------------------------------------------------------------------------------------------------------- +//INTERNAL Sub_Batch_Kind :: enum u8 { - Tessellated, // non-indexed, white texture or user texture, base 2D mode 0 - Text, // indexed, atlas texture, base 2D mode 0 - SDF, // instanced unit quad, base 2D mode 1 - // instanced unit quad, backdrop pipeline V-composite (indexes Backdrop_Primitive). + 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 @@ -248,12 +273,17 @@ Layer :: struct { 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 @@ -272,46 +302,56 @@ init :: proc( ) { min_memory_size: c.size_t = cast(c.size_t)clay.MinMemorySize() - pipeline, pipeline_ok := create_pipeline_2d_base(device, window) - if !pipeline_ok { + core, core_ok := create_core_2d(device, window) + if !core_ok { return false } - backdrop_pipeline, backdrop_pipeline_ok := create_pipeline_2d_backdrop(device, window) - if !backdrop_pipeline_ok { - destroy_pipeline_2d_base(device, &pipeline) + 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_pipeline_2d_backdrop(device, &backdrop_pipeline) - destroy_pipeline_2d_base(device, &pipeline) + 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, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_indices = make([dynamic]c.int, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_batches = make([dynamic]TextBatch, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_primitives = make([dynamic]Base_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_backdrop_primitives = make([dynamic]Backdrop_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), - pipeline_2d_base = pipeline, - pipeline_2d_backdrop = backdrop_pipeline, - text_cache = text_cache, + 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 @@ -345,8 +385,8 @@ resize_global :: proc() { 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_backdrop_primitives) > GLOB.max_backdrop_primitives do GLOB.max_backdrop_primitives = len(GLOB.tmp_backdrop_primitives) - shrink(&GLOB.tmp_backdrop_primitives, GLOB.max_backdrop_primitives) + 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) { @@ -358,7 +398,7 @@ destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) { delete(GLOB.tmp_text_batches) delete(GLOB.tmp_primitives) delete(GLOB.tmp_sub_batches) - delete(GLOB.tmp_backdrop_primitives) + 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) @@ -367,12 +407,12 @@ destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) { destroy_sampler_pool() for ttf_text in GLOB.pending_text_releases do sdl_ttf.DestroyText(ttf_text) delete(GLOB.pending_text_releases) - destroy_pipeline_2d_backdrop(device, &GLOB.pipeline_2d_backdrop) - destroy_pipeline_2d_base(device, &GLOB.pipeline_2d_base) + destroy_backdrop(device, &GLOB.backdrop) + destroy_core_2d(device, &GLOB.core_2d) destroy_text_cache() } -// Internal +//INTERNAL clear_global :: proc() { // Process deferred texture releases from the previous frame process_pending_texture_releases() @@ -394,33 +434,11 @@ clear_global :: proc() { clear(&GLOB.tmp_text_batches) clear(&GLOB.tmp_primitives) clear(&GLOB.tmp_sub_batches) - clear(&GLOB.tmp_backdrop_primitives) + clear(&GLOB.tmp_gaussian_blur_primitives) } // --------------------------------------------------------------------------------------------------------------------- -// ----- Text measurement (Clay) ------- -// --------------------------------------------------------------------------------------------------------------------- - -@(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} -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- Frame lifecycle --------------- +// ----- Frame ------------ // --------------------------------------------------------------------------------------------------------------------- // Sets up renderer to begin upload to the GPU. Returns starting `Layer` to begin processing primitives for. @@ -472,133 +490,89 @@ new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer { return &GLOB.layers[GLOB.curr_layer_index] } -// --------------------------------------------------------------------------------------------------------------------- -// ----- Built-in primitive processing -- -// --------------------------------------------------------------------------------------------------------------------- - -// Submit shape vertices (colored triangles) to the given layer for rendering. -// TODO: Should probably be renamed to better match tesselated naming conventions in the library. -prepare_shape :: proc(layer: ^Layer, vertices: []Vertex) { - if len(vertices) == 0 do return - offset := u32(len(GLOB.tmp_shape_verts)) - append(&GLOB.tmp_shape_verts, ..vertices) - scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] - append_or_extend_sub_batch(scissor, layer, .Tessellated, offset, u32(len(vertices))) -} - -// Submit an SDF primitive to the given layer for rendering. -prepare_sdf_primitive :: proc(layer: ^Layer, prim: Base_2D_Primitive) { - offset := u32(len(GLOB.tmp_primitives)) - append(&GLOB.tmp_primitives, prim) - scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] - append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1) -} - -// Submit a text element to the given layer for rendering. -// Copies SDL_ttf vertices directly (with baked position) and copies indices for indexed drawing. -prepare_text :: proc(layer: ^Layer, text: Text) { - data := sdl_ttf.GetGPUTextDrawData(text.sdl_text) - if data == nil { - return // nil is normal for empty text +// 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()) } - scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + // 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() - // Snap base position to integer physical pixels to avoid atlas sub-pixel - // sampling blur (and the off-by-one bottom-row clip that comes with it). - base_x := math.round(text.position[0] * GLOB.dpi_scaling) - base_y := math.round(text.position[1] * GLOB.dpi_scaling) + // 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) - // Premultiply text color once — reused across all glyph vertices. - pm_color := premultiply_color(text.color) + 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()) + } - for data != nil { - vertex_start := u32(len(GLOB.tmp_text_verts)) - index_start := u32(len(GLOB.tmp_text_indices)) - - // Copy vertices with baked position offset - for i in 0 ..< data.num_vertices { - pos := data.xy[i] - uv := data.uv[i] - append( - &GLOB.tmp_text_verts, - Vertex{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = pm_color}, - ) + 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()) } - - // Copy indices directly - append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices]) - - batch_idx := u32(len(GLOB.tmp_text_batches)) - append( - &GLOB.tmp_text_batches, - TextBatch { - atlas_texture = data.atlas_texture, - vertex_start = vertex_start, - vertex_count = u32(data.num_vertices), - index_start = index_start, - index_count = u32(data.num_indices), - }, - ) - - // Each atlas chunk is a separate sub-batch (different atlas textures can't coalesce) - append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1) - - data = data.next - } -} - -// Submit a text element with a 2D affine transform applied to vertices. -// Used by the high-level `text` proc when rotation or a non-zero origin is specified. -// NOTE: xform must be in physical (DPI-scaled) pixel space — the caller pre-scales -// pos and origin by GLOB.dpi_scaling before building the transform. -prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform_2D) { - data := sdl_ttf.GetGPUTextDrawData(text.sdl_text) - if data == nil { return } - scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + render_texture := swapchain_texture + if has_backdrop { + ensure_backdrop_textures(device, sdl.GetGPUSwapchainTextureFormat(device, window), width, height) + render_texture = GLOB.backdrop.source_texture + } - // Premultiply text color once — reused across all glyph vertices. - pm_color := premultiply_color(text.color) + // 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, + } - for data != nil { - vertex_start := u32(len(GLOB.tmp_text_verts)) - index_start := u32(len(GLOB.tmp_text_indices)) + // 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) + } - for i in 0 ..< data.num_vertices { - pos := data.xy[i] - uv := data.uv[i] - // SDL_ttf gives glyph positions in physical pixels relative to text origin. - // The transform is already in physical-pixel space (caller pre-scaled), - // so we apply directly — no per-vertex DPI divide/multiply. - append( - &GLOB.tmp_text_verts, - Vertex{position = apply_transform(transform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = pm_color}, - ) - } - - append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices]) - - batch_idx := u32(len(GLOB.tmp_text_batches)) - append( - &GLOB.tmp_text_batches, - TextBatch { - atlas_texture = data.atlas_texture, - vertex_start = vertex_start, - vertex_count = u32(data.num_vertices), - index_start = index_start, - index_count = u32(data.num_indices), - }, + // 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) + } - append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1) - - data = data.next + 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 @@ -606,7 +580,7 @@ prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform // 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). -@(private) +//INTERNAL append_or_extend_sub_batch :: proc( scissor: ^Scissor, layer: ^Layer, @@ -645,7 +619,7 @@ append_or_extend_sub_batch :: proc( } // --------------------------------------------------------------------------------------------------------------------- -// ----- Clay ------------------------ +// ----- Clay ------------ // --------------------------------------------------------------------------------------------------------------------- @(private = "file") @@ -654,6 +628,24 @@ clay_error_handler :: proc "c" (errorData: clay.ErrorData) { 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. // @@ -822,142 +814,18 @@ prepare_clay_batch :: proc( } } -// 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()) - } - - // 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.pipeline_2d_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.pipeline_2d_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()) - } -} - // --------------------------------------------------------------------------------------------------------------------- -// ----- Utility ----------------------- -// --------------------------------------------------------------------------------------------------------------------- - -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, - } -} - -Draw_Mode :: enum u32 { - Tessellated = 0, - SDF = 1, -} - -Vertex_Uniforms :: struct { - projection: matrix[4, 4]f32, - scale: f32, - mode: Draw_Mode, -} - -// Push projection, dpi scale, and rendering mode as a single uniform block (slot 0). -push_globals :: proc( - cmd_buffer: ^sdl.GPUCommandBuffer, - width: f32, - height: f32, - mode: Draw_Mode = .Tessellated, -) { - globals := Vertex_Uniforms { - projection = ortho_rh( - left = 0.0, - top = 0.0, - right = f32(width), - bottom = f32(height), - near = -1.0, - far = 1.0, - ), - scale = GLOB.dpi_scaling, - mode = mode, - } - - sdl.PushGPUVertexUniformData(cmd_buffer, 0, &globals, size_of(Vertex_Uniforms)) -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- Buffer ------------------------ +// ----- Buffer ------------ // --------------------------------------------------------------------------------------------------------------------- +//INTERNAL Buffer :: struct { gpu: ^sdl.GPUBuffer, transfer: ^sdl.GPUTransferBuffer, size: u32, } +//INTERNAL @(require_results) create_buffer :: proc( device: ^sdl.GPUDevice, @@ -984,6 +852,7 @@ create_buffer :: proc( return Buffer{gpu, transfer, size}, true } +//INTERNAL grow_buffer_if_needed :: proc( device: ^sdl.GPUDevice, buffer: ^Buffer, @@ -1008,15 +877,26 @@ grow_buffer_if_needed :: proc( } } +//INTERNAL destroy_buffer :: proc(device: ^sdl.GPUDevice, buffer: ^Buffer) { sdl.ReleaseGPUBuffer(device, buffer.gpu) sdl.ReleaseGPUTransferBuffer(device, buffer.transfer) } // --------------------------------------------------------------------------------------------------------------------- -// ----- Transform ------------------------ +// ----- 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 { @@ -1078,9 +958,114 @@ needs_transform :: #force_inline proc(origin: Vec2, rotation: f32) -> bool { } // --------------------------------------------------------------------------------------------------------------------- -// ----- Procedure Groups ------------------------ +// ----- 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, diff --git a/draw/pipeline_2d_base.odin b/draw/pipeline_2d_base.odin deleted file mode 100644 index a47e091..0000000 --- a/draw/pipeline_2d_base.odin +++ /dev/null @@ -1,827 +0,0 @@ -package draw - -import "core:c" -import "core:log" -import "core:mem" -import sdl "vendor:sdl3" - -// Vertex layout for tessellated and text geometry. -// IMPORTANT: `color` must be premultiplied alpha (RGB channels pre-scaled by alpha). -// The tessellated fragment shader passes vertex color through directly — it does NOT -// premultiply. The blend state is ONE, ONE_MINUS_SRC_ALPHA (premultiplied-over). -// Use `premultiply_color` when constructing vertices manually for `prepare_shape`. -Vertex :: struct { - position: Vec2, - uv: [2]f32, - color: Color, -} - -TextBatch :: struct { - atlas_texture: ^sdl.GPUTexture, - vertex_start: u32, - vertex_count: u32, - index_start: u32, - index_count: u32, -} - -// ---------------------------------------------------------------------------------------------------------------- -// ----- SDF primitive types ----------- -// ---------------------------------------------------------------------------------------------------------------- - -// The SDF path evaluates one of four signed distance functions per primitive, dispatched -// by Shape_Kind encoded in the low byte of Base_2D_Primitive.flags: -// -// RRect — rounded rectangle with per-corner radii (sdRoundedBox). Also covers circles -// (uniform radii = half-size), capsule-style line segments (rotated, max rounding), -// and other RRect-reducible shapes. -// NGon — regular polygon with N sides and optional rounding. -// Ellipse — approximate ellipse (non-exact SDF, suitable for UI but not for shape merging). -// Ring_Arc — annular ring with optional angular clipping. Covers full rings, partial arcs, -// pie slices (inner_radius = 0), and loading spinners. -Shape_Kind :: enum u8 { - Solid = 0, // tessellated path (mode marker; not a real SDF kind) - RRect = 1, - NGon = 2, - Ellipse = 3, - Ring_Arc = 4, -} - -Shape_Flag :: enum u8 { - Textured, // bit 0: sample texture using uv_rect (mutually exclusive with Gradient via Brush union) - Gradient, // bit 1: 2-color gradient using effects.gradient_color as end/outer color - Gradient_Radial, // bit 2: if set with Gradient, radial from center; else linear at angle - Outline, // bit 3: outer outline band using effects.outline_color; CPU expands bounds by outline_width - Rotated, // bit 4: shape has non-zero rotation; rotation_sc contains packed sin/cos - Arc_Narrow, // bit 5: ring arc span ≤ π — intersect half-planes. Neither Arc bit = full ring. - Arc_Wide, // bit 6: ring arc span > π — union half-planes. Neither Arc bit = full ring. -} - -Shape_Flags :: bit_set[Shape_Flag;u8] - -RRect_Params :: struct { - half_size: [2]f32, - radii: [4]f32, - half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d) - _: f32, -} - -NGon_Params :: struct { - radius: f32, - sides: f32, - half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d) - _: [5]f32, -} - -Ellipse_Params :: struct { - radii: [2]f32, - half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d) - _: [5]f32, -} - -Ring_Arc_Params :: struct { - inner_radius: f32, // inner radius in physical pixels (0 for pie slice) - outer_radius: f32, // outer radius in physical pixels - normal_start: [2]f32, // pre-computed outward normal of start edge: (sin(start), -cos(start)) - normal_end: [2]f32, // pre-computed outward normal of end edge: (-sin(end), cos(end)) - half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d) - _: f32, -} - -Shape_Params :: struct #raw_union { - rrect: RRect_Params, - ngon: NGon_Params, - ellipse: Ellipse_Params, - ring_arc: Ring_Arc_Params, - raw: [8]f32, -} -#assert(size_of(Shape_Params) == 32) - -// GPU-side storage for 2-color gradient parameters and/or outline parameters. -// Packed into 16 bytes. Independent from uv_rect — texture and outline can coexist. -// The shader reads gradient_color and outline_color via unpackUnorm4x8. -// gradient_dir_sc stores the pre-computed gradient direction as (cos, sin) in f16 pair -// via unpackHalf2x16. outline_packed stores outline_width as f16 via unpackHalf2x16. -Gradient_Outline :: struct { - gradient_color: Color, // 0: end (linear) or outer (radial) gradient color - outline_color: Color, // 4: outline band color - gradient_dir_sc: u32, // 8: packed f16 pair: low = cos(angle), high = sin(angle) — pre-computed gradient direction - outline_packed: u32, // 12: packed f16 pair: low = outline_width (f16, physical pixels), high = reserved -} -#assert(size_of(Gradient_Outline) == 16) - -// GPU layout: 96 bytes, std430-compatible. The shader declares this as a storage buffer struct. -// The low byte of `flags` encodes the Shape_Kind (0 = tessellated, 1-4 = SDF kinds). -// Bits 8-15 encode Shape_Flags (Textured, Gradient, Gradient_Radial, Outline, Rotated, Arc_Narrow, Arc_Wide). -// rotation_sc stores pre-computed sin/cos of the rotation angle as a packed f16 pair, -// avoiding per-pixel trigonometry in the fragment shader. Only read when .Rotated is set. -// -// Named Base_2D_Primitive (not just Primitive) to disambiguate from Backdrop_Primitive in -// backdrop.odin. The two pipelines have unrelated GPU layouts and unrelated fragment-shader -// contracts; pairing each with its own primitive type keeps cross-references unambiguous -// when grepping the codebase. -Base_2D_Primitive :: struct { - bounds: [4]f32, // 0: min_x, min_y, max_x, max_y (world-space, pre-DPI) - color: Color, // 16: u8x4, fill color / gradient start color / texture tint - flags: u32, // 20: low byte = Shape_Kind, bits 8+ = Shape_Flags - rotation_sc: u32, // 24: packed f16 pair: low = sin(angle), high = cos(angle). Requires .Rotated flag. - _pad: f32, // 28: reserved for future use - params: Shape_Params, // 32: per-kind shape parameters (raw union, 32 bytes) - uv_rect: [4]f32, // 64: texture UV coordinates (u_min, v_min, u_max, v_max). Read when .Textured. - effects: Gradient_Outline, // 80: gradient and/or outline parameters. Read when .Gradient and/or .Outline. -} -#assert(size_of(Base_2D_Primitive) == 96) - -// Pack shape kind and flags into the Base_2D_Primitive.flags field. The low byte encodes the -// Shape_Kind (which also serves as the SDF mode marker — kind > 0 means SDF path). The -// tessellated path leaves the field at 0 (Solid kind, set by vertex shader zero-initialization). -pack_kind_flags :: #force_inline proc(kind: Shape_Kind, flags: Shape_Flags) -> u32 { - return u32(kind) | (u32(transmute(u8)flags) << 8) -} - -// Pack two f16 values into a single u32 for GPU consumption via unpackHalf2x16. -// Used to pack gradient_dir_sc (cos/sin) and outline_packed (width/reserved) in Gradient_Outline. -pack_f16_pair :: #force_inline proc(low, high: f16) -> u32 { - return u32(transmute(u16)low) | (u32(transmute(u16)high) << 16) -} - -Pipeline_2D_Base :: struct { - sdl_pipeline: ^sdl.GPUGraphicsPipeline, - vertex_buffer: Buffer, - index_buffer: Buffer, - unit_quad_buffer: ^sdl.GPUBuffer, - primitive_buffer: Buffer, - white_texture: ^sdl.GPUTexture, - sampler: ^sdl.GPUSampler, -} - -// MSAA is not supported by levlib (see init's doc comment in draw.odin); the PSO is hard-wired -// to single-sample. SDF text and shapes provide analytical AA via smoothstep; tessellated user -// geometry is not anti-aliased. -@(private) -create_pipeline_2d_base :: proc( - device: ^sdl.GPUDevice, - window: ^sdl.Window, -) -> ( - pipeline: Pipeline_2D_Base, - ok: bool, -) { - // On failure, clean up any partially-created resources - defer if !ok { - if pipeline.sampler != nil do sdl.ReleaseGPUSampler(device, pipeline.sampler) - if pipeline.white_texture != nil do sdl.ReleaseGPUTexture(device, pipeline.white_texture) - if pipeline.unit_quad_buffer != nil do sdl.ReleaseGPUBuffer(device, pipeline.unit_quad_buffer) - if pipeline.primitive_buffer.gpu != nil do destroy_buffer(device, &pipeline.primitive_buffer) - if pipeline.index_buffer.gpu != nil do destroy_buffer(device, &pipeline.index_buffer) - if pipeline.vertex_buffer.gpu != nil do destroy_buffer(device, &pipeline.vertex_buffer) - if pipeline.sdl_pipeline != nil do sdl.ReleaseGPUGraphicsPipeline(device, pipeline.sdl_pipeline) - } - - active_shader_formats := sdl.GetGPUShaderFormats(device) - if PLATFORM_SHADER_FORMAT_FLAG not_in active_shader_formats { - log.errorf( - "draw: no embedded shader matches active GPU formats; this build supports %v but device reports %v", - PLATFORM_SHADER_FORMAT, - active_shader_formats, - ) - return pipeline, false - } - - log.debug("Loaded", len(BASE_VERT_2D_RAW), "vert bytes") - log.debug("Loaded", len(BASE_FRAG_2D_RAW), "frag bytes") - - vert_info := sdl.GPUShaderCreateInfo { - code_size = len(BASE_VERT_2D_RAW), - code = raw_data(BASE_VERT_2D_RAW), - entrypoint = SHADER_ENTRY, - format = {PLATFORM_SHADER_FORMAT_FLAG}, - stage = .VERTEX, - num_uniform_buffers = 1, - num_storage_buffers = 1, - } - - frag_info := sdl.GPUShaderCreateInfo { - code_size = len(BASE_FRAG_2D_RAW), - code = raw_data(BASE_FRAG_2D_RAW), - entrypoint = SHADER_ENTRY, - format = {PLATFORM_SHADER_FORMAT_FLAG}, - stage = .FRAGMENT, - num_samplers = 1, - } - - vert_shader := sdl.CreateGPUShader(device, vert_info) - if vert_shader == nil { - log.errorf("Could not create draw vertex shader: %s", sdl.GetError()) - return pipeline, false - } - - frag_shader := sdl.CreateGPUShader(device, frag_info) - if frag_shader == nil { - sdl.ReleaseGPUShader(device, vert_shader) - log.errorf("Could not create draw fragment shader: %s", sdl.GetError()) - return pipeline, false - } - - vertex_attributes: [3]sdl.GPUVertexAttribute = { - // position (GLSL location 0) - sdl.GPUVertexAttribute{buffer_slot = 0, location = 0, format = .FLOAT2, offset = 0}, - // uv (GLSL location 1) - sdl.GPUVertexAttribute{buffer_slot = 0, location = 1, format = .FLOAT2, offset = size_of([2]f32)}, - // color (GLSL location 2, u8x4 normalized to float by GPU) - sdl.GPUVertexAttribute{buffer_slot = 0, location = 2, format = .UBYTE4_NORM, offset = size_of([2]f32) * 2}, - } - - pipeline_info := sdl.GPUGraphicsPipelineCreateInfo { - vertex_shader = vert_shader, - fragment_shader = frag_shader, - primitive_type = .TRIANGLELIST, - multisample_state = sdl.GPUMultisampleState{sample_count = ._1}, - target_info = sdl.GPUGraphicsPipelineTargetInfo { - color_target_descriptions = &sdl.GPUColorTargetDescription { - format = sdl.GetGPUSwapchainTextureFormat(device, window), - // Premultiplied-alpha blending: src outputs RGB pre-multiplied by alpha, - // so src factor is ONE (not SRC_ALPHA). This eliminates the per-pixel - // divide in the outline path and is the standard blend mode used by - // Skia, Flutter, and GPUI. - blend_state = sdl.GPUColorTargetBlendState { - enable_blend = true, - enable_color_write_mask = true, - src_color_blendfactor = .ONE, - dst_color_blendfactor = .ONE_MINUS_SRC_ALPHA, - color_blend_op = .ADD, - src_alpha_blendfactor = .ONE, - dst_alpha_blendfactor = .ONE_MINUS_SRC_ALPHA, - alpha_blend_op = .ADD, - color_write_mask = sdl.GPUColorComponentFlags{.R, .G, .B, .A}, - }, - }, - num_color_targets = 1, - }, - vertex_input_state = sdl.GPUVertexInputState { - vertex_buffer_descriptions = &sdl.GPUVertexBufferDescription { - slot = 0, - input_rate = .VERTEX, - pitch = size_of(Vertex), - }, - num_vertex_buffers = 1, - vertex_attributes = raw_data(vertex_attributes[:]), - num_vertex_attributes = 3, - }, - } - - pipeline.sdl_pipeline = sdl.CreateGPUGraphicsPipeline(device, pipeline_info) - // Shaders are no longer needed regardless of pipeline creation success - sdl.ReleaseGPUShader(device, vert_shader) - sdl.ReleaseGPUShader(device, frag_shader) - if pipeline.sdl_pipeline == nil { - log.errorf("Failed to create draw graphics pipeline: %s", sdl.GetError()) - return pipeline, false - } - - // Create vertex buffer - vert_buf_ok: bool - pipeline.vertex_buffer, vert_buf_ok = create_buffer( - device, - size_of(Vertex) * BUFFER_INIT_SIZE, - sdl.GPUBufferUsageFlags{.VERTEX}, - ) - if !vert_buf_ok do return pipeline, false - - // Create index buffer (used by text) - idx_buf_ok: bool - pipeline.index_buffer, idx_buf_ok = create_buffer( - device, - size_of(c.int) * BUFFER_INIT_SIZE, - sdl.GPUBufferUsageFlags{.INDEX}, - ) - if !idx_buf_ok do return pipeline, false - - // Create primitive storage buffer (used by SDF instanced drawing) - prim_buf_ok: bool - pipeline.primitive_buffer, prim_buf_ok = create_buffer( - device, - size_of(Base_2D_Primitive) * BUFFER_INIT_SIZE, - sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ}, - ) - if !prim_buf_ok do return pipeline, false - - // Create static 6-vertex unit quad buffer (two triangles, TRIANGLELIST) - pipeline.unit_quad_buffer = sdl.CreateGPUBuffer( - device, - sdl.GPUBufferCreateInfo{usage = {.VERTEX}, size = 6 * size_of(Vertex)}, - ) - if pipeline.unit_quad_buffer == nil { - log.errorf("Failed to create unit quad buffer: %s", sdl.GetError()) - return pipeline, false - } - - // Create 1x1 white pixel texture - pipeline.white_texture = sdl.CreateGPUTexture( - device, - sdl.GPUTextureCreateInfo { - type = .D2, - format = .R8G8B8A8_UNORM, - usage = {.SAMPLER}, - width = 1, - height = 1, - layer_count_or_depth = 1, - num_levels = 1, - sample_count = ._1, - }, - ) - if pipeline.white_texture == nil { - log.errorf("Failed to create white pixel texture: %s", sdl.GetError()) - return pipeline, false - } - - // Upload white pixel and unit quad data in a single command buffer - white_pixel := Color{255, 255, 255, 255} - white_transfer_buf := sdl.CreateGPUTransferBuffer( - device, - sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(white_pixel)}, - ) - if white_transfer_buf == nil { - log.errorf("Failed to create white pixel transfer buffer: %s", sdl.GetError()) - return pipeline, false - } - defer sdl.ReleaseGPUTransferBuffer(device, white_transfer_buf) - - white_ptr := sdl.MapGPUTransferBuffer(device, white_transfer_buf, false) - if white_ptr == nil { - log.errorf("Failed to map white pixel transfer buffer: %s", sdl.GetError()) - return pipeline, false - } - mem.copy(white_ptr, &white_pixel, size_of(white_pixel)) - sdl.UnmapGPUTransferBuffer(device, white_transfer_buf) - - quad_verts := [6]Vertex { - {position = {0, 0}}, - {position = {1, 0}}, - {position = {0, 1}}, - {position = {0, 1}}, - {position = {1, 0}}, - {position = {1, 1}}, - } - quad_transfer_buf := sdl.CreateGPUTransferBuffer( - device, - sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(quad_verts)}, - ) - if quad_transfer_buf == nil { - log.errorf("Failed to create unit quad transfer buffer: %s", sdl.GetError()) - return pipeline, false - } - defer sdl.ReleaseGPUTransferBuffer(device, quad_transfer_buf) - - quad_ptr := sdl.MapGPUTransferBuffer(device, quad_transfer_buf, false) - if quad_ptr == nil { - log.errorf("Failed to map unit quad transfer buffer: %s", sdl.GetError()) - return pipeline, false - } - mem.copy(quad_ptr, &quad_verts, size_of(quad_verts)) - sdl.UnmapGPUTransferBuffer(device, quad_transfer_buf) - - upload_cmd_buffer := sdl.AcquireGPUCommandBuffer(device) - if upload_cmd_buffer == nil { - log.errorf("Failed to acquire command buffer for init upload: %s", sdl.GetError()) - return pipeline, false - } - upload_pass := sdl.BeginGPUCopyPass(upload_cmd_buffer) - - sdl.UploadToGPUTexture( - upload_pass, - sdl.GPUTextureTransferInfo{transfer_buffer = white_transfer_buf}, - sdl.GPUTextureRegion{texture = pipeline.white_texture, w = 1, h = 1, d = 1}, - false, - ) - - sdl.UploadToGPUBuffer( - upload_pass, - sdl.GPUTransferBufferLocation{transfer_buffer = quad_transfer_buf}, - sdl.GPUBufferRegion{buffer = pipeline.unit_quad_buffer, offset = 0, size = size_of(quad_verts)}, - false, - ) - - sdl.EndGPUCopyPass(upload_pass) - if !sdl.SubmitGPUCommandBuffer(upload_cmd_buffer) { - log.errorf("Failed to submit init upload command buffer: %s", sdl.GetError()) - return pipeline, false - } - - log.debug("White pixel texture and unit quad buffer created and uploaded") - - // Create sampler (shared by shapes and text) - pipeline.sampler = sdl.CreateGPUSampler( - device, - sdl.GPUSamplerCreateInfo { - min_filter = .LINEAR, - mag_filter = .LINEAR, - mipmap_mode = .LINEAR, - address_mode_u = .CLAMP_TO_EDGE, - address_mode_v = .CLAMP_TO_EDGE, - address_mode_w = .CLAMP_TO_EDGE, - }, - ) - if pipeline.sampler == nil { - log.errorf("Could not create GPU sampler: %s", sdl.GetError()) - return pipeline, false - } - - log.debug("Done creating unified draw pipeline") - return pipeline, true -} - -@(private) -upload :: proc(device: ^sdl.GPUDevice, pass: ^sdl.GPUCopyPass) { - // Upload vertices (shapes then text into one buffer) - shape_vert_count := u32(len(GLOB.tmp_shape_verts)) - text_vert_count := u32(len(GLOB.tmp_text_verts)) - total_vert_count := shape_vert_count + text_vert_count - - if total_vert_count > 0 { - total_vert_size := total_vert_count * size_of(Vertex) - shape_vert_size := shape_vert_count * size_of(Vertex) - text_vert_size := text_vert_count * size_of(Vertex) - - grow_buffer_if_needed( - device, - &GLOB.pipeline_2d_base.vertex_buffer, - total_vert_size, - sdl.GPUBufferUsageFlags{.VERTEX}, - ) - - vert_array := sdl.MapGPUTransferBuffer(device, GLOB.pipeline_2d_base.vertex_buffer.transfer, false) - if vert_array == nil { - log.panicf("Failed to map vertex transfer buffer: %s", sdl.GetError()) - } - if shape_vert_size > 0 { - mem.copy(vert_array, raw_data(GLOB.tmp_shape_verts), int(shape_vert_size)) - } - if text_vert_size > 0 { - mem.copy( - rawptr(uintptr(vert_array) + uintptr(shape_vert_size)), - raw_data(GLOB.tmp_text_verts), - int(text_vert_size), - ) - } - sdl.UnmapGPUTransferBuffer(device, GLOB.pipeline_2d_base.vertex_buffer.transfer) - - sdl.UploadToGPUBuffer( - pass, - sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.pipeline_2d_base.vertex_buffer.transfer}, - sdl.GPUBufferRegion{buffer = GLOB.pipeline_2d_base.vertex_buffer.gpu, offset = 0, size = total_vert_size}, - false, - ) - } - - // Upload text indices - index_count := u32(len(GLOB.tmp_text_indices)) - if index_count > 0 { - index_size := index_count * size_of(c.int) - - grow_buffer_if_needed( - device, - &GLOB.pipeline_2d_base.index_buffer, - index_size, - sdl.GPUBufferUsageFlags{.INDEX}, - ) - - idx_array := sdl.MapGPUTransferBuffer(device, GLOB.pipeline_2d_base.index_buffer.transfer, false) - if idx_array == nil { - log.panicf("Failed to map index transfer buffer: %s", sdl.GetError()) - } - mem.copy(idx_array, raw_data(GLOB.tmp_text_indices), int(index_size)) - sdl.UnmapGPUTransferBuffer(device, GLOB.pipeline_2d_base.index_buffer.transfer) - - sdl.UploadToGPUBuffer( - pass, - sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.pipeline_2d_base.index_buffer.transfer}, - sdl.GPUBufferRegion{buffer = GLOB.pipeline_2d_base.index_buffer.gpu, offset = 0, size = index_size}, - false, - ) - } - - // Upload SDF primitives - prim_count := u32(len(GLOB.tmp_primitives)) - if prim_count > 0 { - prim_size := prim_count * size_of(Base_2D_Primitive) - - grow_buffer_if_needed( - device, - &GLOB.pipeline_2d_base.primitive_buffer, - prim_size, - sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ}, - ) - - prim_array := sdl.MapGPUTransferBuffer(device, GLOB.pipeline_2d_base.primitive_buffer.transfer, false) - if prim_array == nil { - log.panicf("Failed to map primitive transfer buffer: %s", sdl.GetError()) - } - mem.copy(prim_array, raw_data(GLOB.tmp_primitives), int(prim_size)) - sdl.UnmapGPUTransferBuffer(device, GLOB.pipeline_2d_base.primitive_buffer.transfer) - - sdl.UploadToGPUBuffer( - pass, - sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.pipeline_2d_base.primitive_buffer.transfer}, - sdl.GPUBufferRegion{buffer = GLOB.pipeline_2d_base.primitive_buffer.gpu, offset = 0, size = prim_size}, - false, - ) - } -} - -@(private) -draw_layer :: proc( - device: ^sdl.GPUDevice, - window: ^sdl.Window, - cmd_buffer: ^sdl.GPUCommandBuffer, - render_texture: ^sdl.GPUTexture, - swapchain_width: u32, - swapchain_height: u32, - clear_color: [4]f32, - layer: ^Layer, -) { - if layer.sub_batch_len == 0 { - if !GLOB.cleared { - pass := sdl.BeginGPURenderPass( - cmd_buffer, - &sdl.GPUColorTargetInfo { - texture = render_texture, - clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]}, - load_op = .CLEAR, - store_op = .STORE, - }, - 1, - nil, - ) - sdl.EndGPURenderPass(pass) - GLOB.cleared = true - } - return - } - - bracket_start_abs := find_first_backdrop_in_layer(layer) - layer_end_abs := int(layer.sub_batch_start + layer.sub_batch_len) - - if bracket_start_abs < 0 { - // Fast path: no backdrop in this layer; render the whole sub-batch range in one pass. - render_layer_sub_batch_range( - cmd_buffer, - render_texture, - swapchain_width, - swapchain_height, - clear_color, - layer, - int(layer.sub_batch_start), - layer_end_abs, - ) - return - } - - // Bracketed layer: Pass A → backdrop bracket → Pass B. - // See README.md § "Backdrop pipeline" for the full ordering semantics. - render_layer_sub_batch_range( - cmd_buffer, - render_texture, - swapchain_width, - swapchain_height, - clear_color, - layer, - int(layer.sub_batch_start), - bracket_start_abs, - ) - - run_backdrop_bracket(cmd_buffer, layer, swapchain_width, swapchain_height) - - // Pass B: render the [bracket_start_abs, layer_end_abs) range. .Backdrop sub-batches in - // this range are dispatched by the bracket above and ignored here (the .Backdrop case in - // the inner switch is a no-op). LOAD is implied because Pass A or the bracket's V- - // composite has already touched render_texture. - render_layer_sub_batch_range( - cmd_buffer, - render_texture, - swapchain_width, - swapchain_height, - clear_color, - layer, - bracket_start_abs, - layer_end_abs, - ) -} - -// Render a sub-range of a layer's sub-batches in a single render pass. Iterates the layer's -// scissors and walks each scissor's sub-batches, dispatching by kind. The `range_start_abs` -// and `range_end_abs` parameters are absolute indices into GLOB.tmp_sub_batches; only sub- -// batches within `[range_start_abs, range_end_abs)` are drawn. -// -// .Backdrop sub-batches in the range are always silently skipped — they are dispatched by -// run_backdrop_bracket, not here. The empty .Backdrop case in the inner switch enforces this. -// -// Render-pass setup mirrors the original draw_layer: clear-or-load based on GLOB.cleared, -// pipeline + storage + index buffer bound up front, then per-batch state tracking. After this -// proc returns, GLOB.cleared is guaranteed true. -// -// If the range is empty after filtering (no eligible sub-batches at all), this proc still -// honors the no-clear-yet contract by issuing a clear-only pass when needed; otherwise it -// returns without opening a render pass. -@(private) -render_layer_sub_batch_range :: proc( - cmd_buffer: ^sdl.GPUCommandBuffer, - render_texture: ^sdl.GPUTexture, - swapchain_width: u32, - swapchain_height: u32, - clear_color: [4]f32, - layer: ^Layer, - range_start_abs: int, - range_end_abs: int, -) { - if range_start_abs >= range_end_abs { - // Empty range. If we still owe a clear, do a clear-only pass; otherwise nothing to do. - if !GLOB.cleared { - pass := sdl.BeginGPURenderPass( - cmd_buffer, - &sdl.GPUColorTargetInfo { - texture = render_texture, - clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]}, - load_op = .CLEAR, - store_op = .STORE, - }, - 1, - nil, - ) - sdl.EndGPURenderPass(pass) - GLOB.cleared = true - } - return - } - - render_pass := sdl.BeginGPURenderPass( - cmd_buffer, - &sdl.GPUColorTargetInfo { - texture = render_texture, - clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]}, - load_op = GLOB.cleared ? .LOAD : .CLEAR, - store_op = .STORE, - }, - 1, - nil, - ) - GLOB.cleared = true - - sdl.BindGPUGraphicsPipeline(render_pass, GLOB.pipeline_2d_base.sdl_pipeline) - - // Bind storage buffer (read by vertex shader in SDF mode) - sdl.BindGPUVertexStorageBuffers( - render_pass, - 0, - ([^]^sdl.GPUBuffer)(&GLOB.pipeline_2d_base.primitive_buffer.gpu), - 1, - ) - - // Always bind index buffer — harmless if no indexed draws are issued - sdl.BindGPUIndexBuffer( - render_pass, - sdl.GPUBufferBinding{buffer = GLOB.pipeline_2d_base.index_buffer.gpu, offset = 0}, - ._32BIT, - ) - - // Shorthand aliases for frequently-used pipeline resources - main_vert_buf := GLOB.pipeline_2d_base.vertex_buffer.gpu - unit_quad := GLOB.pipeline_2d_base.unit_quad_buffer - white_texture := GLOB.pipeline_2d_base.white_texture - sampler := GLOB.pipeline_2d_base.sampler - width := f32(swapchain_width) - height := f32(swapchain_height) - - // Initial GPU state: tessellated mode, main vertex buffer, no atlas bound yet - push_globals(cmd_buffer, width, height, .Tessellated) - sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1) - - current_mode: Draw_Mode = .Tessellated - current_vert_buf := main_vert_buf - current_atlas: ^sdl.GPUTexture - current_sampler := sampler - - // Text vertices live after shape vertices in the GPU vertex buffer - text_vertex_gpu_base := u32(len(GLOB.tmp_shape_verts)) - - for &scissor in GLOB.scissors[layer.scissor_start:][:layer.scissor_len] { - // Intersect this scissor's sub-batch span with the requested range. - scissor_start := int(scissor.sub_batch_start) - scissor_end := scissor_start + int(scissor.sub_batch_len) - effective_start := max(scissor_start, range_start_abs) - effective_end := min(scissor_end, range_end_abs) - if effective_start >= effective_end do continue - - sdl.SetGPUScissor(render_pass, scissor.bounds) - - for abs_idx in effective_start ..< effective_end { - batch := &GLOB.tmp_sub_batches[abs_idx] - switch batch.kind { - case .Tessellated: - if current_mode != .Tessellated { - push_globals(cmd_buffer, width, height, .Tessellated) - current_mode = .Tessellated - } - if current_vert_buf != main_vert_buf { - sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1) - current_vert_buf = main_vert_buf - } - // Determine texture and sampler for this batch - batch_texture: ^sdl.GPUTexture = white_texture - batch_sampler: ^sdl.GPUSampler = sampler - if batch.texture_id != INVALID_TEXTURE { - if bound_texture := texture_gpu_handle(batch.texture_id); bound_texture != nil { - batch_texture = bound_texture - } - batch_sampler = get_sampler(batch.sampler) - } - if current_atlas != batch_texture || current_sampler != batch_sampler { - sdl.BindGPUFragmentSamplers( - render_pass, - 0, - &sdl.GPUTextureSamplerBinding{texture = batch_texture, sampler = batch_sampler}, - 1, - ) - current_atlas = batch_texture - current_sampler = batch_sampler - } - sdl.DrawGPUPrimitives(render_pass, batch.count, 1, batch.offset, 0) - - case .Text: - if current_mode != .Tessellated { - push_globals(cmd_buffer, width, height, .Tessellated) - current_mode = .Tessellated - } - if current_vert_buf != main_vert_buf { - sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1) - current_vert_buf = main_vert_buf - } - text_batch := &GLOB.tmp_text_batches[batch.offset] - if current_atlas != text_batch.atlas_texture { - sdl.BindGPUFragmentSamplers( - render_pass, - 0, - &sdl.GPUTextureSamplerBinding{texture = text_batch.atlas_texture, sampler = sampler}, - 1, - ) - current_atlas = text_batch.atlas_texture - } - sdl.DrawGPUIndexedPrimitives( - render_pass, - text_batch.index_count, - 1, - text_batch.index_start, - i32(text_vertex_gpu_base + text_batch.vertex_start), - 0, - ) - - case .SDF: - if current_mode != .SDF { - push_globals(cmd_buffer, width, height, .SDF) - current_mode = .SDF - } - if current_vert_buf != unit_quad { - sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = unit_quad, offset = 0}, 1) - current_vert_buf = unit_quad - } - // Determine texture and sampler for this batch - batch_texture: ^sdl.GPUTexture = white_texture - batch_sampler: ^sdl.GPUSampler = sampler - if batch.texture_id != INVALID_TEXTURE { - if bound_texture := texture_gpu_handle(batch.texture_id); bound_texture != nil { - batch_texture = bound_texture - } - batch_sampler = get_sampler(batch.sampler) - } - if current_atlas != batch_texture || current_sampler != batch_sampler { - sdl.BindGPUFragmentSamplers( - render_pass, - 0, - &sdl.GPUTextureSamplerBinding{texture = batch_texture, sampler = batch_sampler}, - 1, - ) - current_atlas = batch_texture - current_sampler = batch_sampler - } - sdl.DrawGPUPrimitives(render_pass, 6, batch.count, 0, batch.offset) - - case .Backdrop: - // Always a no-op here. Backdrop sub-batches are dispatched by run_backdrop_bracket; - // when this proc encounters one (only possible in Pass B, since Pass A and the no- - // backdrop fast path both stop their range before any .Backdrop index), we skip it. - } - } - } - - sdl.EndGPURenderPass(render_pass) -} - -destroy_pipeline_2d_base :: proc(device: ^sdl.GPUDevice, pipeline: ^Pipeline_2D_Base) { - destroy_buffer(device, &pipeline.vertex_buffer) - destroy_buffer(device, &pipeline.index_buffer) - destroy_buffer(device, &pipeline.primitive_buffer) - if pipeline.unit_quad_buffer != nil { - sdl.ReleaseGPUBuffer(device, pipeline.unit_quad_buffer) - } - sdl.ReleaseGPUTexture(device, pipeline.white_texture) - sdl.ReleaseGPUSampler(device, pipeline.sampler) - sdl.ReleaseGPUGraphicsPipeline(device, pipeline.sdl_pipeline) -} diff --git a/draw/shaders/generated/backdrop_blur.vert.metal b/draw/shaders/generated/backdrop_blur.vert.metal index f633ac5..4bce4ce 100644 --- a/draw/shaders/generated/backdrop_blur.vert.metal +++ b/draw/shaders/generated/backdrop_blur.vert.metal @@ -52,7 +52,7 @@ struct Uniforms float2 _pad0; }; -struct Backdrop_Primitive +struct Gaussian_Blur_Primitive { float4 bounds; float4 radii; @@ -61,7 +61,7 @@ struct Backdrop_Primitive uint color; }; -struct Backdrop_Primitive_1 +struct Gaussian_Blur_Primitive_1 { float4 bounds; float4 radii; @@ -70,9 +70,9 @@ struct Backdrop_Primitive_1 uint color; }; -struct Backdrop_Primitives +struct Gaussian_Blur_Primitives { - Backdrop_Primitive_1 primitives[1]; + Gaussian_Blur_Primitive_1 primitives[1]; }; constant spvUnsafeArray _97 = spvUnsafeArray({ float2(0.0), float2(1.0, 0.0), float2(0.0, 1.0), float2(0.0, 1.0), float2(1.0, 0.0), float2(1.0) }); @@ -87,7 +87,7 @@ struct main0_out float4 gl_Position [[position]]; }; -vertex main0_out main0(constant Uniforms& _13 [[buffer(0)]], const device Backdrop_Primitives& _69 [[buffer(1)]], uint gl_VertexIndex [[vertex_id]], uint gl_InstanceIndex [[instance_id]]) +vertex main0_out main0(constant Uniforms& _13 [[buffer(0)]], const device Gaussian_Blur_Primitives& _69 [[buffer(1)]], uint gl_VertexIndex [[vertex_id]], uint gl_InstanceIndex [[instance_id]]) { main0_out out = {}; if (_13.mode == 0u) @@ -102,7 +102,7 @@ vertex main0_out main0(constant Uniforms& _13 [[buffer(0)]], const device Backdr } else { - Backdrop_Primitive p; + Gaussian_Blur_Primitive p; p.bounds = _69.primitives[int(gl_InstanceIndex)].bounds; p.radii = _69.primitives[int(gl_InstanceIndex)].radii; p.half_size = _69.primitives[int(gl_InstanceIndex)].half_size; diff --git a/draw/shaders/generated/backdrop_blur.vert.spv b/draw/shaders/generated/backdrop_blur.vert.spv index 8e2eb1f..65522fb 100644 Binary files a/draw/shaders/generated/backdrop_blur.vert.spv and b/draw/shaders/generated/backdrop_blur.vert.spv differ diff --git a/draw/shaders/generated/base_2d.vert.metal b/draw/shaders/generated/base_2d.vert.metal index a9325b7..0f7c83b 100644 --- a/draw/shaders/generated/base_2d.vert.metal +++ b/draw/shaders/generated/base_2d.vert.metal @@ -10,7 +10,7 @@ struct Uniforms uint mode; }; -struct Base_2D_Primitive +struct Core_2D_Primitive { float4 bounds; uint color; @@ -23,7 +23,7 @@ struct Base_2D_Primitive uint4 effects; }; -struct Base_2D_Primitive_1 +struct Core_2D_Primitive_1 { float4 bounds; uint color; @@ -36,9 +36,9 @@ struct Base_2D_Primitive_1 uint4 effects; }; -struct Base_2D_Primitives +struct Core_2D_Primitives { - Base_2D_Primitive_1 primitives[1]; + Core_2D_Primitive_1 primitives[1]; }; struct main0_out @@ -60,7 +60,7 @@ struct main0_in float4 v_color [[attribute(2)]]; }; -vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer(0)]], const device Base_2D_Primitives& _75 [[buffer(1)]], uint gl_InstanceIndex [[instance_id]]) +vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer(0)]], const device Core_2D_Primitives& _75 [[buffer(1)]], uint gl_InstanceIndex [[instance_id]]) { main0_out out = {}; if (_12.mode == 0u) @@ -76,7 +76,7 @@ vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer } else { - Base_2D_Primitive p; + Core_2D_Primitive p; p.bounds = _75.primitives[int(gl_InstanceIndex)].bounds; p.color = _75.primitives[int(gl_InstanceIndex)].color; p.flags = _75.primitives[int(gl_InstanceIndex)].flags; diff --git a/draw/shaders/generated/base_2d.vert.spv b/draw/shaders/generated/base_2d.vert.spv index 01d5077..2d18546 100644 Binary files a/draw/shaders/generated/base_2d.vert.spv and b/draw/shaders/generated/base_2d.vert.spv differ diff --git a/draw/shaders/source/backdrop_blur.vert b/draw/shaders/source/backdrop_blur.vert index 6b2e313..01d3c65 100644 --- a/draw/shaders/source/backdrop_blur.vert +++ b/draw/shaders/source/backdrop_blur.vert @@ -3,7 +3,7 @@ // Unified backdrop blur vertex shader. // Handles both the 1D separable blur passes (fullscreen triangle, mode 0; used for // BOTH the H-pass and V-pass) and the composite pass (instanced unit-quad over -// Backdrop_Primitive storage buffer, mode 1) for the second PSO of the backdrop bracket. +// Gaussian_Blur_Primitive storage buffer, mode 1) for the second PSO of the backdrop bracket. // The first PSO (downsample) uses backdrop_fullscreen.vert. // // No vertex buffer for either mode. Mode 0 uses gl_VertexIndex 0..2 for a single @@ -33,7 +33,7 @@ layout(location = 4) flat out float f_half_feather; // --- Uniforms (set 1) --- // Backdrop pipeline's own uniform block — distinct from the main pipeline's -// Vertex_Uniforms. `mode` selects between H-blur (0) and V-composite (1). +// Vertex_Uniforms_2D. `mode` selects between H-blur (0) and V-composite (1). layout(set = 1, binding = 0) uniform Uniforms { mat4 projection; float dpi_scale; @@ -41,18 +41,18 @@ layout(set = 1, binding = 0) uniform Uniforms { vec2 _pad0; }; -// --- Backdrop primitive storage buffer (set 0) --- +// --- Gaussian blur primitive storage buffer (set 0) --- // 48 bytes, std430-natural layout (no implicit padding). vec4 members are // front-loaded so their 16-byte alignment is satisfied without holes; the // vec2 and scalar tail packs tight to land the struct at a clean 48-byte // stride (a multiple of 16, so the array stride needs no rounding either). -// Field semantics match the CPU-side Backdrop_Primitive declared in +// Field semantics match the CPU-side Gaussian_Blur_Primitive declared in // levlib/draw/backdrop.odin; keep both in sync. // -// Backdrop primitives are tint-only: outline is intentionally absent. Specialized +// Gaussian blur primitives are tint-only: outline is intentionally absent. Specialized // edge effects (e.g. liquid-glass-style refraction outlines) would be a dedicated // primitive type with its own pipeline rather than a flag bit here. -struct Backdrop_Primitive { +struct Gaussian_Blur_Primitive { vec4 bounds; // 0-15: min_xy, max_xy (world-space) vec4 radii; // 16-31: per-corner radii (physical px) vec2 half_size; // 32-39: RRect half extents (physical px) @@ -60,8 +60,8 @@ struct Backdrop_Primitive { uint color; // 44-47: tint, packed RGBA u8x4 }; -layout(std430, set = 0, binding = 0) readonly buffer Backdrop_Primitives { - Backdrop_Primitive primitives[]; +layout(std430, set = 0, binding = 0) readonly buffer Gaussian_Blur_Primitives { + Gaussian_Blur_Primitive primitives[]; }; void main() { @@ -82,8 +82,8 @@ void main() { f_radii = vec4(0.0); f_half_feather = 0.0; } else { - // ---- Mode 1: V-composite instanced unit-quad over Backdrop_Primitive ---- - Backdrop_Primitive p = primitives[gl_InstanceIndex]; + // ---- Mode 1: V-composite instanced unit-quad over Gaussian_Blur_Primitive ---- + Gaussian_Blur_Primitive p = primitives[gl_InstanceIndex]; // Unit-quad corners for TRIANGLELIST (2 triangles, 6 vertices): // index 0 -> (0,0) index 3 -> (0,1) diff --git a/draw/shaders/source/backdrop_downsample.frag b/draw/shaders/source/backdrop_downsample.frag index b933991..f9b9b65 100644 --- a/draw/shaders/source/backdrop_downsample.frag +++ b/draw/shaders/source/backdrop_downsample.frag @@ -26,8 +26,8 @@ // working-texture coords (work_region_phys / factor), clamped to the texture bounds. layout(set = 3, binding = 0) uniform Uniforms { - vec2 inv_source_size; // 1.0 / source_texture pixel dimensions - uint downsample_factor; // 1, 2, 4, 8, or 16 + vec2 inv_source_size; // 1.0 / source_texture pixel dimensions + uint downsample_factor; // 1, 2, 4, 8, or 16 uint _pad0; }; @@ -55,13 +55,13 @@ void main() { // bilinear level), giving a 4-tap = 16-source-pixel uniform sample of the block. float off = float(downsample_factor) * 0.25; vec2 uv_tl = (src_block_center + vec2(-off, -off)) * inv_source_size; - vec2 uv_tr = (src_block_center + vec2( off, -off)) * inv_source_size; - vec2 uv_bl = (src_block_center + vec2(-off, off)) * inv_source_size; - vec2 uv_br = (src_block_center + vec2( off, off)) * inv_source_size; + vec2 uv_tr = (src_block_center + vec2(off, -off)) * inv_source_size; + vec2 uv_bl = (src_block_center + vec2(-off, off)) * inv_source_size; + vec2 uv_br = (src_block_center + vec2(off, off)) * inv_source_size; vec4 c = texture(source_tex, uv_tl) - + texture(source_tex, uv_tr) - + texture(source_tex, uv_bl) - + texture(source_tex, uv_br); + + texture(source_tex, uv_tr) + + texture(source_tex, uv_bl) + + texture(source_tex, uv_br); out_color = c * 0.25; } } diff --git a/draw/shaders/source/base_2d.vert b/draw/shaders/source/base_2d.vert index c903d1c..e259374 100644 --- a/draw/shaders/source/base_2d.vert +++ b/draw/shaders/source/base_2d.vert @@ -23,10 +23,10 @@ layout(set = 1, binding = 0) uniform Uniforms { }; // ---------- SDF primitive storage buffer ---------- -// Mirrors the CPU-side Base_2D_Primitive in pipeline_2d_base.odin. Named with the -// pipeline prefix so a project-wide grep on the type name matches both the GLSL +// Mirrors the CPU-side Core_2D_Primitive in core_2d.odin. Named with the +// subsystem prefix so a project-wide grep on the type name matches both the GLSL // declaration and the Odin declaration. -struct Base_2D_Primitive { +struct Core_2D_Primitive { vec4 bounds; // 0-15 uint color; // 16-19 uint flags; // 20-23 @@ -38,8 +38,8 @@ struct Base_2D_Primitive { uvec4 effects; // 80-95: gradient/outline parameters (read when .Gradient/.Outline) }; -layout(std430, set = 0, binding = 0) readonly buffer Base_2D_Primitives { - Base_2D_Primitive primitives[]; +layout(std430, set = 0, binding = 0) readonly buffer Core_2D_Primitives { + Core_2D_Primitive primitives[]; }; // ---------- Entry point ---------- @@ -57,7 +57,7 @@ void main() { gl_Position = projection * vec4(v_position * dpi_scale, 0.0, 1.0); } else { // ---- Mode 1: SDF instanced quads ---- - Base_2D_Primitive p = primitives[gl_InstanceIndex]; + Core_2D_Primitive p = primitives[gl_InstanceIndex]; vec2 corner = v_position; // unit quad corners: (0,0)-(1,1) vec2 world_pos = mix(p.bounds.xy, p.bounds.zw, corner); diff --git a/draw/shapes.odin b/draw/shapes.odin deleted file mode 100644 index bd12413..0000000 --- a/draw/shapes.odin +++ /dev/null @@ -1,765 +0,0 @@ -package draw - -import "core:math" - -// ----- Internal helpers ---- - -// Internal -extrude_line :: proc( - start, end_pos: Vec2, - thickness: f32, - color: Color, - vertices: []Vertex, - offset: int, -) -> int { - direction := end_pos - start - delta_x := direction[0] - delta_y := direction[1] - length := math.sqrt(delta_x * delta_x + delta_y * delta_y) - if length < 0.0001 do return 0 - - scale := thickness / (2 * length) - perpendicular := Vec2{-delta_y * scale, delta_x * scale} - - p0 := start + perpendicular - p1 := start - perpendicular - p2 := end_pos - perpendicular - p3 := end_pos + perpendicular - - vertices[offset + 0] = solid_vertex(p0, color) - vertices[offset + 1] = solid_vertex(p1, color) - vertices[offset + 2] = solid_vertex(p2, color) - vertices[offset + 3] = solid_vertex(p0, color) - vertices[offset + 4] = solid_vertex(p2, color) - vertices[offset + 5] = solid_vertex(p3, color) - - return 6 -} - -// Create a vertex for solid-color shape drawing (no texture, UV defaults to zero). -// Color is premultiplied: the tessellated fragment shader passes it through directly -// and the blend state is ONE, ONE_MINUS_SRC_ALPHA. -solid_vertex :: proc(position: Vec2, color: Color) -> Vertex { - return Vertex{position = position, color = premultiply_color(color)} -} - -emit_rectangle :: proc(x, y, width, height: f32, color: Color, vertices: []Vertex, offset: int) { - vertices[offset + 0] = solid_vertex({x, y}, color) - vertices[offset + 1] = solid_vertex({x + width, y}, color) - vertices[offset + 2] = solid_vertex({x + width, y + height}, color) - vertices[offset + 3] = solid_vertex({x, y}, color) - vertices[offset + 4] = solid_vertex({x + width, y + height}, color) - vertices[offset + 5] = solid_vertex({x, y + height}, color) -} - -// Internal — submit an SDF primitive with optional texture binding. -// The texture-aware counterpart of `draw.prepare_sdf_primitive`; lets shape procs route a -// texture_id and sampler into the sub-batch without growing the public API. -@(private) -prepare_sdf_primitive_ex :: proc( - layer: ^Layer, - prim: Base_2D_Primitive, - texture_id: Texture_Id = INVALID_TEXTURE, - sampler: Sampler_Preset = DFT_SAMPLER, -) { - offset := u32(len(GLOB.tmp_primitives)) - append(&GLOB.tmp_primitives, prim) - scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] - append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1, texture_id, sampler) -} - -// Internal — resolve Texture_Fill zero-initialized fields to their defaults. -// Odin structs zero-initialize; Color{} and Rectangle{} are all-zero which is not a -// useful tint or UV rect. This proc substitutes sensible defaults for zero values. -@(private) -resolve_texture_defaults :: #force_inline proc( - tf: Texture_Fill, -) -> ( - tint: Color, - uv: Rectangle, - sampler: Sampler_Preset, -) { - tint = tf.tint == Color{} ? DFT_TINT : tf.tint - uv = tf.uv_rect == Rectangle{} ? DFT_UV_RECT : tf.uv_rect - sampler = tf.sampler - return -} - -//Internal -// -// Compute the visual center of a center-parametrized shape after applying -// Convention B origin semantics: `center` is where the origin-point lands in -// world space; the visual center is offset by -origin and then rotated around -// the landing point. -// visual_center = center + R(θ) · (-origin) -// When θ=0: visual_center = center - origin (pure positioning shift). -// When origin={0,0}: visual_center = center (no change). -compute_pivot_center :: proc(center: Vec2, origin: Vec2, sin_angle, cos_angle: f32) -> Vec2 { - if origin == {0, 0} do return center - return( - center + - {cos_angle * (-origin.x) - sin_angle * (-origin.y), sin_angle * (-origin.x) + cos_angle * (-origin.y)} \ - ) -} - -// Compute the AABB half-extents of a rectangle with half-size (half_width, half_height) rotated by the given cos/sin. -rotated_aabb_half_extents :: proc(half_width, half_height, cos_angle, sin_angle: f32) -> [2]f32 { - cos_abs := abs(cos_angle) - sin_abs := abs(sin_angle) - return {half_width * cos_abs + half_height * sin_abs, half_width * sin_abs + half_height * cos_abs} -} - -// Pack sin/cos into the Base_2D_Primitive.rotation_sc field as two f16 values. -pack_rotation_sc :: #force_inline proc(sin_angle, cos_angle: f32) -> u32 { - return pack_f16_pair(f16(sin_angle), f16(cos_angle)) -} - - -// Internal -// -// Build an RRect Base_2D_Primitive with bounds, params, and rotation computed from rectangle geometry. -// The caller sets color, flags, and uv fields on the returned primitive before submitting. -build_rrect_primitive :: proc( - rect: Rectangle, - radii: Rectangle_Radii, - origin: Vec2, - rotation: f32, - feather_px: f32, -) -> Base_2D_Primitive { - max_radius := min(rect.width, rect.height) * 0.5 - clamped_top_left := clamp(radii.top_left, 0, max_radius) - clamped_top_right := clamp(radii.top_right, 0, max_radius) - clamped_bottom_right := clamp(radii.bottom_right, 0, max_radius) - clamped_bottom_left := clamp(radii.bottom_left, 0, max_radius) - - half_feather := feather_px * 0.5 - padding := half_feather / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - half_width := rect.width * 0.5 - half_height := rect.height * 0.5 - center_x := rect.x + half_width - origin.x - center_y := rect.y + half_height - origin.y - sin_angle: f32 = 0 - cos_angle: f32 = 1 - has_rotation := false - - if needs_transform(origin, rotation) { - rotation_radians := math.to_radians(rotation) - sin_angle, cos_angle = math.sincos(rotation_radians) - has_rotation = rotation != 0 - transform := build_pivot_rotation_sc({rect.x + origin.x, rect.y + origin.y}, origin, cos_angle, sin_angle) - new_center := apply_transform(transform, {half_width, half_height}) - center_x = new_center.x - center_y = new_center.y - } - - bounds_half_width, bounds_half_height := half_width, half_height - if has_rotation { - expanded := rotated_aabb_half_extents(half_width, half_height, cos_angle, sin_angle) - bounds_half_width = expanded.x - bounds_half_height = expanded.y - } - - prim := Base_2D_Primitive { - bounds = { - center_x - bounds_half_width - padding, - center_y - bounds_half_height - padding, - center_x + bounds_half_width + padding, - center_y + bounds_half_height + padding, - }, - rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0, - } - prim.params.rrect = RRect_Params { - half_size = {half_width * dpi_scale, half_height * dpi_scale}, - radii = { - clamped_bottom_right * dpi_scale, - clamped_top_right * dpi_scale, - clamped_bottom_left * dpi_scale, - clamped_top_left * dpi_scale, - }, - half_feather = half_feather, - } - return prim -} - -// Internal -// -// Build an RRect Base_2D_Primitive for a circle (fully-rounded square RRect). -// The caller sets color, flags, and uv fields on the returned primitive before submitting. -build_circle_primitive :: proc( - center: Vec2, - radius: f32, - origin: Vec2, - rotation: f32, - feather_px: f32, -) -> Base_2D_Primitive { - half_feather := feather_px * 0.5 - padding := half_feather / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - if origin != {0, 0} { - sin_a, cos_a := math.sincos(math.to_radians(rotation)) - actual_center = compute_pivot_center(center, origin, sin_a, cos_a) - } - - prim := Base_2D_Primitive { - bounds = { - actual_center.x - radius - padding, - actual_center.y - radius - padding, - actual_center.x + radius + padding, - actual_center.y + radius + padding, - }, - } - scaled_radius := radius * dpi_scale - prim.params.rrect = RRect_Params { - half_size = {scaled_radius, scaled_radius}, - radii = {scaled_radius, scaled_radius, scaled_radius, scaled_radius}, - half_feather = half_feather, - } - return prim -} - -// Internal -// -// Build an Ellipse Base_2D_Primitive with bounds, params, and rotation computed from ellipse geometry. -// The caller sets color, flags, and uv fields on the returned primitive before submitting. -build_ellipse_primitive :: proc( - center: Vec2, - radius_horizontal, radius_vertical: f32, - origin: Vec2, - rotation: f32, - feather_px: f32, -) -> Base_2D_Primitive { - half_feather := feather_px * 0.5 - padding := half_feather / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - sin_angle: f32 = 0 - cos_angle: f32 = 1 - has_rotation := false - - if needs_transform(origin, rotation) { - rotation_radians := math.to_radians(rotation) - sin_angle, cos_angle = math.sincos(rotation_radians) - actual_center = compute_pivot_center(center, origin, sin_angle, cos_angle) - has_rotation = rotation != 0 - } - - bound_horizontal, bound_vertical := radius_horizontal, radius_vertical - if has_rotation { - expanded := rotated_aabb_half_extents(radius_horizontal, radius_vertical, cos_angle, sin_angle) - bound_horizontal = expanded.x - bound_vertical = expanded.y - } - - prim := Base_2D_Primitive { - bounds = { - actual_center.x - bound_horizontal - padding, - actual_center.y - bound_vertical - padding, - actual_center.x + bound_horizontal + padding, - actual_center.y + bound_vertical + padding, - }, - rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0, - } - prim.params.ellipse = Ellipse_Params { - radii = {radius_horizontal * dpi_scale, radius_vertical * dpi_scale}, - half_feather = half_feather, - } - return prim -} - -// Internal -// -// Build an NGon Base_2D_Primitive with bounds, params, and rotation computed from polygon geometry. -// The caller sets color, flags, and uv fields on the returned primitive before submitting. -build_polygon_primitive :: proc( - center: Vec2, - sides: int, - radius: f32, - origin: Vec2, - rotation: f32, - feather_px: f32, -) -> Base_2D_Primitive { - half_feather := feather_px * 0.5 - padding := half_feather / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - if origin != {0, 0} && rotation != 0 { - sin_a, cos_a := math.sincos(math.to_radians(rotation)) - actual_center = compute_pivot_center(center, origin, sin_a, cos_a) - } - - rotation_radians := math.to_radians(rotation) - sin_rot, cos_rot := math.sincos(rotation_radians) - - prim := Base_2D_Primitive { - bounds = { - actual_center.x - radius - padding, - actual_center.y - radius - padding, - actual_center.x + radius + padding, - actual_center.y + radius + padding, - }, - rotation_sc = rotation != 0 ? pack_rotation_sc(sin_rot, cos_rot) : 0, - } - prim.params.ngon = NGon_Params { - radius = radius * math.cos(math.PI / f32(sides)) * dpi_scale, - sides = f32(sides), - half_feather = half_feather, - } - return prim -} - -// Internal -// -// Build a Ring_Arc Base_2D_Primitive with bounds and params computed from ring/arc geometry. -// Pre-computes the angular boundary normals on the CPU so the fragment shader needs -// no per-pixel sin/cos. The radial SDF uses max(inner-r, r-outer) which correctly -// handles pie slices (inner_radius = 0) and full rings. -// The caller sets color, flags, and uv fields on the returned primitive before submitting. -build_ring_arc_primitive :: proc( - center: Vec2, - inner_radius, outer_radius: f32, - start_angle: f32, - end_angle: f32, - origin: Vec2, - rotation: f32, - feather_px: f32, -) -> ( - Base_2D_Primitive, - Shape_Flags, -) { - half_feather := feather_px * 0.5 - padding := half_feather / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - rotation_offset: f32 = 0 - if needs_transform(origin, rotation) { - sin_a, cos_a := math.sincos(math.to_radians(rotation)) - actual_center = compute_pivot_center(center, origin, sin_a, cos_a) - rotation_offset = math.to_radians(rotation) - } - - start_rad := math.to_radians(start_angle) + rotation_offset - end_rad := math.to_radians(end_angle) + rotation_offset - - // Normalize arc span to [0, 2π] - arc_span := end_rad - start_rad - if arc_span < 0 { - arc_span += 2 * math.PI - } - - // Pre-compute edge normals and arc flags on CPU — no per-pixel trig needed. - // arc_flags: {} = full ring, {.Arc_Narrow} = span ≤ π (intersect), {.Arc_Wide} = span > π (union) - arc_flags: Shape_Flags = {} - normal_start: [2]f32 = {} - normal_end: [2]f32 = {} - - if arc_span < 2 * math.PI - 0.001 { - sin_start, cos_start := math.sincos(start_rad) - sin_end, cos_end := math.sincos(end_rad) - normal_start = {sin_start, -cos_start} - normal_end = {-sin_end, cos_end} - arc_flags = arc_span <= math.PI ? {.Arc_Narrow} : {.Arc_Wide} - } - - prim := Base_2D_Primitive { - bounds = { - actual_center.x - outer_radius - padding, - actual_center.y - outer_radius - padding, - actual_center.x + outer_radius + padding, - actual_center.y + outer_radius + padding, - }, - } - prim.params.ring_arc = Ring_Arc_Params { - inner_radius = inner_radius * dpi_scale, - outer_radius = outer_radius * dpi_scale, - normal_start = normal_start, - normal_end = normal_end, - half_feather = half_feather, - } - return prim, arc_flags -} - -// Apply brush fill and outline to a primitive, then submit it. -// Dispatches to the correct sub-batch based on the Brush variant. -// All parameters (outline_width) are in logical pixels, matching the rest of the public API. -// The helper converts to physical pixels for GPU packing internally. -@(private) -apply_brush_and_outline :: proc( - layer: ^Layer, - prim: ^Base_2D_Primitive, - kind: Shape_Kind, - brush: Brush, - outline_color: Color, - outline_width: f32, - extra_flags: Shape_Flags = {}, -) { - flags: Shape_Flags = extra_flags - - // Fill — determined by the Brush variant. - texture_id := INVALID_TEXTURE - sampler := DFT_SAMPLER - - switch b in brush { - case Color: prim.color = b - case Linear_Gradient: - flags += {.Gradient} - prim.color = b.start_color - prim.effects.gradient_color = b.end_color - rad := math.to_radians(b.angle) - sin_a, cos_a := math.sincos(rad) - prim.effects.gradient_dir_sc = pack_f16_pair(f16(cos_a), f16(sin_a)) - case Radial_Gradient: - flags += {.Gradient, .Gradient_Radial} - prim.color = b.inner_color - prim.effects.gradient_color = b.outer_color - case Texture_Fill: - flags += {.Textured} - tint, uv, sam := resolve_texture_defaults(b) - prim.color = tint - prim.uv_rect = {uv.x, uv.y, uv.width, uv.height} - texture_id = b.id - sampler = sam - } - - // Outline — orthogonal to all Brush variants. - if outline_width > 0 { - flags += {.Outline} - prim.effects.outline_color = outline_color - prim.effects.outline_packed = pack_f16_pair(f16(outline_width * GLOB.dpi_scaling), 0) - // Expand bounds to contain the outline (bounds are in logical pixels) - prim.bounds[0] -= outline_width - prim.bounds[1] -= outline_width - prim.bounds[2] += outline_width - prim.bounds[3] += outline_width - } - - // Set .Rotated flag if rotation_sc was populated by the build proc - if prim.rotation_sc != 0 { - flags += {.Rotated} - } - - prim.flags = pack_kind_flags(kind, flags) - prepare_sdf_primitive_ex(layer, prim^, texture_id, sampler) -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- SDF Rectangle procs ----------- -// --------------------------------------------------------------------------------------------------------------------- - -// Draw a filled rectangle via SDF with optional per-corner rounding radii. -// Use `uniform_radii(rect, roundness)` to compute uniform radii from a 0–1 fraction. -// -// Origin semantics: -// `origin` is a local offset from the rect's top-left corner that selects both the positioning -// anchor and the rotation pivot. `rect.x, rect.y` specifies where that anchor point lands in -// world space. When `origin = {0, 0}` (default), `rect.x, rect.y` is the top-left corner. -// Rotation always occurs around the anchor point. -rectangle :: proc( - layer: ^Layer, - rect: Rectangle, - brush: Brush, - outline_color: Color = {}, - outline_width: f32 = 0, - radii: Rectangle_Radii = {}, - origin: Vec2 = {}, - rotation: f32 = 0, - feather_px: f32 = DFT_FEATHER_PX, -) { - prim := build_rrect_primitive(rect, radii, origin, rotation, feather_px) - apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width) -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- SDF Circle procs (emit RRect primitives) ------ -// --------------------------------------------------------------------------------------------------------------------- - -// Draw a filled circle via SDF (emitted as a fully-rounded RRect). -// -// Origin semantics (Convention B): -// `origin` is a local offset from the shape's center that selects both the positioning anchor -// and the rotation pivot. The `center` parameter specifies where that anchor point lands in -// world space. When `origin = {0, 0}` (default), `center` is the visual center. -// When `origin = {r, 0}`, the point `r` pixels to the right of the shape center lands at -// `center`, shifting the shape left by `r`. -circle :: proc( - layer: ^Layer, - center: Vec2, - radius: f32, - brush: Brush, - outline_color: Color = {}, - outline_width: f32 = 0, - origin: Vec2 = {}, - rotation: f32 = 0, - feather_px: f32 = DFT_FEATHER_PX, -) { - prim := build_circle_primitive(center, radius, origin, rotation, feather_px) - apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width) -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- SDF Ellipse procs (emit Ellipse primitives) --- -// --------------------------------------------------------------------------------------------------------------------- - -// Draw a filled ellipse via SDF. -// Origin semantics: see `circle`. -ellipse :: proc( - layer: ^Layer, - center: Vec2, - radius_horizontal, radius_vertical: f32, - brush: Brush, - outline_color: Color = {}, - outline_width: f32 = 0, - origin: Vec2 = {}, - rotation: f32 = 0, - feather_px: f32 = DFT_FEATHER_PX, -) { - prim := build_ellipse_primitive(center, radius_horizontal, radius_vertical, origin, rotation, feather_px) - apply_brush_and_outline(layer, &prim, .Ellipse, brush, outline_color, outline_width) -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- SDF Polygon procs (emit NGon primitives) ------ -// --------------------------------------------------------------------------------------------------------------------- - -// Draw a filled regular polygon via SDF. -// `sides` must be >= 3. The polygon is inscribed in a circle of the given `radius`. -// Origin semantics: see `circle`. -polygon :: proc( - layer: ^Layer, - center: Vec2, - sides: int, - radius: f32, - brush: Brush, - outline_color: Color = {}, - outline_width: f32 = 0, - origin: Vec2 = {}, - rotation: f32 = 0, - feather_px: f32 = DFT_FEATHER_PX, -) { - if sides < 3 do return - - prim := build_polygon_primitive(center, sides, radius, origin, rotation, feather_px) - apply_brush_and_outline(layer, &prim, .NGon, brush, outline_color, outline_width) -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- SDF Ring / Arc procs (emit Ring_Arc primitives) ---- -// --------------------------------------------------------------------------------------------------------------------- - -// Draw a ring, arc, or pie slice via SDF. -// Full ring by default. Pass start_angle/end_angle (degrees) for partial arcs. -// Use inner_radius = 0 for pie slices (sectors). -// Origin semantics: see `circle`. -ring :: proc( - layer: ^Layer, - center: Vec2, - inner_radius, outer_radius: f32, - brush: Brush, - outline_color: Color = {}, - outline_width: f32 = 0, - start_angle: f32 = 0, - end_angle: f32 = DFT_CIRC_END_ANGLE, - origin: Vec2 = {}, - rotation: f32 = 0, - feather_px: f32 = DFT_FEATHER_PX, -) { - prim, arc_flags := build_ring_arc_primitive( - center, - inner_radius, - outer_radius, - start_angle, - end_angle, - origin, - rotation, - feather_px, - ) - apply_brush_and_outline(layer, &prim, .Ring_Arc, brush, outline_color, outline_width, arc_flags) -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- SDF Line procs (emit rotated RRect primitives) ---- -// --------------------------------------------------------------------------------------------------------------------- - -// Draw a line segment via SDF (emitted as a rotated capsule-shaped RRect). -// Round caps are produced by setting corner radii equal to half the thickness. -line :: proc( - layer: ^Layer, - start_position, end_position: Vec2, - brush: Brush, - thickness: f32 = DFT_STROKE_THICKNESS, - outline_color: Color = {}, - outline_width: f32 = 0, - feather_px: f32 = DFT_FEATHER_PX, -) { - delta_x := end_position.x - start_position.x - delta_y := end_position.y - start_position.y - seg_length := math.sqrt(delta_x * delta_x + delta_y * delta_y) - if seg_length < 0.0001 do return - rotation_radians := math.atan2(delta_y, delta_x) - sin_angle, cos_angle := math.sincos(rotation_radians) - - center_x := (start_position.x + end_position.x) * 0.5 - center_y := (start_position.y + end_position.y) * 0.5 - - half_length := seg_length * 0.5 - half_thickness := thickness * 0.5 - cap_radius := half_thickness - - half_feather := feather_px * 0.5 - padding := half_feather / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - // Expand bounds for rotation - bounds_half := rotated_aabb_half_extents(half_length + cap_radius, half_thickness, cos_angle, sin_angle) - - prim := Base_2D_Primitive { - bounds = { - center_x - bounds_half.x - padding, - center_y - bounds_half.y - padding, - center_x + bounds_half.x + padding, - center_y + bounds_half.y + padding, - }, - rotation_sc = pack_rotation_sc(sin_angle, cos_angle), - } - prim.params.rrect = RRect_Params { - half_size = {(half_length + cap_radius) * dpi_scale, half_thickness * dpi_scale}, - radii = { - cap_radius * dpi_scale, - cap_radius * dpi_scale, - cap_radius * dpi_scale, - cap_radius * dpi_scale, - }, - half_feather = half_feather, - } - apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width) -} - -// Draw a line strip via decomposed SDF line segments. -line_strip :: proc( - layer: ^Layer, - points: []Vec2, - brush: Brush, - thickness: f32 = DFT_STROKE_THICKNESS, - outline_color: Color = {}, - outline_width: f32 = 0, - feather_px: f32 = DFT_FEATHER_PX, -) { - if len(points) < 2 do return - for i in 0 ..< len(points) - 1 { - line(layer, points[i], points[i + 1], brush, thickness, outline_color, outline_width, feather_px) - } -} - - -// --------------------------------------------------------------------------------------------------------------------- -// ----- Helpers ---------------- -// --------------------------------------------------------------------------------------------------------------------- - -// 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} -} - -// 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). - -// ----- 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 -} diff --git a/draw/tess/tess.odin b/draw/tess/tess.odin index 55caad6..10ec7f5 100644 --- a/draw/tess/tess.odin +++ b/draw/tess/tess.odin @@ -4,6 +4,7 @@ import "core:math" import draw ".." +//INTERNAL SMOOTH_CIRCLE_ERROR_RATE :: 0.1 auto_segments :: proc(radius: f32, arc_degrees: f32) -> int { @@ -22,11 +23,18 @@ auto_segments :: proc(radius: f32, arc_degrees: f32) -> int { // Color is premultiplied: the tessellated fragment shader passes it through directly // and the blend state is ONE, ONE_MINUS_SRC_ALPHA. -solid_vertex :: proc(position: draw.Vec2, color: draw.Color) -> draw.Vertex { - return draw.Vertex{position = position, color = draw.premultiply_color(color)} +//INTERNAL +solid_vertex :: proc(position: draw.Vec2, color: draw.Color) -> draw.Vertex_2D { + return draw.Vertex_2D{position = position, color = draw.premultiply_color(color)} } -emit_rectangle :: proc(x, y, width, height: f32, color: draw.Color, vertices: []draw.Vertex, offset: int) { +//INTERNAL +emit_rectangle :: proc( + x, y, width, height: f32, + color: draw.Color, + vertices: []draw.Vertex_2D, + offset: int, +) { vertices[offset + 0] = solid_vertex({x, y}, color) vertices[offset + 1] = solid_vertex({x + width, y}, color) vertices[offset + 2] = solid_vertex({x + width, y + height}, color) @@ -35,11 +43,12 @@ emit_rectangle :: proc(x, y, width, height: f32, color: draw.Color, vertices: [] vertices[offset + 5] = solid_vertex({x, y + height}, color) } +//INTERNAL extrude_line :: proc( start, end_pos: draw.Vec2, thickness: f32, color: draw.Color, - vertices: []draw.Vertex, + vertices: []draw.Vertex_2D, offset: int, ) -> int { direction := end_pos - start @@ -69,7 +78,7 @@ extrude_line :: proc( // ----- Public draw ----- pixel :: proc(layer: ^draw.Layer, pos: draw.Vec2, color: draw.Color) { - vertices: [6]draw.Vertex + vertices: [6]draw.Vertex_2D emit_rectangle(pos[0], pos[1], 1, 1, color, vertices[:], 0) draw.prepare_shape(layer, vertices[:]) } @@ -82,7 +91,7 @@ triangle :: proc( rotation: f32 = 0, ) { if !draw.needs_transform(origin, rotation) { - vertices := [3]draw.Vertex{solid_vertex(v1, color), solid_vertex(v2, color), solid_vertex(v3, color)} + vertices := [3]draw.Vertex_2D{solid_vertex(v1, color), solid_vertex(v2, color), solid_vertex(v3, color)} draw.prepare_shape(layer, vertices[:]) return } @@ -91,7 +100,7 @@ triangle :: proc( local_v1 := v1 - bounds_min local_v2 := v2 - bounds_min local_v3 := v3 - bounds_min - vertices := [3]draw.Vertex { + vertices := [3]draw.Vertex_2D { solid_vertex(draw.apply_transform(transform, local_v1), color), solid_vertex(draw.apply_transform(transform, local_v2), color), solid_vertex(draw.apply_transform(transform, local_v3), color), @@ -170,7 +179,7 @@ triangle_aa :: proc( transparent := draw.BLANK // 3 interior + 6 × 3 edge-quad = 21 vertices - vertices: [21]draw.Vertex + vertices: [21]draw.Vertex_2D // Interior triangle vertices[0] = solid_vertex(p0, color) @@ -213,7 +222,7 @@ triangle_lines :: proc( rotation: f32 = 0, temp_allocator := context.temp_allocator, ) { - vertices := make([]draw.Vertex, 18, temp_allocator) + vertices := make([]draw.Vertex_2D, 18, temp_allocator) defer delete(vertices, temp_allocator) write_offset := 0 @@ -249,7 +258,7 @@ triangle_fan :: proc( triangle_count := len(points) - 2 vertex_count := triangle_count * 3 - vertices := make([]draw.Vertex, vertex_count, temp_allocator) + vertices := make([]draw.Vertex_2D, vertex_count, temp_allocator) defer delete(vertices, temp_allocator) if !draw.needs_transform(origin, rotation) { @@ -289,7 +298,7 @@ triangle_strip :: proc( triangle_count := len(points) - 2 vertex_count := triangle_count * 3 - vertices := make([]draw.Vertex, vertex_count, temp_allocator) + vertices := make([]draw.Vertex_2D, vertex_count, temp_allocator) defer delete(vertices, temp_allocator) if !draw.needs_transform(origin, rotation) { diff --git a/draw/text.odin b/draw/text.odin index 4ebdd1f..5849f71 100644 --- a/draw/text.odin +++ b/draw/text.odin @@ -8,21 +8,25 @@ import sdl_ttf "vendor:sdl3/ttf" Font_Id :: u16 +//INTERNAL Font_Key :: struct { id: Font_Id, size: u16, } +//INTERNAL Cache_Source :: enum u8 { Custom, Clay, } +//INTERNAL Cache_Key :: struct { id: u32, source: Cache_Source, } +//INTERNAL Text_Cache :: struct { engine: ^sdl_ttf.TextEngine, font_bytes: [dynamic][]u8, @@ -30,7 +34,8 @@ Text_Cache :: struct { cache: map[Cache_Key]^sdl_ttf.Text, } -// Internal for fetching SDL TTF font pointer for rendering +// Fetch SDL TTF font pointer for rendering. +//INTERNAL get_font :: proc(id: Font_Id, size: u16) -> ^sdl_ttf.Font { assert(int(id) < len(GLOB.text_cache.font_bytes), "Invalid font ID.") key := Font_Key{id, size} @@ -77,6 +82,7 @@ register_font :: proc(bytes: []u8) -> (id: Font_Id, ok: bool) #optional_ok { return Font_Id(len(GLOB.text_cache.font_bytes) - 1), true } +//INTERNAL Text :: struct { sdl_text: ^sdl_ttf.Text, position: Vec2, @@ -89,7 +95,7 @@ Text :: struct { // Shared cache lookup/create/update logic used by both the `text` proc and the Clay render path. // Returns the cached (or newly created) TTF_Text pointer. -@(private) +//INTERNAL cache_get_or_update :: proc(key: Cache_Key, c_str: cstring, font: ^sdl_ttf.Font) -> ^sdl_ttf.Text { existing, found := GLOB.text_cache.cache[key] if !found { @@ -268,7 +274,8 @@ clear_text_cache_entry :: proc(id: u32) { // ----- Internal cache lifecycle ------ // --------------------------------------------------------------------------------------------------------------------- -@(private, require_results) +//INTERNAL +@(require_results) init_text_cache :: proc( device: ^sdl.GPUDevice, allocator := context.allocator, @@ -299,6 +306,7 @@ init_text_cache :: proc( return text_cache, true } +//INTERNAL destroy_text_cache :: proc() { for _, font in GLOB.text_cache.sdl_fonts { sdl_ttf.CloseFont(font) diff --git a/draw/textures.odin b/draw/textures.odin index adb9f88..a48ab57 100644 --- a/draw/textures.odin +++ b/draw/textures.odin @@ -41,8 +41,7 @@ Texture_Desc :: struct { kind: Texture_Kind, } -// Internal slot — not exported. -@(private) +//INTERNAL Texture_Slot :: struct { gpu_texture: ^sdl.GPUTexture, desc: Texture_Desc, @@ -319,8 +318,8 @@ texture_kind :: proc(id: Texture_Id) -> Texture_Kind { return GLOB.texture_slots[u32(id)].desc.kind } -// Internal: get the raw GPU texture pointer for binding during draw. -@(private) +// Get the raw GPU texture pointer for binding during draw. +//INTERNAL texture_gpu_handle :: proc(id: Texture_Id) -> ^sdl.GPUTexture { if id == INVALID_TEXTURE do return nil idx := u32(id) @@ -328,8 +327,8 @@ texture_gpu_handle :: proc(id: Texture_Id) -> ^sdl.GPUTexture { return GLOB.texture_slots[idx].gpu_texture } -// Deferred release (called from draw.end / clear_global) -@(private) +// Deferred release (called from end / clear_global). +//INTERNAL process_pending_texture_releases :: proc() { device := GLOB.device for id in GLOB.pending_texture_releases { @@ -346,7 +345,7 @@ process_pending_texture_releases :: proc() { clear(&GLOB.pending_texture_releases) } -@(private) +//INTERNAL get_sampler :: proc(preset: Sampler_Preset) -> ^sdl.GPUSampler { idx := int(preset) if GLOB.samplers[idx] != nil do return GLOB.samplers[idx] @@ -379,15 +378,15 @@ get_sampler :: proc(preset: Sampler_Preset) -> ^sdl.GPUSampler { ) if sampler == nil { log.errorf("Failed to create sampler preset %v: %s", preset, sdl.GetError()) - return GLOB.pipeline_2d_base.sampler // fallback to existing default sampler + return GLOB.core_2d.sampler // fallback to existing default sampler } GLOB.samplers[idx] = sampler return sampler } -// Internal: destroy all sampler pool entries. Called from draw.destroy(). -@(private) +// Destroy all sampler pool entries. Called from destroy(). +//INTERNAL destroy_sampler_pool :: proc() { device := GLOB.device for &s in GLOB.samplers { @@ -398,8 +397,8 @@ destroy_sampler_pool :: proc() { } } -// Internal: destroy all registered textures. Called from draw.destroy(). -@(private) +// Destroy all registered textures. Called from destroy(). +//INTERNAL destroy_all_textures :: proc() { device := GLOB.device for &slot in GLOB.texture_slots {