diff --git a/draw/README.md b/draw/README.md index 5eeabf2..a04d09a 100644 --- a/draw/README.md +++ b/draw/README.md @@ -9,35 +9,51 @@ The renderer uses a single unified `Pipeline_2D_Base` (`TRIANGLELIST` pipeline) modes dispatched by a push constant: - **Mode 0 (Tessellated):** Vertex buffer contains real geometry. Used for text (indexed draws into - SDL_ttf atlas textures), axis-aligned sharp-corner rectangles (already optimal as 2 triangles), - per-vertex color gradients (`rectangle_gradient`, `circle_gradient`), angular-clipped circle - sectors (`circle_sector`), and arbitrary user geometry (`triangle`, `triangle_fan`, - `triangle_strip`). The fragment shader computes `out = color * texture(tex, uv)`. + SDL_ttf atlas textures), single-pixel points (`tes_pixel`), arbitrary user geometry (`tes_triangle`, + `tes_triangle_fan`, `tes_triangle_strip`), and shapes without a closed-form rounded-rectangle + reduction: ellipses (`tes_ellipse`), regular polygons (`tes_polygon`), and circle sectors + (`tes_sector`). The fragment shader computes `out = color * texture(tex, uv)`. - **Mode 1 (SDF):** A static 6-vertex unit-quad buffer is drawn instanced, with per-primitive - `Primitive` structs 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` to evaluate the correct signed distance - function analytically. + `Primitive` structs (80 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 always evaluates `sdRoundedBox` — there is no per-primitive + kind dispatch. -Seven SDF shape kinds are implemented: +The SDF path handles all shapes that are algebraically reducible to a rounded rectangle: -1. **RRect** — rounded rectangle with per-corner radii (iq's `sdRoundedBox`) -2. **Circle** — filled or stroked circle -3. **Ellipse** — exact signed-distance ellipse (iq's iterative `sdEllipse`) -4. **Segment** — capsule-style line segment with rounded caps -5. **Ring_Arc** — annular ring with angular clipping for arcs -6. **NGon** — regular polygon with arbitrary side count and rotation -7. **Polyline** — decomposed into independent `Segment` primitives per adjacent point pair +- **Rounded rectangles** — per-corner radii via `sdRoundedBox` (iq). Covers filled, stroked, + textured, and gradient-filled rectangles. +- **Circles** — uniform radii equal to half-size. Covers filled, stroked, and radial-gradient circles. +- **Line segments / capsules** — rotated RRect with uniform radii equal to half-thickness (stadium shape). +- **Full rings / annuli** — stroked circle (mid-radius with stroke thickness = outer - inner). -All SDF shapes support fill and stroke modes via `Shape_Flags`, and produce mathematically exact -curves with analytical anti-aliasing via `smoothstep` — no tessellation, no piecewise-linear -approximation. A rounded rectangle is 1 primitive (64 bytes) instead of ~250 vertices (~5000 bytes). +All SDF shapes support fill, stroke, solid color, bilinear 4-corner gradients, radial 2-color +gradients, and texture fills via `Shape_Flags`. Gradient colors are packed into the same 16 bytes as +the texture UV rect via a `Uv_Or_Gradient` raw union — zero size increase to the 80-byte `Primitive` +struct. Gradient and texture are mutually exclusive. + +All SDF shapes produce mathematically exact curves with analytical anti-aliasing via `smoothstep` — +no tessellation, no piecewise-linear approximation. A rounded rectangle is 1 primitive (80 bytes) +instead of ~250 vertices (~5000 bytes). + +The fragment shader's estimated register footprint is ~20–23 VGPRs via static live-range analysis. +RRect and Ring_Arc are roughly tied at peak pressure — RRect carries `corner_radii` (4 regs) plus +`sdRoundedBox` temporaries, Ring_Arc carries wedge normals plus dot-product temporaries. Both land +comfortably under Mali Valhall's 32-register occupancy cliff (G57/G77/G78 and later) and well under +desktop limits. On older Bifrost Mali (G71/G72/G76, 16-register cliff) either shape kind may incur +partial occupancy reduction. These estimates are hand-counted; exact numbers require `malioc` or +Radeon GPU Analyzer against the compiled SPIR-V. MSAA is opt-in (default `._1`, no MSAA) via `Init_Options.msaa_samples`. SDF rendering does not benefit from MSAA because fragment coverage is computed analytically. MSAA remains useful for text glyph edges and tessellated user geometry if desired. +All public drawing procs use prefixed names for clarity: `sdf_*` for SDF-path shapes, `tes_*` for +tessellated-path shapes. Proc groups provide a single entry point per shape concept (e.g., +`sdf_rectangle` dispatches to `sdf_rectangle_solid` or `sdf_rectangle_gradient` based on argument +count). + ## 2D rendering pipeline plan This section documents the planned architecture for levlib's 2D rendering system. The design is driven @@ -91,19 +107,19 @@ Below the cliff, adding registers has zero occupancy cost. On consumer Ampere/Ada GPUs (RTX 30xx/40xx, 65,536 regs/SM, max 1,536 threads/SM, cliff at ~43 regs): -| Register allocation | Reg-limited threads | Actual (hw-capped) | Occupancy | -| ----------------------- | ------------------- | ------------------ | --------- | -| 20 regs (main pipeline) | 3,276 | 1,536 | 100% | -| 32 regs | 2,048 | 1,536 | 100% | -| 48 regs (effects) | 1,365 | 1,365 | ~89% | +| Register allocation | Reg-limited threads | Actual (hw-capped) | Occupancy | +| ------------------------ | ------------------- | ------------------ | --------- | +| ~16 regs (main pipeline) | 4,096 | 1,536 | 100% | +| 32 regs | 2,048 | 1,536 | 100% | +| 48 regs (effects) | 1,365 | 1,365 | ~89% | On Volta/A100 GPUs (65,536 regs/SM, max 2,048 threads/SM, cliff at ~32 regs): -| Register allocation | Reg-limited threads | Actual (hw-capped) | Occupancy | -| ----------------------- | ------------------- | ------------------ | --------- | -| 20 regs (main pipeline) | 3,276 | 2,048 | 100% | -| 32 regs | 2,048 | 2,048 | 100% | -| 48 regs (effects) | 1,365 | 1,365 | ~67% | +| Register allocation | Reg-limited threads | Actual (hw-capped) | Occupancy | +| ------------------------ | ------------------- | ------------------ | --------- | +| ~16 regs (main pipeline) | 4,096 | 2,048 | 100% | +| 32 regs | 2,048 | 2,048 | 100% | +| 48 regs (effects) | 1,365 | 1,365 | ~67% | On low-end mobile (ARM Mali Bifrost/Valhall, 64 regs/thread, cliff fixed at 32 regs): @@ -261,11 +277,12 @@ Our design has two branch points: Every thread in every warp of a draw call sees the same `mode` value. **Zero divergence, zero cost.** -2. **`shape_kind` (flat varying from storage buffer): which SDF to evaluate.** This is category 3. +2. **`flags` (flat varying from storage buffer): gradient/texture/stroke mode.** This is category 3. The `flat` interpolation qualifier ensures that all fragments rasterized from one primitive's quad - receive the same `shape_kind` value. Divergence can only occur at the **boundary between two - adjacent primitives of different kinds**, where the rasterizer might pack fragments from both - primitives into the same warp. + receive the same flag bits. However, since the SDF path now evaluates only `sdRoundedBox` with no + kind dispatch, the only flag-dependent branches are gradient vs. texture vs. solid color selection + — all lightweight (3–8 instructions per path). Divergence at primitive boundaries between + different flag combinations has negligible cost. For category 3, the divergence analysis depends on primitive size: @@ -282,9 +299,10 @@ For category 3, the divergence analysis depends on primitive size: frame-level divergence is typically **1–3%** of all warps. At 1–3% divergence, the throughput impact is negligible. At 4K with 12.4M total fragments -(~387,000 warps), divergent boundary warps number in the low thousands. Each divergent warp pays at -most ~25 extra instructions (the cost of the longest untaken SDF branch). At ~12G instructions/sec -on a mid-range GPU, that totals ~4μs — under 0.05% of an 8.3ms (120 FPS) frame budget. This is +(~387,000 warps), divergent boundary warps number in the low thousands. Without kind dispatch, the +longest untaken branch is the gradient evaluation (~8 instructions), not a different SDF function. +Each divergent warp pays at most ~8 extra instructions. At ~12G instructions/sec on a mid-range GPU, +that totals ~1.3μs — under 0.02% of an 8.3ms (120 FPS) frame budget. This is confirmed by production renderers that use exactly this pattern: - **vger / vger-rs** (Audulus): single pipeline, 11 primitive kinds dispatched by a `switch` on a @@ -309,9 +327,10 @@ our design: > have no per-fragment data-dependent branches in the main pipeline. 2. **Branches where both paths are very long.** If both sides of a branch are 500+ instructions, - divergent warps pay double a large cost. Our SDF functions are 10–25 instructions each. Even - fully divergent, the penalty is ~25 extra instructions — less than a single texture sample's - latency. + divergent warps pay double a large cost. Without kind dispatch, the SDF path always evaluates + `sdRoundedBox`; the only branches are gradient/texture/solid color selection at 3–8 instructions + each. Even fully divergent, the penalty is ~8 extra instructions — less than a single texture + sample's latency. 3. **Branches that prevent compiler optimizations.** Some compilers cannot schedule instructions across branch boundaries, reducing VLIW utilization on older architectures. Modern GPUs (NVIDIA @@ -319,9 +338,9 @@ our design: concern. 4. **Register pressure from the union of all branches.** This is the real cost, and it is why we - split heavy effects (shadows, glass) into separate pipelines. Within the main pipeline, all SDF - branches have similar register footprints (12–22 registers), so combining them causes negligible - occupancy loss. + split heavy effects (shadows, glass) into separate pipelines. Within the main pipeline, the SDF + path has a single evaluation (sdRoundedBox) with flag-based color selection, clustering at ~15–18 + registers, so there is negligible occupancy loss. **References:** @@ -342,17 +361,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 push constant: +vertex input layout, distinguished by a mode marker in the `Primitive.flags` field (low byte: +0 = tessellated, 1 = SDF). The tessellated path sets this to 0 via zero-initialization in the vertex +shader; the SDF path sets it to 1 via `pack_flags`. -- **Tessellated mode** (`mode = 0`): direct vertex buffer with explicit geometry. Unchanged from - today. Used for text (SDL_ttf atlas sampling), polylines, triangle fans/strips, gradient-filled - shapes, and any user-provided raw vertex geometry. +- **Tessellated mode** (`mode = 0`): direct vertex buffer with explicit geometry. Used for text + (SDL_ttf atlas sampling), triangle fans/strips, ellipses, regular polygons, circle sectors, and + any user-provided raw vertex geometry. - **SDF mode** (`mode = 1`): shared unit-quad vertex buffer + GPU storage buffer of `Primitive` structs, drawn instanced. Used for all shapes with closed-form signed distance functions. -Both modes converge on the same fragment shader, which dispatches on a `shape_kind` discriminant -carried either in the vertex data (tessellated, always `Solid = 0`) or in the storage-buffer -primitive struct (SDF modes). +Both modes use the same fragment shader. The fragment shader checks the mode marker: mode 0 computes +`out = color * texture(tex, uv)`; mode 1 always evaluates `sdRoundedBox` and applies +gradient/texture/solid color based on flag bits. #### Why SDF for shapes @@ -391,49 +412,60 @@ 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 `Primitive` struct (~56 bytes) in the storage buffer. The +Each SDF shape is described by a single `Primitive` struct (80 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. Compared to encoding per-primitive data in vertex attributes (the "fat vertex" approach), storage- buffer instancing eliminates the 4–6× data duplication across quad corners. A rounded rectangle costs -56 bytes instead of 4 vertices × 40+ bytes = 160+ bytes. +80 bytes instead of 4 vertices × 40+ bytes = 160+ bytes. The tessellated path retains the existing direct vertex buffer layout (20 bytes/vertex, no storage buffer access). The vertex shader branch on `mode` (push constant) is warp-uniform — every invocation in a draw call has the same mode — so it is effectively free on all modern GPUs. -#### Shape kinds +#### Shape folding -Primitives in the main pipeline's storage buffer carry a `Shape_Kind` discriminant: +The SDF path evaluates a single function — `sdRoundedBox` — for all primitives. There is no +`Shape_Kind` enum or per-primitive kind dispatch in the fragment shader. Shapes that are algebraically +special cases of a rounded rectangle are emitted as RRect primitives by the CPU-side drawing procs: -| Kind | SDF function | Notes | -| ---------- | -------------------------------------- | --------------------------------------------------------- | -| `RRect` | `sdRoundedBox` (iq) | Per-corner radii. Covers all Clay rectangles and borders. | -| `Circle` | `sdCircle` | Filled and stroked. | -| `Ellipse` | `sdEllipse` | Exact (iq's closed-form). | -| `Segment` | `sdSegment` capsule | Rounded caps, correct sub-pixel thin lines. | -| `Ring_Arc` | `abs(sdCircle) - thickness` + arc mask | Rings, arcs, circle sectors unified. | -| `NGon` | `sdRegularPolygon` | Regular n-gon for n ≥ 5. | +| User-facing shape | RRect mapping | Notes | +| ---------------------------- | -------------------------------------------- | ---------------------------------------- | +| Rectangle (sharp or rounded) | Direct | Per-corner radii from `radii` param | +| Circle | `half_size = (r, r)`, `radii = (r, r, r, r)` | Uniform radii = half-size | +| Line segment / capsule | Rotated RRect, `radii = half_thickness` | Stadium shape (fully-rounded minor axis) | +| Full ring / annulus | Stroked circle at mid-radius | `stroke_px = outer - inner` | -The `Solid` kind (value 0) is reserved for the tessellated path, where `shape_kind` is implicitly -zero because the fragment shader receives it from zero-initialized vertex attributes. +Shapes without a closed-form RRect reduction are drawn via the tessellated path: -Stroke/outline variants of each shape are handled by the `Shape_Flags` bit set rather than separate -shape kinds. The fragment shader transforms `d = abs(d) - stroke_width` when the `Stroke` flag is -set. +| Shape | Tessellated proc | Method | +| ------------------------- | ---------------------------------- | -------------------------- | +| Ellipse | `tes_ellipse`, `tes_ellipse_lines` | Triangle fan approximation | +| Regular polygon (N-gon) | `tes_polygon`, `tes_polygon_lines` | Triangle fan from center | +| Circle sector (pie slice) | `tes_sector` | Triangle fan arc | + +The `Shape_Flags` bit set controls rendering mode per primitive: + +| Flag | Bit | Effect | +| ----------------- | --- | -------------------------------------------------------------------- | +| `Stroke` | 0 | Outline instead of fill (`d = abs(d) - stroke_width/2`) | +| `Textured` | 1 | Sample texture using `uv.uv_rect` (mutually exclusive with Gradient) | +| `Gradient` | 2 | Bilinear 4-corner interpolation from `uv.corner_colors` | +| `Gradient_Radial` | 3 | Radial 2-color falloff (inner/outer) from `uv.corner_colors[0..1]` | **What stays tessellated:** - Text (SDL_ttf atlas, pending future MSDF evaluation) -- `rectangle_gradient`, `circle_gradient` (per-vertex color interpolation) -- `triangle_fan`, `triangle_strip` (arbitrary user-provided point lists) -- `line_strip` / polylines (SDF polyline rendering is possible but complex; deferred) +- Ellipses (`tes_ellipse`, `tes_ellipse_lines`) +- Regular polygons (`tes_polygon`, `tes_polygon_lines`) +- Circle sectors / pie slices (`tes_sector`) +- `tes_triangle`, `tes_triangle_fan`, `tes_triangle_strip` (arbitrary user-provided geometry) - Any raw vertex geometry submitted via `prepare_shape` -The rule: if the shape has a closed-form SDF, it goes SDF. If it's described only by a vertex list or -needs per-vertex color interpolation, it stays tessellated. +The design rule: if the shape reduces to `sdRoundedBox`, it goes SDF. If it requires a different SDF +function or is described by a vertex list, it stays tessellated. ### Effects pipeline @@ -547,21 +579,21 @@ The `Primitive` struct for SDF shapes lives in the storage buffer, not in vertex ``` Primitive :: struct { - bounds: [4]f32, // 0: min_x, min_y, max_x, max_y - color: Color, // 16: u8x4, unpacked in shader via unpackUnorm4x8 - kind_flags: u32, // 20: (kind as u32) | (flags as u32 << 8) - rotation: f32, // 24: shader self-rotation in radians - _pad: f32, // 28: alignment - params: Shape_Params, // 32: raw union, 32 bytes (two vec4s of shape-specific data) - uv_rect: [4]f32, // 64: texture UV sub-region (u_min, v_min, u_max, v_max) + 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 + rotation_sc: u32, // 24: packed f16 pair (sin, cos). Requires .Rotated flag. + _pad: f32, // 28: reserved for future use + params: Shape_Params, // 32: per-kind params union (half_feather, radii, etc.) (32 bytes) + uv: Uv_Or_Effects, // 64: texture UV rect or gradient/outline parameters (16 bytes) } // Total: 80 bytes (std430 aligned) ``` -`Shape_Params` is a `#raw_union` with named variants per shape kind (`rrect`, `circle`, `segment`, -etc.), ensuring type safety on the CPU side and zero-cost reinterpretation on the GPU side. The -`uv_rect` field is used by textured SDF primitives (Shape_Flag.Textured); non-textured primitives -leave it zeroed. +`RRect_Params` holds the rounded-rectangle parameters directly — there is no `Shape_Params` union. +`Uv_Or_Gradient` is a `#raw_union` that aliases `[4]f32` (texture UV rect) with `[4]Color` (gradient +corner colors, clockwise from top-left: TL, TR, BR, BL). The `flags` field encodes both the +tessellated/SDF mode marker (low byte) and shape flags (bits 8+) via `pack_flags`. ### Draw submission order @@ -583,14 +615,15 @@ invariant is that each primitive is drawn exactly once, in the pipeline that own Text rendering currently uses SDL_ttf's GPU text engine, which rasterizes glyphs per `(font, size)` pair into bitmap atlases and emits indexed triangle data via `GetGPUTextDrawData`. This path is **unchanged** by the SDF migration — text continues to flow through the main pipeline's tessellated -mode with `shape_kind = Solid`, sampling the SDL_ttf atlas texture. +mode with `mode = 0`, sampling the SDL_ttf atlas texture. A future phase may evaluate MSDF (multi-channel signed distance field) text rendering, which would allow resolution-independent glyph rendering from a single small atlas per font. This would involve: - Offline atlas generation via Chlumský's msdf-atlas-gen tool. - Runtime glyph metrics via `vendor:stb/truetype` (already in the Odin distribution). -- A new `Shape_Kind.MSDF_Glyph` variant in the main pipeline's fragment shader. +- A new MSDF glyph mode in the fragment shader, which would require reintroducing a mode/kind + distinction (the current shader evaluates only `sdRoundedBox` with no kind dispatch). - Potential removal of the SDL_ttf dependency. This is explicitly deferred. The SDF shape migration is independent of and does not block text @@ -659,30 +692,26 @@ with the same texture but different samplers produce separate draw calls, which #### Textured draw procs -Textured rectangles route through the existing SDF path via `draw.rectangle_texture` and -`draw.rectangle_texture_corners`, mirroring `draw.rectangle` and `draw.rectangle_corners` exactly — +Textured rectangles route through the existing SDF path via `sdf_rectangle_texture` and +`sdf_rectangle_texture_corners`, mirroring `sdf_rectangle` and `sdf_rectangle_corners` exactly — same parameters, same naming — with the color parameter replaced by a texture ID plus an optional tint. -An earlier iteration of this design considered a separate tessellated `draw.texture` proc for -"simple" fullscreen quads, on the theory that the tessellated path's lower register count (~16 regs -vs ~24 for the SDF textured branch) would improve occupancy at large fragment counts. Applying the -register-pressure analysis from the pipeline-strategy section above shows this is wrong: both 16 and -24 registers are well below the register cliff (~43 regs on consumer Ampere/Ada, ~32 on Volta/A100), -so both run at 100% occupancy. The remaining ALU difference (~15 extra instructions for the SDF -evaluation) amounts to ~20μs at 4K — below noise. Meanwhile, splitting into a separate pipeline -would add ~1–5μs per pipeline bind on the CPU side per scissor, matching or exceeding the GPU-side -savings. Within the main pipeline, unified remains strictly better. +An earlier iteration of this design considered a separate tessellated proc for "simple" fullscreen +quads, on the theory that the tessellated path's lower register count (~16 regs vs ~18 for the SDF +textured branch) would improve occupancy at large fragment counts. Applying the register-pressure +analysis from the pipeline-strategy section above shows this is wrong: both 16 and 18 registers are +well below the register cliff (~43 regs on consumer Ampere/Ada, ~32 on Volta/A100), so both run at +100% occupancy. The remaining ALU difference (~15 extra instructions for the SDF evaluation) amounts +to ~20μs at 4K — below noise. Meanwhile, splitting into a separate pipeline would add ~1–5μs per +pipeline bind on the CPU side per scissor, matching or exceeding the GPU-side savings. Within the +main pipeline, unified remains strictly better. -The naming convention follows the existing shape API: `rectangle_texture` and -`rectangle_texture_corners` sit alongside `rectangle` and `rectangle_corners`, mirroring the -`rectangle_gradient` / `circle_gradient` pattern where the shape is the primary noun and the -modifier (gradient, texture) is secondary. This groups related procs together in autocomplete -(`rectangle_*`) and reads as natural English ("draw a rectangle with a texture"). - -Future per-shape texture variants (`circle_texture`, `ellipse_texture`, `polygon_texture`) are -reserved by this naming convention and require only a `Shape_Flag.Textured` bit plus a small -per-shape UV mapping function in the fragment shader. These are additive. +The naming convention uses `sdf_` and `tes_` prefixes to indicate the rendering path, with suffixes +for modifiers: `sdf_rectangle_texture` and `sdf_rectangle_texture_corners` sit alongside +`sdf_rectangle` (solid or gradient overload). Proc groups like `sdf_rectangle` dispatch to +`sdf_rectangle_solid` or `sdf_rectangle_gradient` based on argument count. Future per-shape texture +variants (`sdf_circle_texture`) are additive. #### What SDF anti-aliasing does and does not do for textured draws @@ -721,9 +750,9 @@ textures onto a free list that is processed in `r_end_frame`, not at the call si Clay's `RenderCommandType.Image` is handled by dereferencing `imageData: rawptr` as a pointer to a `Clay_Image_Data` struct containing a `Texture_Id`, `Fit_Mode`, and tint color. Routing mirrors the -existing rectangle handling: zero `cornerRadius` dispatches to `draw.texture` (tessellated), nonzero -dispatches to `draw.rectangle_texture_corners` (SDF). A `fit_params` call computes UVs from the fit -mode before dispatch. +existing rectangle handling: zero `cornerRadius` dispatches to `sdf_rectangle_texture` (SDF, sharp +corners), nonzero dispatches to `sdf_rectangle_texture_corners` (SDF, per-corner radii). A +`fit_params` call computes UVs from the fit mode before dispatch. #### Deferred features @@ -735,7 +764,7 @@ The following are plumbed in the descriptor but not implemented in phase 1: - **3D textures, arrays, cube maps**: `Texture_Desc.type` and `depth_or_layers` fields exist. - **Additional samplers**: anisotropic, trilinear, clamp-to-border — additive enum values. - **Atlas packing**: internal optimization for sub-batch coalescing; invisible to callers. -- **Per-shape texture variants**: `circle_texture`, `ellipse_texture`, etc. — reserved by naming. +- **Per-shape texture variants**: `sdf_circle_texture`, `tes_ellipse_texture`, `tes_polygon_texture` — potential future additions, reserved by naming convention. **References:** diff --git a/draw/draw.odin b/draw/draw.odin index 0fb4934..fab8c54 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -4,6 +4,7 @@ import "base:runtime" import "core:c" import "core:log" import "core:math" + import "core:strings" import sdl "vendor:sdl3" import sdl_ttf "vendor:sdl3/ttf" @@ -27,72 +28,22 @@ BUFFER_INIT_SIZE :: 256 INITIAL_LAYER_SIZE :: 5 INITIAL_SCISSOR_SIZE :: 10 -// --------------------------------------------------------------------------------------------------------------------- -// ----- Color ------------------------- -// --------------------------------------------------------------------------------------------------------------------- +// Sentinel value: when passed as msaa_samples, `init` will use the maximum MSAA sample count +// supported by the GPU for the swapchain format. +MSAA_MAX :: sdl.GPUSampleCount(0xFF) -Color :: distinct [4]u8 - -BLACK :: Color{0, 0, 0, 255} -WHITE :: Color{255, 255, 255, 255} -RED :: Color{255, 0, 0, 255} -GREEN :: Color{0, 255, 0, 255} -BLUE :: Color{0, 0, 255, 255} -BLANK :: Color{0, 0, 0, 0} - -// Convert clay.Color ([4]c.float in 0–255 range) to Color. -color_from_clay :: proc(clay_color: clay.Color) -> Color { - return Color{u8(clay_color[0]), u8(clay_color[1]), u8(clay_color[2]), u8(clay_color[3])} -} - -// Convert Color to [4]f32 in 0.0–1.0 range. Useful for SDL interop (e.g. clear color). -color_to_f32 :: proc(color: Color) -> [4]f32 { - INV :: 1.0 / 255.0 - return {f32(color[0]) * INV, f32(color[1]) * INV, f32(color[2]) * INV, f32(color[3]) * INV} -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- Core types -------------------- -// --------------------------------------------------------------------------------------------------------------------- - -Rectangle :: struct { - x: f32, - y: f32, - width: f32, - height: f32, -} - -Sub_Batch_Kind :: enum u8 { - Shapes, // non-indexed, white texture or user texture, mode 0 - Text, // indexed, atlas texture, mode 0 - SDF, // instanced unit quad, white texture or user texture, mode 1 -} - -Sub_Batch :: struct { - kind: Sub_Batch_Kind, - offset: u32, // Shapes: vertex offset; Text: text_batch index; SDF: primitive index - count: u32, // Shapes: vertex count; Text: always 1; SDF: primitive count - texture_id: Texture_Id, - sampler: Sampler_Preset, -} - -Layer :: struct { - bounds: Rectangle, - sub_batch_start: u32, - sub_batch_len: u32, - scissor_start: u32, - scissor_len: u32, -} - -Scissor :: struct { - bounds: sdl.Rect, - sub_batch_start: u32, - sub_batch_len: u32, -} - -// --------------------------------------------------------------------------------------------------------------------- -// ----- Global state ------------------ -// --------------------------------------------------------------------------------------------------------------------- +// ----- Default parameter values ----- +// Named constants for non-zero default procedure parameters. Centralizes magic numbers +// so they can be tuned in one place and referenced by name in proc signatures. +DFT_FEATHER_PX :: 1 // Total AA feather width in physical pixels (half on each side of boundary). +DFT_STROKE_THICKNESS :: 1 // Default line/stroke thickness in logical pixels. +DFT_FONT_SIZE :: 44 // Default font size in points for text rendering. +DFT_CIRC_END_ANGLE :: 360 // Full-circle end angle in degrees (ring/arc). +DFT_UV_RECT :: Rectangle{0, 0, 1, 1} // Full-texture UV rect (rectangle_texture). +DFT_TINT :: WHITE // Default texture tint (rectangle_texture, clay_image). +DFT_TEXT_COLOR :: BLACK // Default text color. +DFT_CLEAR_COLOR :: BLACK // Default clear color for end(). +DFT_SAMPLER :: Sampler_Preset.Linear_Clamp // Default texture sampler preset. GLOB: Global @@ -153,6 +104,136 @@ Global :: struct { odin_context: runtime.Context, // Odin context captured at init for use in callbacks. } +// --------------------------------------------------------------------------------------------------------------------- +// ----- Core types -------------------- +// --------------------------------------------------------------------------------------------------------------------- + +// A 2D position in world space. Non-distinct alias for [2]f32 — bare literals like {100, 200} +// work at non-ambiguous call sites. +// +// Coordinate system: origin is the top-left corner of the window/layer. X increases rightward, +// Y increases downward. This matches SDL, HTML Canvas, and most 2D UI coordinate conventions. +// All position parameters in the draw API (center, origin, start_position, end_position, etc.) +// use this coordinate system. +// +// Units are logical pixels (pre-DPI-scaling). The renderer multiplies by dpi_scaling internally +// before uploading to the GPU. A Vec2{100, 50} refers to the same visual location regardless of +// display DPI. +Vec2 :: [2]f32 + +// An RGBA color with 8 bits per channel. Distinct type over [4]u8 so that proc-group +// overloads can disambiguate Color from other 4-byte structs. +// +// Channel order: R, G, B, A (indices 0, 1, 2, 3). Alpha 255 is fully opaque, 0 is fully +// transparent. This matches the GPU-side layout: the shader unpacks via unpackUnorm4x8 which +// reads the bytes in memory order as R, G, B, A and normalizes each to [0, 1]. +// +// When used in the Primitive struct (Primitive.color), the 4 bytes are stored as a u32 in +// native byte order and unpacked by the shader. +Color :: [4]u8 + +BLACK :: Color{0, 0, 0, 255} +WHITE :: Color{255, 255, 255, 255} +RED :: Color{255, 0, 0, 255} +GREEN :: Color{0, 255, 0, 255} +BLUE :: Color{0, 0, 255, 255} +BLANK :: Color{0, 0, 0, 0} + +// Per-corner rounding radii for rectangles, specified clockwise from top-left. +// All values are in logical pixels (pre-DPI-scaling). +Rectangle_Radii :: struct { + top_left: f32, + top_right: f32, + bottom_right: f32, + bottom_left: f32, +} + +// A linear gradient between two colors along an arbitrary angle. +// The `end_color` is the color at the end of the gradient direction; the shape's fill `color` +// parameter acts as the start color. `angle` is in degrees: 0 = left-to-right, 90 = top-to-bottom. +Linear_Gradient :: struct { + end_color: Color, + angle: f32, +} + +// A radial gradient between two colors from center to edge. +// The `outer_color` is the color at the shape's edge; the shape's fill `color` parameter +// acts as the inner (center) color. +Radial_Gradient :: struct { + outer_color: Color, +} + +// Tagged union for specifying a gradient on any shape. Defaults to `nil` (no gradient). +// When a gradient is active, the shape's `color` parameter becomes the start/inner color, +// and the gradient struct carries the end/outer color plus any type-specific parameters. +// +// Gradient and Textured are mutually exclusive on the same primitive. If a shape uses +// `rectangle_texture`, gradients are not applicable — use the tint color instead. +Gradient :: union { + Linear_Gradient, + Radial_Gradient, +} + +// Convert clay.Color ([4]c.float in 0–255 range) to Color. +color_from_clay :: #force_inline proc(clay_color: clay.Color) -> Color { + return Color{u8(clay_color[0]), u8(clay_color[1]), u8(clay_color[2]), u8(clay_color[3])} +} + +// Convert Color to [4]f32 in 0.0–1.0 range. Useful for SDL interop (e.g. clear color). +color_to_f32 :: proc(color: Color) -> [4]f32 { + INV :: 1.0 / 255.0 + return {f32(color[0]) * INV, f32(color[1]) * INV, f32(color[2]) * INV, f32(color[3]) * INV} +} + +// Pre-multiply RGB channels by alpha. 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. +premultiply_color :: #force_inline proc(color: Color) -> Color { + a := u32(color[3]) + return Color { + u8((u32(color[0]) * a + 127) / 255), + u8((u32(color[1]) * a + 127) / 255), + u8((u32(color[2]) * a + 127) / 255), + color[3], + } +} + +Rectangle :: struct { + x: f32, + y: f32, + width: f32, + height: f32, +} + +Sub_Batch_Kind :: enum u8 { + Tessellated, // non-indexed, white texture or user texture, mode 0 + Text, // indexed, atlas texture, mode 0 + SDF, // instanced unit quad, white texture or user texture, mode 1 +} + +Sub_Batch :: struct { + kind: Sub_Batch_Kind, + offset: u32, // Tessellated: vertex offset; Text: text_batch index; SDF: primitive index + count: u32, // Tessellated: vertex count; Text: always 1; SDF: primitive count + texture_id: Texture_Id, + sampler: Sampler_Preset, +} + +Layer :: struct { + bounds: Rectangle, + sub_batch_start: u32, + sub_batch_len: u32, + scissor_start: u32, + scissor_len: u32, +} + +Scissor :: struct { + bounds: sdl.Rect, + sub_batch_start: u32, + sub_batch_len: u32, +} + Init_Options :: struct { // MSAA sample count. Default is ._1 (no MSAA). SDF rendering does not benefit from MSAA // because SDF fragments compute coverage analytically via `smoothstep`. MSAA helps for @@ -162,10 +243,6 @@ Init_Options :: struct { msaa_samples: sdl.GPUSampleCount, } -// Sentinel value: when passed as msaa_samples, `init` will use the maximum MSAA sample count -// supported by the GPU for the swapchain format. -MSAA_MAX :: sdl.GPUSampleCount(0xFF) - // Initialize the renderer. Returns false if GPU pipeline or text engine creation fails. @(require_results) init :: proc( @@ -378,12 +455,13 @@ new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer { // --------------------------------------------------------------------------------------------------------------------- // 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, .Shapes, offset, u32(len(vertices))) + append_or_extend_sub_batch(scissor, layer, .Tessellated, offset, u32(len(vertices))) } // Submit an SDF primitive to the given layer for rendering. @@ -409,6 +487,9 @@ prepare_text :: proc(layer: ^Layer, text: Text) { 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)) @@ -419,7 +500,7 @@ prepare_text :: proc(layer: ^Layer, text: Text) { 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 = text.color}, + Vertex{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = pm_color}, ) } @@ -457,6 +538,9 @@ prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform 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)) @@ -469,7 +553,7 @@ prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform // 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 = text.color}, + Vertex{position = apply_transform(transform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = pm_color}, ) } @@ -502,7 +586,7 @@ append_or_extend_sub_batch :: proc( offset: u32, count: u32, texture_id: Texture_Id = INVALID_TEXTURE, - sampler: Sampler_Preset = .Linear_Clamp, + sampler: Sampler_Preset = DFT_SAMPLER, ) { if scissor.sub_batch_len > 0 { last := &GLOB.tmp_sub_batches[scissor.sub_batch_start + scissor.sub_batch_len - 1] @@ -595,6 +679,9 @@ prepare_clay_batch :: proc( switch (render_command.commandType) { case clay.RenderCommandType.None: + log.errorf( + "Received render command with type None. This generally means we're in some kind of fucked up state.", + ) case clay.RenderCommandType.Text: render_data := render_command.renderData.text txt := string(render_data.stringContents.chars[:render_data.stringContents.length]) @@ -609,46 +696,29 @@ prepare_clay_batch :: proc( ) prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)}) case clay.RenderCommandType.Image: + // Any texture render_data := render_command.renderData.image if render_data.imageData == nil do continue img_data := (^Clay_Image_Data)(render_data.imageData)^ cr := render_data.cornerRadius - radii := [4]f32{cr.topLeft, cr.topRight, cr.bottomRight, cr.bottomLeft} + radii := Rectangle_Radii { + top_left = cr.topLeft, + top_right = cr.topRight, + bottom_right = cr.bottomRight, + bottom_left = cr.bottomLeft, + } // Background color behind the image (Clay allows it) bg := color_from_clay(render_data.backgroundColor) if bg[3] > 0 { - if radii == {0, 0, 0, 0} { - rectangle(layer, bounds, bg) - } else { - rectangle_corners(layer, bounds, radii, bg) - } + rectangle(layer, bounds, bg, radii = radii) } // Compute fit UVs uv, sampler, inner := fit_params(img_data.fit, bounds, img_data.texture_id) - // Draw the image — route by cornerRadius - if radii == {0, 0, 0, 0} { - rectangle_texture( - layer, - inner, - img_data.texture_id, - tint = img_data.tint, - uv_rect = uv, - sampler = sampler, - ) - } else { - rectangle_texture_corners( - layer, - inner, - radii, - img_data.texture_id, - tint = img_data.tint, - uv_rect = uv, - sampler = sampler, - ) - } + // Draw the image + rectangle_texture(layer, inner, img_data.texture_id, img_data.tint, uv, sampler, radii) case clay.RenderCommandType.ScissorStart: if bounds.width == 0 || bounds.height == 0 do continue @@ -680,34 +750,38 @@ prepare_clay_batch :: proc( render_data := render_command.renderData.rectangle cr := render_data.cornerRadius color := color_from_clay(render_data.backgroundColor) - radii := [4]f32{cr.topLeft, cr.topRight, cr.bottomRight, cr.bottomLeft} - - if radii == {0, 0, 0, 0} { - rectangle(layer, bounds, color) - } else { - rectangle_corners(layer, bounds, radii, color) + radii := Rectangle_Radii { + top_left = cr.topLeft, + top_right = cr.topRight, + bottom_right = cr.bottomRight, + bottom_left = cr.bottomLeft, } + + rectangle(layer, bounds, color, radii = radii) case clay.RenderCommandType.Border: render_data := render_command.renderData.border cr := render_data.cornerRadius color := color_from_clay(render_data.color) thickness := f32(render_data.width.top) - radii := [4]f32{cr.topLeft, cr.topRight, cr.bottomRight, cr.bottomLeft} - - if radii == {0, 0, 0, 0} { - rectangle_lines(layer, bounds, color, thickness) - } else { - rectangle_corners_lines(layer, bounds, radii, color, thickness) + radii := Rectangle_Radii { + top_left = cr.topLeft, + top_right = cr.topRight, + bottom_right = cr.bottomRight, + bottom_left = cr.bottomLeft, } + + rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii) case clay.RenderCommandType.Custom: if custom_draw != nil { custom_draw(layer, bounds, render_command.renderData.custom) + } else { + log.error("Received clay render command of type custom but no custom_draw proc provided.") } } } } // Render primitives. clear_color is the background fill before any layers are drawn. -end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = BLACK) { +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()) @@ -740,7 +814,16 @@ end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = BL render_texture = GLOB.msaa_texture } - clear_color_f32 := color_to_f32(clear_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, + } // Draw layers. One render pass per layer; sub-batches draw in submission order within each scissor. for &layer, index in GLOB.layers { @@ -948,10 +1031,20 @@ Transform_2D :: struct { // origin – pivot point in local space (measured from the shape's natural reference point). // rotation_deg – rotation in degrees, counter-clockwise. // -build_pivot_rotation :: proc(position: [2]f32, origin: [2]f32, rotation_deg: f32) -> Transform_2D { +build_pivot_rotation :: proc(position: Vec2, origin: Vec2, rotation_deg: f32) -> Transform_2D { radians := math.to_radians(rotation_deg) cos_angle := math.cos(radians) sin_angle := math.sin(radians) + return build_pivot_rotation_sc(position, origin, cos_angle, sin_angle) +} + +// Variant of build_pivot_rotation that accepts pre-computed cos/sin values, +// avoiding redundant trigonometry when the caller has already computed them. +build_pivot_rotation_sc :: #force_inline proc( + position: Vec2, + origin: Vec2, + cos_angle, sin_angle: f32, +) -> Transform_2D { return Transform_2D { m00 = cos_angle, m01 = -sin_angle, @@ -963,7 +1056,7 @@ build_pivot_rotation :: proc(position: [2]f32, origin: [2]f32, rotation_deg: f32 } // Apply the transform to a local-space point, producing a world-space point. -apply_transform :: #force_inline proc(transform: Transform_2D, point: [2]f32) -> [2]f32 { +apply_transform :: #force_inline proc(transform: Transform_2D, point: Vec2) -> Vec2 { return { transform.m00 * point.x + transform.m01 * point.y + transform.tx, transform.m10 * point.x + transform.m11 * point.y + transform.ty, @@ -973,7 +1066,7 @@ apply_transform :: #force_inline proc(transform: Transform_2D, point: [2]f32) -> // Fast-path check callers use BEFORE building a transform. // Returns true if either the origin is non-zero or rotation is non-zero, // meaning a transform actually needs to be computed. -needs_transform :: #force_inline proc(origin: [2]f32, rotation: f32) -> bool { +needs_transform :: #force_inline proc(origin: Vec2, rotation: f32) -> bool { return origin != {0, 0} || rotation != 0 } diff --git a/draw/draw_qr/draw_qr.odin b/draw/draw_qr/draw_qr.odin index 91cf532..d7f8586 100644 --- a/draw/draw_qr/draw_qr.odin +++ b/draw/draw_qr/draw_qr.odin @@ -3,6 +3,10 @@ package draw_qr import draw ".." import "../../qrcode" +DFT_QR_DARK :: draw.BLACK // Default QR code dark module color. +DFT_QR_LIGHT :: draw.WHITE // Default QR code light module color. +DFT_QR_BOOST_ECL :: true // Default QR error correction level boost. + // Returns the number of bytes to_texture will write for the given encoded // QR buffer. Equivalent to size*size*4 where size = qrcode.get_size(qrcode_buf). texture_size :: #force_inline proc(qrcode_buf: []u8) -> int { @@ -21,8 +25,8 @@ texture_size :: #force_inline proc(qrcode_buf: []u8) -> int { to_texture :: proc( qrcode_buf: []u8, texture_buf: []u8, - dark: draw.Color = draw.BLACK, - light: draw.Color = draw.WHITE, + dark: draw.Color = DFT_QR_DARK, + light: draw.Color = DFT_QR_LIGHT, ) -> ( desc: draw.Texture_Desc, ok: bool, @@ -65,8 +69,8 @@ to_texture :: proc( @(require_results) register_texture_from_raw :: proc( qrcode_buf: []u8, - dark: draw.Color = draw.BLACK, - light: draw.Color = draw.WHITE, + dark: draw.Color = DFT_QR_DARK, + light: draw.Color = DFT_QR_LIGHT, temp_allocator := context.temp_allocator, ) -> ( texture: draw.Texture_Id, @@ -96,9 +100,9 @@ register_texture_from_text :: proc( min_version: int = qrcode.VERSION_MIN, max_version: int = qrcode.VERSION_MAX, mask: Maybe(qrcode.Mask) = nil, - boost_ecl: bool = true, - dark: draw.Color = draw.BLACK, - light: draw.Color = draw.WHITE, + boost_ecl: bool = DFT_QR_BOOST_ECL, + dark: draw.Color = DFT_QR_DARK, + light: draw.Color = DFT_QR_LIGHT, temp_allocator := context.temp_allocator, ) -> ( texture: draw.Texture_Id, @@ -135,9 +139,9 @@ register_texture_from_binary :: proc( min_version: int = qrcode.VERSION_MIN, max_version: int = qrcode.VERSION_MAX, mask: Maybe(qrcode.Mask) = nil, - boost_ecl: bool = true, - dark: draw.Color = draw.BLACK, - light: draw.Color = draw.WHITE, + boost_ecl: bool = DFT_QR_BOOST_ECL, + dark: draw.Color = DFT_QR_DARK, + light: draw.Color = DFT_QR_LIGHT, temp_allocator := context.temp_allocator, ) -> ( texture: draw.Texture_Id, @@ -163,13 +167,13 @@ register_texture_from_binary :: proc( register_texture_from :: proc { register_texture_from_text, - register_texture_from_binary + register_texture_from_binary, } // Default fit=.Fit preserves the QR's square aspect; override as needed. clay_image :: #force_inline proc( texture: draw.Texture_Id, - tint: draw.Color = draw.WHITE, + tint: draw.Color = draw.DFT_TINT, ) -> draw.Clay_Image_Data { return draw.clay_image_data(texture, fit = .Fit, tint = tint) } diff --git a/draw/examples/hellope.odin b/draw/examples/hellope.odin index eb945bd..9a3bafd 100644 --- a/draw/examples/hellope.odin +++ b/draw/examples/hellope.odin @@ -1,6 +1,7 @@ package examples import "../../draw" +import "../../draw/tess" import "../../vendor/clay" import "core:math" import "core:os" @@ -28,19 +29,26 @@ hellope_shapes :: proc() { base_layer := draw.begin({width = 500, height = 500}) // Background - draw.rectangle(base_layer, {0, 0, 500, 500}, {40, 40, 40, 255}) + draw.rectangle(base_layer, {0, 0, 500, 500}, draw.Color{40, 40, 40, 255}) // ----- Shapes without rotation (existing demo) ----- - draw.rectangle(base_layer, {20, 20, 200, 120}, {80, 120, 200, 255}) - draw.rectangle_lines(base_layer, {20, 20, 200, 120}, draw.WHITE, thickness = 2) - draw.rectangle(base_layer, {240, 20, 240, 120}, {200, 80, 80, 255}, roundness = 0.3) - draw.rectangle_gradient( + draw.rectangle( + base_layer, + {20, 20, 200, 120}, + draw.Color{80, 120, 200, 255}, + outline_color = draw.WHITE, + outline_width = 2, + radii = {top_right = 15, top_left = 5}, + ) + + red_rect_raddi := draw.uniform_radii({240, 20, 240, 120}, 0.3) + red_rect_raddi.bottom_left = 0 + draw.rectangle(base_layer, {240, 20, 240, 120}, draw.Color{200, 80, 80, 255}, radii = red_rect_raddi) + draw.rectangle( base_layer, {20, 160, 460, 60}, {255, 0, 0, 255}, - {0, 255, 0, 255}, - {0, 0, 255, 255}, - {255, 255, 0, 255}, + gradient = draw.Linear_Gradient{end_color = {0, 0, 255, 255}, angle = 0}, ) // ----- Rotation demos ----- @@ -50,17 +58,12 @@ hellope_shapes :: proc() { draw.rectangle( base_layer, rect, - {100, 200, 100, 255}, - origin = draw.center_of(rect), - rotation = spin_angle, - ) - draw.rectangle_lines( - base_layer, - rect, - draw.WHITE, - thickness = 2, + draw.Color{100, 200, 100, 255}, + outline_color = draw.WHITE, + outline_width = 2, origin = draw.center_of(rect), rotation = spin_angle, + feather_px = 1, ) // Rounded rectangle rotating around its center @@ -68,8 +71,8 @@ hellope_shapes :: proc() { draw.rectangle( base_layer, rrect, - {200, 100, 200, 255}, - roundness = 0.4, + draw.Color{200, 100, 200, 255}, + radii = draw.uniform_radii(rrect, 0.4), origin = draw.center_of(rrect), rotation = spin_angle, ) @@ -80,18 +83,34 @@ hellope_shapes :: proc() { // Circle orbiting a point (moon orbiting planet) // Convention B: center = pivot point (planet), origin = offset from moon center to pivot. // Moon's visual center at rotation=0: planet_pos - origin = (100, 450) - (0, 40) = (100, 410). - planet_pos := [2]f32{100, 450} + planet_pos := draw.Vec2{100, 450} draw.circle(base_layer, planet_pos, 8, {200, 200, 200, 255}) // planet (stationary) - draw.circle(base_layer, planet_pos, 5, {100, 150, 255, 255}, origin = {0, 40}, rotation = spin_angle) // moon orbiting + draw.circle( + base_layer, + planet_pos, + 5, + {100, 150, 255, 255}, + origin = draw.Vec2{0, 40}, + rotation = spin_angle, + ) // moon orbiting - // Ring arc rotating in place - draw.ring(base_layer, {250, 450}, 15, 30, 0, 270, {100, 100, 220, 255}, rotation = spin_angle) + // Sector (pie slice) rotating in place + draw.ring( + base_layer, + draw.Vec2{250, 450}, + 0, + 30, + {100, 100, 220, 255}, + start_angle = 0, + end_angle = 270, + rotation = spin_angle, + ) // Triangle rotating around its center - tv1 := [2]f32{350, 420} - tv2 := [2]f32{420, 480} - tv3 := [2]f32{340, 480} - draw.triangle( + tv1 := draw.Vec2{350, 420} + tv2 := draw.Vec2{420, 480} + tv3 := draw.Vec2{340, 480} + tess.triangle_aa( base_layer, tv1, tv2, @@ -102,8 +121,16 @@ hellope_shapes :: proc() { ) // Polygon rotating around its center (already had rotation; now with origin for orbit) - draw.polygon(base_layer, {460, 450}, 6, 30, {180, 100, 220, 255}, rotation = spin_angle) - draw.polygon_lines(base_layer, {460, 450}, 6, 30, draw.WHITE, rotation = spin_angle, thickness = 2) + draw.polygon( + base_layer, + {460, 450}, + 6, + 30, + {180, 100, 220, 255}, + outline_color = draw.WHITE, + outline_width = 2, + rotation = spin_angle, + ) draw.end(gpu, window) } @@ -134,9 +161,6 @@ hellope_text :: proc() { spin_angle += 0.5 base_layer := draw.begin({width = 600, height = 600}) - // Grey background - draw.rectangle(base_layer, {0, 0, 600, 600}, {127, 127, 127, 255}) - // ----- Text API demos ----- // Cached text with id — TTF_Text reused across frames (good for text-heavy apps) @@ -176,7 +200,7 @@ hellope_text :: proc() { // Measure text for manual layout size := draw.measure_text("Measured!", JETBRAINS_MONO_REGULAR, FONT_SIZE) - draw.rectangle(base_layer, {300 - size.x / 2, 380, size.x, size.y}, {60, 60, 60, 200}) + draw.rectangle(base_layer, {300 - size.x / 2, 380, size.x, size.y}, draw.Color{60, 60, 60, 200}) draw.text( base_layer, "Measured!", @@ -200,7 +224,7 @@ hellope_text :: proc() { id = CORNER_SPIN_ID, ) - draw.end(gpu, window) + draw.end(gpu, window, draw.Color{127, 127, 127, 255}) } } @@ -338,15 +362,21 @@ hellope_custom :: proc() { draw_custom :: proc(layer: ^draw.Layer, bounds: draw.Rectangle, render_data: clay.CustomRenderData) { gauge := cast(^Gauge)render_data.customData - // Background from clay's backgroundColor - draw.rectangle(layer, bounds, draw.color_from_clay(render_data.backgroundColor), roundness = 0.25) + border_width: f32 = 2 + draw.rectangle( + layer, + bounds, + draw.color_from_clay(render_data.backgroundColor), + outline_color = draw.WHITE, + outline_width = border_width, + ) - // Fill bar - fill := bounds - fill.width *= gauge.value - draw.rectangle(layer, fill, gauge.color, roundness = 0.25) - - // Border - draw.rectangle_lines(layer, bounds, draw.WHITE, thickness = 2, roundness = 0.25) + fill := draw.Rectangle { + x = bounds.x, + y = bounds.y, + width = bounds.width * gauge.value, + height = bounds.height, + } + draw.rectangle(layer, fill, gauge.color) } } diff --git a/draw/examples/textures.odin b/draw/examples/textures.odin index b49b33a..258cb76 100644 --- a/draw/examples/textures.odin +++ b/draw/examples/textures.odin @@ -89,7 +89,7 @@ textures :: proc() { base_layer := draw.begin({width = 800, height = 600}) // Background - draw.rectangle(base_layer, {0, 0, 800, 600}, {30, 30, 30, 255}) + draw.rectangle(base_layer, {0, 0, 800, 600}, draw.Color{30, 30, 30, 255}) //----- Row 1: Sampler presets (y=30) ---------------------------------- @@ -154,7 +154,7 @@ textures :: proc() { ROW2_Y :: f32(190) // QR code (RGBA texture with baked colors, nearest sampling) - draw.rectangle(base_layer, {COL1, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, {255, 255, 255, 255}) // white bg + draw.rectangle(base_layer, {COL1, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, draw.Color{255, 255, 255, 255}) // white bg draw.rectangle_texture( base_layer, {COL1, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, @@ -176,7 +176,7 @@ textures :: proc() { {COL2, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, checker_texture, sampler = .Nearest_Clamp, - roundness = 0.3, + radii = draw.uniform_radii({COL2, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, 0.3), ) draw.text( base_layer, @@ -213,7 +213,7 @@ textures :: proc() { // Stretch uv_s, sampler_s, inner_s := draw.fit_params(.Stretch, {COL1, ROW3_Y, FIT_SIZE, FIT_SIZE}, stripe_texture) - draw.rectangle(base_layer, {COL1, ROW3_Y, FIT_SIZE, FIT_SIZE}, {60, 60, 60, 255}) // bg + draw.rectangle(base_layer, {COL1, ROW3_Y, FIT_SIZE, FIT_SIZE}, draw.Color{60, 60, 60, 255}) // bg draw.rectangle_texture(base_layer, inner_s, stripe_texture, uv_rect = uv_s, sampler = sampler_s) draw.text( base_layer, @@ -226,7 +226,7 @@ textures :: proc() { // Fill (center-crop) uv_f, sampler_f, inner_f := draw.fit_params(.Fill, {COL2, ROW3_Y, FIT_SIZE, FIT_SIZE}, stripe_texture) - draw.rectangle(base_layer, {COL2, ROW3_Y, FIT_SIZE, FIT_SIZE}, {60, 60, 60, 255}) + draw.rectangle(base_layer, {COL2, ROW3_Y, FIT_SIZE, FIT_SIZE}, draw.Color{60, 60, 60, 255}) draw.rectangle_texture(base_layer, inner_f, stripe_texture, uv_rect = uv_f, sampler = sampler_f) draw.text( base_layer, @@ -239,7 +239,7 @@ textures :: proc() { // Fit (letterbox) uv_ft, sampler_ft, inner_ft := draw.fit_params(.Fit, {COL3, ROW3_Y, FIT_SIZE, FIT_SIZE}, stripe_texture) - draw.rectangle(base_layer, {COL3, ROW3_Y, FIT_SIZE, FIT_SIZE}, {60, 60, 60, 255}) // visible margin bg + draw.rectangle(base_layer, {COL3, ROW3_Y, FIT_SIZE, FIT_SIZE}, draw.Color{60, 60, 60, 255}) // visible margin bg draw.rectangle_texture(base_layer, inner_ft, stripe_texture, uv_rect = uv_ft, sampler = sampler_ft) draw.text( base_layer, @@ -251,12 +251,12 @@ textures :: proc() { ) // Per-corner radii - draw.rectangle_texture_corners( + draw.rectangle_texture( base_layer, {COL4, ROW3_Y, FIT_SIZE, FIT_SIZE}, - {20, 0, 20, 0}, checker_texture, sampler = .Nearest_Clamp, + radii = {20, 0, 20, 0}, ) draw.text( base_layer, diff --git a/draw/pipeline_2d_base.odin b/draw/pipeline_2d_base.odin index a69facb..2d5923b 100644 --- a/draw/pipeline_2d_base.odin +++ b/draw/pipeline_2d_base.odin @@ -5,8 +5,13 @@ 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: [2]f32, + position: Vec2, uv: [2]f32, color: Color, } @@ -23,99 +28,127 @@ TextBatch :: struct { // ----- 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 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, + Solid = 0, // tessellated path (mode marker; not a real SDF kind) RRect = 1, - Circle = 2, + NGon = 2, Ellipse = 3, - Segment = 4, - Ring_Arc = 5, - NGon = 6, + Ring_Arc = 4, } Shape_Flag :: enum u8 { - Stroke, - Textured, + Textured, // bit 0: sample texture using uv.uv_rect (mutually exclusive with Gradient) + Gradient, // bit 1: 2-color gradient using uv.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 uv.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, - soft_px: f32, - stroke_px: f32, -} - -Circle_Params :: struct { - radius: f32, - soft_px: f32, - stroke_px: f32, - _: [5]f32, -} - -Ellipse_Params :: struct { - radii: [2]f32, - soft_px: f32, - stroke_px: f32, - _: [4]f32, -} - -Segment_Params :: struct { - a: [2]f32, - b: [2]f32, - width: f32, - soft_px: f32, - _: [2]f32, -} - -Ring_Arc_Params :: struct { - inner_radius: f32, - outer_radius: f32, - start_rad: f32, - end_rad: f32, - soft_px: f32, - _: [3]f32, + 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, - rotation: f32, - sides: f32, - soft_px: f32, - stroke_px: f32, - _: [3]f32, + 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, - circle: Circle_Params, - ellipse: Ellipse_Params, - segment: Segment_Params, - ring_arc: Ring_Arc_Params, ngon: NGon_Params, + ellipse: Ellipse_Params, + ring_arc: Ring_Arc_Params, raw: [8]f32, } - #assert(size_of(Shape_Params) == 32) -// GPU layout: 64 bytes, std430-compatible. The shader declares this as a storage buffer struct. +// GPU-side storage for 2-color gradient parameters and/or outline parameters. +// Packed into 16 bytes to alias with uv_rect in the Uv_Or_Effects raw union. +// 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) + +// Uv_Or_Effects aliases the final 16 bytes of a Primitive. When .Textured is set, +// uv_rect holds texture-atlas coordinates. When .Gradient or .Outline is set, +// effects holds 2-color gradient parameters and/or outline parameters. +// Textured and Gradient are mutually exclusive; if both are set, Gradient takes precedence. +Uv_Or_Effects :: struct #raw_union { + uv_rect: [4]f32, // u_min, v_min, u_max, v_max (default {0,0,1,1}) + effects: Gradient_Outline, // gradient + outline parameters +} + +// GPU layout: 80 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. Primitive :: struct { - bounds: [4]f32, // 0: min_x, min_y, max_x, max_y (world-space, pre-DPI) - color: Color, // 16: u8x4, unpacked in shader via unpackUnorm4x8 - kind_flags: u32, // 20: (kind as u32) | (flags as u32 << 8) - rotation: f32, // 24: shader self-rotation in radians (used by RRect, Ellipse) - _pad: f32, // 28: alignment to vec4 boundary - params: Shape_Params, // 32: two vec4s of shape params - uv_rect: [4]f32, // 64: u_min, v_min, u_max, v_max (default {0,0,1,1}) + 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: Uv_Or_Effects, // 64: texture coords or gradient/outline parameters } #assert(size_of(Primitive) == 80) +// Pack shape kind and flags into the 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, @@ -208,19 +241,23 @@ create_pipeline_2d_base :: proc( 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 = .SRC_ALPHA, + src_color_blendfactor = .ONE, dst_color_blendfactor = .ONE_MINUS_SRC_ALPHA, color_blend_op = .ADD, - src_alpha_blendfactor = .SRC_ALPHA, + 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, + num_color_targets = 1, }, vertex_input_state = sdl.GPUVertexInputState { vertex_buffer_descriptions = &sdl.GPUVertexBufferDescription { @@ -300,7 +337,7 @@ create_pipeline_2d_base :: proc( } // Upload white pixel and unit quad data in a single command buffer - white_pixel := [4]u8{255, 255, 255, 255} + white_pixel := Color{255, 255, 255, 255} white_transfer_buf := sdl.CreateGPUTransferBuffer( device, sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(white_pixel)}, @@ -578,7 +615,7 @@ draw_layer :: proc( for &batch in GLOB.tmp_sub_batches[scissor.sub_batch_start:][:scissor.sub_batch_len] { switch batch.kind { - case .Shapes: + case .Tessellated: if current_mode != .Tessellated { push_globals(cmd_buffer, width, height, .Tessellated) current_mode = .Tessellated diff --git a/draw/shaders/generated/base_2d.frag.metal b/draw/shaders/generated/base_2d.frag.metal index 7a4b934..e3e07d6 100644 --- a/draw/shaders/generated/base_2d.frag.metal +++ b/draw/shaders/generated/base_2d.frag.metal @@ -23,293 +23,225 @@ struct main0_in float2 f_local_or_uv [[user(locn1)]]; float4 f_params [[user(locn2)]]; float4 f_params2 [[user(locn3)]]; - uint f_kind_flags [[user(locn4)]]; - float f_rotation [[user(locn5), flat]]; - float4 f_uv_rect [[user(locn6), flat]]; + uint f_flags [[user(locn4)]]; + uint f_rotation_sc [[user(locn5)]]; + uint4 f_uv_or_effects [[user(locn6)]]; }; static inline __attribute__((always_inline)) -float2 apply_rotation(thread const float2& p, thread const float& angle) +float sdRoundedBox(thread const float2& p, thread const float2& b, thread const float4& r) { - float cr = cos(-angle); - float sr = sin(-angle); - return float2x2(float2(cr, sr), float2(-sr, cr)) * p; -} - -static inline __attribute__((always_inline)) -float sdRoundedBox(thread const float2& p, thread const float2& b, thread float4& r) -{ - float2 _61; + float2 _48; if (p.x > 0.0) { - _61 = r.xy; + _48 = r.xy; } else { - _61 = r.zw; + _48 = r.zw; } - r.x = _61.x; - r.y = _61.y; - float _78; + float2 rxy = _48; + float _62; if (p.y > 0.0) { - _78 = r.x; + _62 = rxy.x; } else { - _78 = r.y; + _62 = rxy.y; } - r.x = _78; - float2 q = (abs(p) - b) + float2(r.x); - return (fast::min(fast::max(q.x, q.y), 0.0) + length(fast::max(q, float2(0.0)))) - r.x; -} - -static inline __attribute__((always_inline)) -float sdf_stroke(thread const float& d, thread const float& stroke_width) -{ - return abs(d) - (stroke_width * 0.5); -} - -static inline __attribute__((always_inline)) -float sdf_alpha(thread const float& d, thread const float& soft) -{ - return 1.0 - smoothstep(-soft, soft, d); -} - -static inline __attribute__((always_inline)) -float sdCircle(thread const float2& p, thread const float& r) -{ - return length(p) - r; -} - -static inline __attribute__((always_inline)) -float sdEllipse(thread float2& p, thread float2& ab) -{ - p = abs(p); - if (p.x > p.y) + float rr = _62; + float2 q = abs(p) - b; + if (rr == 0.0) { - p = p.yx; - ab = ab.yx; + return fast::max(q.x, q.y); } - float l = (ab.y * ab.y) - (ab.x * ab.x); - float m = (ab.x * p.x) / l; - float m2 = m * m; - float n = (ab.y * p.y) / l; - float n2 = n * n; - float c = ((m2 + n2) - 1.0) / 3.0; - float c3 = (c * c) * c; - float q = c3 + ((m2 * n2) * 2.0); - float d = c3 + (m2 * n2); - float g = m + (m * n2); - float co; - if (d < 0.0) - { - float h = acos(q / c3) / 3.0; - float s = cos(h); - float t = sin(h) * 1.73205077648162841796875; - float rx = sqrt(((-c) * ((s + t) + 2.0)) + m2); - float ry = sqrt(((-c) * ((s - t) + 2.0)) + m2); - co = (((ry + (sign(l) * rx)) + (abs(g) / (rx * ry))) - m) / 2.0; - } - else - { - float h_1 = ((2.0 * m) * n) * sqrt(d); - float s_1 = sign(q + h_1) * powr(abs(q + h_1), 0.3333333432674407958984375); - float u = sign(q - h_1) * powr(abs(q - h_1), 0.3333333432674407958984375); - float rx_1 = (((-s_1) - u) - (c * 4.0)) + (2.0 * m2); - float ry_1 = (s_1 - u) * 1.73205077648162841796875; - float rm = sqrt((rx_1 * rx_1) + (ry_1 * ry_1)); - co = (((ry_1 / sqrt(rm - rx_1)) + ((2.0 * g) / rm)) - m) / 2.0; - } - float2 r = ab * float2(co, sqrt(1.0 - (co * co))); - return length(r - p) * sign(p.y - r.y); + q += float2(rr); + return (fast::min(fast::max(q.x, q.y), 0.0) + length(fast::max(q, float2(0.0)))) - rr; } static inline __attribute__((always_inline)) -float sdSegment(thread const float2& p, thread const float2& a, thread const float2& b) +float sdRegularPolygon(thread const float2& p, thread const float& r, thread const float& n) { - float2 pa = p - a; - float2 ba = b - a; - float h = fast::clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); - return length(pa - (ba * h)); + float an = 3.1415927410125732421875 / n; + float bn = mod(precise::atan2(p.y, p.x), 2.0 * an) - an; + return (length(p) * cos(bn)) - r; +} + +static inline __attribute__((always_inline)) +float sdEllipseApprox(thread const float2& p, thread const float2& ab) +{ + float k0 = length(p / ab); + float k1 = length(p / (ab * ab)); + return (k0 * (k0 - 1.0)) / k1; +} + +static inline __attribute__((always_inline)) +float4 gradient_2color(thread const float4& start_color, thread const float4& end_color, thread const float& t) +{ + return mix(start_color, end_color, float4(fast::clamp(t, 0.0, 1.0))); +} + +static inline __attribute__((always_inline)) +float sdf_alpha(thread const float& d, thread const float& h) +{ + return 1.0 - smoothstep(-h, h, d); } fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[texture(0)]], sampler texSmplr [[sampler(0)]]) { main0_out out = {}; - uint kind = in.f_kind_flags & 255u; - uint flags = (in.f_kind_flags >> 8u) & 255u; + uint kind = in.f_flags & 255u; + uint flags = (in.f_flags >> 8u) & 255u; if (kind == 0u) { - out.out_color = in.f_color * tex.sample(texSmplr, in.f_local_or_uv); + float4 t = tex.sample(texSmplr, in.f_local_or_uv); + float _195 = t.w; + float4 _197 = t; + float3 _199 = _197.xyz * _195; + t.x = _199.x; + t.y = _199.y; + t.z = _199.z; + out.out_color = in.f_color * t; return out; } float d = 1000000015047466219876688855040.0; - float soft = 1.0; + float h = 0.5; + float2 half_size = in.f_params.xy; + float2 p_local = in.f_local_or_uv; + if ((flags & 16u) != 0u) + { + float2 sc = float2(as_type(in.f_rotation_sc)); + p_local = float2((sc.y * p_local.x) + (sc.x * p_local.y), ((-sc.x) * p_local.x) + (sc.y * p_local.y)); + } if (kind == 1u) { - float2 b = in.f_params.xy; - float4 r = float4(in.f_params.zw, in.f_params2.xy); - soft = fast::max(in.f_params2.z, 1.0); - float stroke_px = in.f_params2.w; - float2 p_local = in.f_local_or_uv; - if (in.f_rotation != 0.0) - { - float2 param = p_local; - float param_1 = in.f_rotation; - p_local = apply_rotation(param, param_1); - } - float2 param_2 = p_local; - float2 param_3 = b; - float4 param_4 = r; - float _491 = sdRoundedBox(param_2, param_3, param_4); - d = _491; - if ((flags & 1u) != 0u) - { - float param_5 = d; - float param_6 = stroke_px; - d = sdf_stroke(param_5, param_6); - } - float4 shape_color = in.f_color; - if ((flags & 2u) != 0u) - { - float2 p_for_uv = in.f_local_or_uv; - if (in.f_rotation != 0.0) - { - float2 param_7 = p_for_uv; - float param_8 = in.f_rotation; - p_for_uv = apply_rotation(param_7, param_8); - } - float2 local_uv = ((p_for_uv / b) * 0.5) + float2(0.5); - float2 uv = mix(in.f_uv_rect.xy, in.f_uv_rect.zw, local_uv); - shape_color *= tex.sample(texSmplr, uv); - } - float param_9 = d; - float param_10 = soft; - float alpha = sdf_alpha(param_9, param_10); - out.out_color = float4(shape_color.xyz, shape_color.w * alpha); - return out; + float4 corner_radii = float4(in.f_params.zw, in.f_params2.xy); + h = in.f_params2.z; + float2 param = p_local; + float2 param_1 = half_size; + float4 param_2 = corner_radii; + d = sdRoundedBox(param, param_1, param_2); } else { if (kind == 2u) { float radius = in.f_params.x; - soft = fast::max(in.f_params.y, 1.0); - float stroke_px_1 = in.f_params.z; - float2 param_11 = in.f_local_or_uv; - float param_12 = radius; - d = sdCircle(param_11, param_12); - if ((flags & 1u) != 0u) - { - float param_13 = d; - float param_14 = stroke_px_1; - d = sdf_stroke(param_13, param_14); - } + float sides = in.f_params.y; + h = in.f_params.z; + float2 param_3 = p_local; + float param_4 = radius; + float param_5 = sides; + d = sdRegularPolygon(param_3, param_4, param_5); + half_size = float2(radius); } else { if (kind == 3u) { float2 ab = in.f_params.xy; - soft = fast::max(in.f_params.z, 1.0); - float stroke_px_2 = in.f_params.w; - float2 p_local_1 = in.f_local_or_uv; - if (in.f_rotation != 0.0) - { - float2 param_15 = p_local_1; - float param_16 = in.f_rotation; - p_local_1 = apply_rotation(param_15, param_16); - } - float2 param_17 = p_local_1; - float2 param_18 = ab; - float _616 = sdEllipse(param_17, param_18); - d = _616; - if ((flags & 1u) != 0u) - { - float param_19 = d; - float param_20 = stroke_px_2; - d = sdf_stroke(param_19, param_20); - } + h = in.f_params.z; + float2 param_6 = p_local; + float2 param_7 = ab; + d = sdEllipseApprox(param_6, param_7); + half_size = ab; } else { if (kind == 4u) { - float2 a = in.f_params.xy; - float2 b_1 = in.f_params.zw; - float width = in.f_params2.x; - soft = fast::max(in.f_params2.y, 1.0); - float2 param_21 = in.f_local_or_uv; - float2 param_22 = a; - float2 param_23 = b_1; - d = sdSegment(param_21, param_22, param_23) - (width * 0.5); - } - else - { - if (kind == 5u) + float inner = in.f_params.x; + float outer = in.f_params.y; + float2 n_start = in.f_params.zw; + float2 n_end = in.f_params2.xy; + uint arc_bits = (flags >> 5u) & 3u; + h = in.f_params2.z; + float r = length(p_local); + d = fast::max(inner - r, r - outer); + if (arc_bits != 0u) { - float inner = in.f_params.x; - float outer = in.f_params.y; - float start_rad = in.f_params.z; - float end_rad = in.f_params.w; - soft = fast::max(in.f_params2.x, 1.0); - float r_1 = length(in.f_local_or_uv); - float d_ring = fast::max(inner - r_1, r_1 - outer); - float angle = precise::atan2(in.f_local_or_uv.y, in.f_local_or_uv.x); - if (angle < 0.0) + float d_start = dot(p_local, n_start); + float d_end = dot(p_local, n_end); + float _372; + if (arc_bits == 1u) { - angle += 6.283185482025146484375; - } - float ang_start = mod(start_rad, 6.283185482025146484375); - float ang_end = mod(end_rad, 6.283185482025146484375); - float _710; - if (ang_end > ang_start) - { - _710 = float((angle >= ang_start) && (angle <= ang_end)); + _372 = fast::max(d_start, d_end); } else { - _710 = float((angle >= ang_start) || (angle <= ang_end)); - } - float in_arc = _710; - if (abs(ang_end - ang_start) >= 6.282185077667236328125) - { - in_arc = 1.0; - } - d = (in_arc > 0.5) ? d_ring : 1000000015047466219876688855040.0; - } - else - { - if (kind == 6u) - { - float radius_1 = in.f_params.x; - float rotation = in.f_params.y; - float sides = in.f_params.z; - soft = fast::max(in.f_params.w, 1.0); - float stroke_px_3 = in.f_params2.x; - float2 p = in.f_local_or_uv; - float c = cos(rotation); - float s = sin(rotation); - p = float2x2(float2(c, -s), float2(s, c)) * p; - float an = 3.1415927410125732421875 / sides; - float bn = mod(precise::atan2(p.y, p.x), 2.0 * an) - an; - d = (length(p) * cos(bn)) - radius_1; - if ((flags & 1u) != 0u) - { - float param_24 = d; - float param_25 = stroke_px_3; - d = sdf_stroke(param_24, param_25); - } + _372 = fast::min(d_start, d_end); } + float d_wedge = _372; + d = fast::max(d, d_wedge); } + half_size = float2(outer); } } } } - float param_26 = d; - float param_27 = soft; - float alpha_1 = sdf_alpha(param_26, param_27); - out.out_color = float4(in.f_color.xyz, in.f_color.w * alpha_1); + float grad_magnitude = fast::max(fwidth(d), 9.9999999747524270787835121154785e-07); + d /= grad_magnitude; + h /= grad_magnitude; + float4 shape_color; + if ((flags & 2u) != 0u) + { + float4 gradient_start = in.f_color; + float4 gradient_end = unpack_unorm4x8_to_float(in.f_uv_or_effects.x); + if ((flags & 4u) != 0u) + { + float t_1 = length(p_local / half_size); + float4 param_8 = gradient_start; + float4 param_9 = gradient_end; + float param_10 = t_1; + shape_color = gradient_2color(param_8, param_9, param_10); + } + else + { + float2 direction = float2(as_type(in.f_uv_or_effects.z)); + float t_2 = (dot(p_local / half_size, direction) * 0.5) + 0.5; + float4 param_11 = gradient_start; + float4 param_12 = gradient_end; + float param_13 = t_2; + shape_color = gradient_2color(param_11, param_12, param_13); + } + } + else + { + if ((flags & 1u) != 0u) + { + float4 uv_rect = as_type(in.f_uv_or_effects); + float2 local_uv = ((p_local / half_size) * 0.5) + float2(0.5); + float2 uv = mix(uv_rect.xy, uv_rect.zw, local_uv); + shape_color = in.f_color * tex.sample(texSmplr, uv); + } + else + { + shape_color = in.f_color; + } + } + if ((flags & 8u) != 0u) + { + float4 ol_color = unpack_unorm4x8_to_float(in.f_uv_or_effects.y); + float ol_width = float2(as_type(in.f_uv_or_effects.w)).x / grad_magnitude; + float param_14 = d; + float param_15 = h; + float fill_cov = sdf_alpha(param_14, param_15); + float param_16 = d - ol_width; + float param_17 = h; + float total_cov = sdf_alpha(param_16, param_17); + float outline_cov = fast::max(total_cov - fill_cov, 0.0); + float3 rgb_pm = ((shape_color.xyz * shape_color.w) * fill_cov) + ((ol_color.xyz * ol_color.w) * outline_cov); + float alpha_pm = (shape_color.w * fill_cov) + (ol_color.w * outline_cov); + out.out_color = float4(rgb_pm, alpha_pm); + } + else + { + float param_18 = d; + float param_19 = h; + float alpha = sdf_alpha(param_18, param_19); + out.out_color = float4((shape_color.xyz * shape_color.w) * alpha, shape_color.w * alpha); + } return out; } + diff --git a/draw/shaders/generated/base_2d.frag.spv b/draw/shaders/generated/base_2d.frag.spv index c1411b3..aee3760 100644 Binary files a/draw/shaders/generated/base_2d.frag.spv and b/draw/shaders/generated/base_2d.frag.spv differ diff --git a/draw/shaders/generated/base_2d.vert.metal b/draw/shaders/generated/base_2d.vert.metal index 75fa3b4..d7d7234 100644 --- a/draw/shaders/generated/base_2d.vert.metal +++ b/draw/shaders/generated/base_2d.vert.metal @@ -14,24 +14,24 @@ struct Primitive { float4 bounds; uint color; - uint kind_flags; - float rotation; + uint flags; + uint rotation_sc; float _pad; float4 params; float4 params2; - float4 uv_rect; + uint4 uv_or_effects; }; struct Primitive_1 { float4 bounds; uint color; - uint kind_flags; - float rotation; + uint flags; + uint rotation_sc; float _pad; float4 params; float4 params2; - float4 uv_rect; + uint4 uv_or_effects; }; struct Primitives @@ -45,9 +45,9 @@ struct main0_out float2 f_local_or_uv [[user(locn1)]]; float4 f_params [[user(locn2)]]; float4 f_params2 [[user(locn3)]]; - uint f_kind_flags [[user(locn4)]]; - float f_rotation [[user(locn5)]]; - float4 f_uv_rect [[user(locn6)]]; + uint f_flags [[user(locn4)]]; + uint f_rotation_sc [[user(locn5)]]; + uint4 f_uv_or_effects [[user(locn6)]]; float4 gl_Position [[position]]; }; @@ -58,7 +58,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 Primitives& _74 [[buffer(1)]], uint gl_InstanceIndex [[instance_id]]) +vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer(0)]], const device Primitives& _75 [[buffer(1)]], uint gl_InstanceIndex [[instance_id]]) { main0_out out = {}; if (_12.mode == 0u) @@ -67,22 +67,22 @@ vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer out.f_local_or_uv = in.v_uv; out.f_params = float4(0.0); out.f_params2 = float4(0.0); - out.f_kind_flags = 0u; - out.f_rotation = 0.0; - out.f_uv_rect = float4(0.0, 0.0, 1.0, 1.0); + out.f_flags = 0u; + out.f_rotation_sc = 0u; + out.f_uv_or_effects = uint4(0u); out.gl_Position = _12.projection * float4(in.v_position * _12.dpi_scale, 0.0, 1.0); } else { Primitive p; - p.bounds = _74.primitives[int(gl_InstanceIndex)].bounds; - p.color = _74.primitives[int(gl_InstanceIndex)].color; - p.kind_flags = _74.primitives[int(gl_InstanceIndex)].kind_flags; - p.rotation = _74.primitives[int(gl_InstanceIndex)].rotation; - p._pad = _74.primitives[int(gl_InstanceIndex)]._pad; - p.params = _74.primitives[int(gl_InstanceIndex)].params; - p.params2 = _74.primitives[int(gl_InstanceIndex)].params2; - p.uv_rect = _74.primitives[int(gl_InstanceIndex)].uv_rect; + p.bounds = _75.primitives[int(gl_InstanceIndex)].bounds; + p.color = _75.primitives[int(gl_InstanceIndex)].color; + p.flags = _75.primitives[int(gl_InstanceIndex)].flags; + p.rotation_sc = _75.primitives[int(gl_InstanceIndex)].rotation_sc; + p._pad = _75.primitives[int(gl_InstanceIndex)]._pad; + p.params = _75.primitives[int(gl_InstanceIndex)].params; + p.params2 = _75.primitives[int(gl_InstanceIndex)].params2; + p.uv_or_effects = _75.primitives[int(gl_InstanceIndex)].uv_or_effects; float2 corner = in.v_position; float2 world_pos = mix(p.bounds.xy, p.bounds.zw, corner); float2 center = (p.bounds.xy + p.bounds.zw) * 0.5; @@ -90,10 +90,11 @@ vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer out.f_local_or_uv = (world_pos - center) * _12.dpi_scale; out.f_params = p.params; out.f_params2 = p.params2; - out.f_kind_flags = p.kind_flags; - out.f_rotation = p.rotation; - out.f_uv_rect = p.uv_rect; + out.f_flags = p.flags; + out.f_rotation_sc = p.rotation_sc; + out.f_uv_or_effects = p.uv_or_effects; out.gl_Position = _12.projection * float4(world_pos * _12.dpi_scale, 0.0, 1.0); } return out; } + diff --git a/draw/shaders/generated/base_2d.vert.spv b/draw/shaders/generated/base_2d.vert.spv index ca08cba..5cc30c5 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/base_2d.frag b/draw/shaders/source/base_2d.frag index cf301d5..0ebb043 100644 --- a/draw/shaders/source/base_2d.frag +++ b/draw/shaders/source/base_2d.frag @@ -1,13 +1,13 @@ #version 450 core // --- Inputs from vertex shader --- -layout(location = 0) in vec4 f_color; +layout(location = 0) in mediump vec4 f_color; layout(location = 1) in vec2 f_local_or_uv; layout(location = 2) in vec4 f_params; layout(location = 3) in vec4 f_params2; -layout(location = 4) flat in uint f_kind_flags; -layout(location = 5) flat in float f_rotation; -layout(location = 6) flat in vec4 f_uv_rect; +layout(location = 4) flat in uint f_flags; +layout(location = 5) flat in uint f_rotation_sc; +layout(location = 6) flat in uvec4 f_uv_or_effects; // --- Output --- layout(location = 0) out vec4 out_color; @@ -20,77 +20,43 @@ layout(set = 2, binding = 0) uniform sampler2D tex; // All operate in physical pixel space — no dpi_scale needed here. // --------------------------------------------------------------------------- -const float PI = 3.14159265358979; - -float sdCircle(vec2 p, float r) { - return length(p) - r; -} - float sdRoundedBox(vec2 p, vec2 b, vec4 r) { - r.xy = (p.x > 0.0) ? r.xy : r.zw; - r.x = (p.y > 0.0) ? r.x : r.y; - vec2 q = abs(p) - b + r.x; - return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - r.x; -} - -float sdSegment(vec2 p, vec2 a, vec2 b) { - vec2 pa = p - a, ba = b - a; - float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); - return length(pa - ba * h); -} - -float sdEllipse(vec2 p, vec2 ab) { - p = abs(p); - if (p.x > p.y) { - p = p.yx; - ab = ab.yx; + vec2 rxy = (p.x > 0.0) ? r.xy : r.zw; + float rr = (p.y > 0.0) ? rxy.x : rxy.y; + vec2 q = abs(p) - b; + if (rr == 0.0) { + return max(q.x, q.y); } - float l = ab.y * ab.y - ab.x * ab.x; - float m = ab.x * p.x / l; - float m2 = m * m; - float n = ab.y * p.y / l; - float n2 = n * n; - float c = (m2 + n2 - 1.0) / 3.0; - float c3 = c * c * c; - float q = c3 + m2 * n2 * 2.0; - float d = c3 + m2 * n2; - float g = m + m * n2; - float co; - if (d < 0.0) { - float h = acos(q / c3) / 3.0; - float s = cos(h); - float t = sin(h) * sqrt(3.0); - float rx = sqrt(-c * (s + t + 2.0) + m2); - float ry = sqrt(-c * (s - t + 2.0) + m2); - co = (ry + sign(l) * rx + abs(g) / (rx * ry) - m) / 2.0; - } else { - float h = 2.0 * m * n * sqrt(d); - float s = sign(q + h) * pow(abs(q + h), 1.0 / 3.0); - float u = sign(q - h) * pow(abs(q - h), 1.0 / 3.0); - float rx = -s - u - c * 4.0 + 2.0 * m2; - float ry = (s - u) * sqrt(3.0); - float rm = sqrt(rx * rx + ry * ry); - co = (ry / sqrt(rm - rx) + 2.0 * g / rm - m) / 2.0; - } - vec2 r = ab * vec2(co, sqrt(1.0 - co * co)); - return length(r - p) * sign(p.y - r.y); + q += rr; + return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - rr; } -float sdf_alpha(float d, float soft) { - return 1.0 - smoothstep(-soft, soft, d); +// Approximate ellipse SDF — fast, suitable for UI, NOT a true Euclidean distance. +float sdEllipseApprox(vec2 p, vec2 ab) { + float k0 = length(p / ab); + float k1 = length(p / (ab * ab)); + return k0 * (k0 - 1.0) / k1; } -float sdf_stroke(float d, float stroke_width) { - return abs(d) - stroke_width * 0.5; +// Regular N-gon SDF (Inigo Quilez). +float sdRegularPolygon(vec2 p, float r, float n) { + float an = 3.141592653589793 / n; + float bn = mod(atan(p.y, p.x), 2.0 * an) - an; + return length(p) * cos(bn) - r; } -// Rotate a 2D point by the negative of the given angle (inverse rotation). -// Used to rotate the sampling frame opposite to the shape's rotation so that -// the SDF evaluates correctly for the rotated shape. -vec2 apply_rotation(vec2 p, float angle) { - float cr = cos(-angle); - float sr = sin(-angle); - return mat2(cr, sr, -sr, cr) * p; +// Coverage from SDF distance using half-feather width (feather_px * 0.5, pre-computed on CPU). +// Produces a symmetric transition centered on d=0: smoothstep(-h, h, d). +float sdf_alpha(float d, float h) { + return 1.0 - smoothstep(-h, h, d); +} + +// --------------------------------------------------------------------------- +// Gradient helpers +// --------------------------------------------------------------------------- + +mediump vec4 gradient_2color(mediump vec4 start_color, mediump vec4 end_color, mediump float t) { + return mix(start_color, end_color, clamp(t, 0.0, 1.0)); } // --------------------------------------------------------------------------- @@ -98,131 +64,137 @@ vec2 apply_rotation(vec2 p, float angle) { // --------------------------------------------------------------------------- void main() { - uint kind = f_kind_flags & 0xFFu; - uint flags = (f_kind_flags >> 8u) & 0xFFu; + uint kind = f_flags & 0xFFu; + uint flags = (f_flags >> 8u) & 0xFFu; - // ----------------------------------------------------------------------- - // Kind 0: Tessellated path. Texture multiply for text atlas, - // white pixel for solid shapes. - // ----------------------------------------------------------------------- + // Kind 0: Tessellated path — vertex colors arrive premultiplied from CPU. + // Texture samples are straight-alpha (SDL_ttf glyph atlas: rgb=1, a=coverage; + // or the 1x1 white texture: rgba=1). Convert to premultiplied form so the + // blend state (ONE, ONE_MINUS_SRC_ALPHA) composites correctly. if (kind == 0u) { - out_color = f_color * texture(tex, f_local_or_uv); + vec4 t = texture(tex, f_local_or_uv); + t.rgb *= t.a; + out_color = f_color * t; return; } - // ----------------------------------------------------------------------- - // SDF path. f_local_or_uv = shape-centered position in physical pixels. - // All dimensional params are already in physical pixels (CPU pre-scaled). - // ----------------------------------------------------------------------- + // SDF path — dispatch on kind float d = 1e30; - float soft = 1.0; + float h = 0.5; // half-feather width; overwritten per shape kind + vec2 half_size = f_params.xy; // used by RRect and as reference size for gradients + + vec2 p_local = f_local_or_uv; + + // Apply inverse rotation using pre-computed sin/cos (no per-pixel trig). + // .Rotated flag = bit 4 = 16u + if ((flags & 16u) != 0u) { + vec2 sc = unpackHalf2x16(f_rotation_sc); // .x = sin(angle), .y = cos(angle) + // Inverse rotation matrix R(-angle) = [[cos, sin], [-sin, cos]] + p_local = vec2(sc.y * p_local.x + sc.x * p_local.y, + -sc.x * p_local.x + sc.y * p_local.y); + } if (kind == 1u) { - // RRect: rounded box - vec2 b = f_params.xy; // half_size (phys px) - vec4 r = vec4(f_params.zw, f_params2.xy); // corner radii: tr, br, tl, bl - soft = max(f_params2.z, 1.0); - float stroke_px = f_params2.w; - - vec2 p_local = f_local_or_uv; - if (f_rotation != 0.0) { - p_local = apply_rotation(p_local, f_rotation); - } - - d = sdRoundedBox(p_local, b, r); - if ((flags & 1u) != 0u) d = sdf_stroke(d, stroke_px); - - // Texture sampling for textured SDF primitives - vec4 shape_color = f_color; - if ((flags & 2u) != 0u) { - // Compute UV from local position and half_size - vec2 p_for_uv = f_local_or_uv; - if (f_rotation != 0.0) { - p_for_uv = apply_rotation(p_for_uv, f_rotation); - } - vec2 local_uv = p_for_uv / b * 0.5 + 0.5; - vec2 uv = mix(f_uv_rect.xy, f_uv_rect.zw, local_uv); - shape_color *= texture(tex, uv); - } - - float alpha = sdf_alpha(d, soft); - out_color = vec4(shape_color.rgb, shape_color.a * alpha); - return; + // RRect — half_feather in params2.z + vec4 corner_radii = vec4(f_params.zw, f_params2.xy); + h = f_params2.z; + d = sdRoundedBox(p_local, half_size, corner_radii); } else if (kind == 2u) { - // Circle — rotationally symmetric, no rotation needed + // NGon — half_feather in params.z float radius = f_params.x; - soft = max(f_params.y, 1.0); - float stroke_px = f_params.z; - - d = sdCircle(f_local_or_uv, radius); - if ((flags & 1u) != 0u) d = sdf_stroke(d, stroke_px); + float sides = f_params.y; + h = f_params.z; + d = sdRegularPolygon(p_local, radius, sides); + half_size = vec2(radius); // for gradient UV computation } else if (kind == 3u) { - // Ellipse + // Ellipse — half_feather in params.z vec2 ab = f_params.xy; - soft = max(f_params.z, 1.0); - float stroke_px = f_params.w; - - vec2 p_local = f_local_or_uv; - if (f_rotation != 0.0) { - p_local = apply_rotation(p_local, f_rotation); - } - - d = sdEllipse(p_local, ab); - if ((flags & 1u) != 0u) d = sdf_stroke(d, stroke_px); + h = f_params.z; + d = sdEllipseApprox(p_local, ab); + half_size = ab; // for gradient UV computation } else if (kind == 4u) { - // Segment (capsule line) — no rotation (excluded) - vec2 a = f_params.xy; // already in local physical pixels - vec2 b = f_params.zw; - float width = f_params2.x; - soft = max(f_params2.y, 1.0); - - d = sdSegment(f_local_or_uv, a, b) - width * 0.5; - } - else if (kind == 5u) { - // Ring / Arc — rotation handled by CPU angle offset, no shader rotation + // Ring_Arc — half_feather in params2.z + // Arc mode from flag bits 5-6: 0 = full, 1 = narrow (≤π), 2 = wide (>π) float inner = f_params.x; float outer = f_params.y; - float start_rad = f_params.z; - float end_rad = f_params.w; - soft = max(f_params2.x, 1.0); + vec2 n_start = f_params.zw; + vec2 n_end = f_params2.xy; + uint arc_bits = (flags >> 5u) & 3u; - float r = length(f_local_or_uv); - float d_ring = max(inner - r, r - outer); + h = f_params2.z; - // Angular clip - float angle = atan(f_local_or_uv.y, f_local_or_uv.x); - if (angle < 0.0) angle += 2.0 * PI; - float ang_start = mod(start_rad, 2.0 * PI); - float ang_end = mod(end_rad, 2.0 * PI); + float r = length(p_local); + d = max(inner - r, r - outer); - float in_arc = (ang_end > ang_start) - ? ((angle >= ang_start && angle <= ang_end) ? 1.0 : 0.0) : ((angle >= ang_start || angle <= ang_end) ? 1.0 : 0.0); - if (abs(ang_end - ang_start) >= 2.0 * PI - 0.001) in_arc = 1.0; + if (arc_bits != 0u) { + float d_start = dot(p_local, n_start); + float d_end = dot(p_local, n_end); + float d_wedge = (arc_bits == 1u) + ? max(d_start, d_end) // arc ≤ π: intersect half-planes + : min(d_start, d_end); // arc > π: union half-planes + d = max(d, d_wedge); + } - d = in_arc > 0.5 ? d_ring : 1e30; - } - else if (kind == 6u) { - // Regular N-gon — has its own rotation in params, no Primitive.rotation used - float radius = f_params.x; - float rotation = f_params.y; - float sides = f_params.z; - soft = max(f_params.w, 1.0); - float stroke_px = f_params2.x; - - vec2 p = f_local_or_uv; - float c = cos(rotation), s = sin(rotation); - p = mat2(c, -s, s, c) * p; - - float an = PI / sides; - float bn = mod(atan(p.y, p.x), 2.0 * an) - an; - d = length(p) * cos(bn) - radius; - - if ((flags & 1u) != 0u) d = sdf_stroke(d, stroke_px); + half_size = vec2(outer); // for gradient UV computation } - float alpha = sdf_alpha(d, soft); - out_color = vec4(f_color.rgb, f_color.a * alpha); + // --- fwidth-based normalization for correct AA and stroke width --- + float grad_magnitude = max(fwidth(d), 1e-6); + d = d / grad_magnitude; + h = h / grad_magnitude; + + // --- Determine shape color based on flags --- + mediump vec4 shape_color; + if ((flags & 2u) != 0u) { + // Gradient active (bit 1) + mediump vec4 gradient_start = f_color; + mediump vec4 gradient_end = unpackUnorm4x8(f_uv_or_effects.x); + + if ((flags & 4u) != 0u) { + // Radial gradient (bit 2): t from distance to center + mediump float t = length(p_local / half_size); + shape_color = gradient_2color(gradient_start, gradient_end, t); + } else { + // Linear gradient: direction pre-computed on CPU as (cos, sin) f16 pair + vec2 direction = unpackHalf2x16(f_uv_or_effects.z); + mediump float t = dot(p_local / half_size, direction) * 0.5 + 0.5; + shape_color = gradient_2color(gradient_start, gradient_end, t); + } + } else if ((flags & 1u) != 0u) { + // Textured (bit 0) — RRect only in practice + vec4 uv_rect = uintBitsToFloat(f_uv_or_effects); + vec2 local_uv = p_local / half_size * 0.5 + 0.5; + vec2 uv = mix(uv_rect.xy, uv_rect.zw, local_uv); + shape_color = f_color * texture(tex, uv); + } else { + // Solid color + shape_color = f_color; + } + + // --- Outline (bit 3) — outer outline via premultiplied compositing --- + // The outline band sits OUTSIDE the original shape boundary (d=0 to d=+ol_width). + // fill_cov covers the interior with AA at d=0; total_cov covers interior+outline with + // AA at d=ol_width. The outline band's coverage is total_cov - fill_cov. + // Output is premultiplied: blend state is ONE, ONE_MINUS_SRC_ALPHA. + if ((flags & 8u) != 0u) { + mediump vec4 ol_color = unpackUnorm4x8(f_uv_or_effects.y); + // Outline width in f_uv_or_effects.w (low f16 half) + float ol_width = unpackHalf2x16(f_uv_or_effects.w).x / grad_magnitude; + + float fill_cov = sdf_alpha(d, h); + float total_cov = sdf_alpha(d - ol_width, h); + float outline_cov = max(total_cov - fill_cov, 0.0); + + // Premultiplied output — no divide, no threshold check + vec3 rgb_pm = shape_color.rgb * shape_color.a * fill_cov + + ol_color.rgb * ol_color.a * outline_cov; + float alpha_pm = shape_color.a * fill_cov + ol_color.a * outline_cov; + out_color = vec4(rgb_pm, alpha_pm); + } else { + mediump float alpha = sdf_alpha(d, h); + out_color = vec4(shape_color.rgb * shape_color.a * alpha, shape_color.a * alpha); + } } diff --git a/draw/shaders/source/base_2d.vert b/draw/shaders/source/base_2d.vert index a43b51f..0f74941 100644 --- a/draw/shaders/source/base_2d.vert +++ b/draw/shaders/source/base_2d.vert @@ -6,13 +6,13 @@ layout(location = 1) in vec2 v_uv; layout(location = 2) in vec4 v_color; // ---------- Outputs to fragment shader ---------- -layout(location = 0) out vec4 f_color; +layout(location = 0) out mediump vec4 f_color; layout(location = 1) out vec2 f_local_or_uv; layout(location = 2) out vec4 f_params; layout(location = 3) out vec4 f_params2; -layout(location = 4) flat out uint f_kind_flags; -layout(location = 5) flat out float f_rotation; -layout(location = 6) flat out vec4 f_uv_rect; +layout(location = 4) flat out uint f_flags; +layout(location = 5) flat out uint f_rotation_sc; +layout(location = 6) flat out uvec4 f_uv_or_effects; // ---------- Uniforms (single block — avoids spirv-cross reordering on Metal) ---------- layout(set = 1, binding = 0) uniform Uniforms { @@ -23,14 +23,14 @@ layout(set = 1, binding = 0) uniform Uniforms { // ---------- SDF primitive storage buffer ---------- struct Primitive { - vec4 bounds; // 0-15: min_x, min_y, max_x, max_y - uint color; // 16-19: packed u8x4 (unpack with unpackUnorm4x8) - uint kind_flags; // 20-23: kind | (flags << 8) - float rotation; // 24-27: shader self-rotation in radians - float _pad; // 28-31: alignment padding - vec4 params; // 32-47: shape params part 1 - vec4 params2; // 48-63: shape params part 2 - vec4 uv_rect; // 64-79: u_min, v_min, u_max, v_max + vec4 bounds; // 0-15 + uint color; // 16-19 + uint flags; // 20-23 + uint rotation_sc; // 24-27: packed f16 pair (sin, cos) + float _pad; // 28-31 + vec4 params; // 32-47 + vec4 params2; // 48-63 + uvec4 uv_or_effects; // 64-79 }; layout(std430, set = 0, binding = 0) readonly buffer Primitives { @@ -45,9 +45,9 @@ void main() { f_local_or_uv = v_uv; f_params = vec4(0.0); f_params2 = vec4(0.0); - f_kind_flags = 0u; - f_rotation = 0.0; - f_uv_rect = vec4(0.0, 0.0, 1.0, 1.0); + f_flags = 0u; + f_rotation_sc = 0u; + f_uv_or_effects = uvec4(0); gl_Position = projection * vec4(v_position * dpi_scale, 0.0, 1.0); } else { @@ -62,9 +62,9 @@ void main() { f_local_or_uv = (world_pos - center) * dpi_scale; // shape-centered physical pixels f_params = p.params; f_params2 = p.params2; - f_kind_flags = p.kind_flags; - f_rotation = p.rotation; - f_uv_rect = p.uv_rect; + f_flags = p.flags; + f_rotation_sc = p.rotation_sc; + f_uv_or_effects = p.uv_or_effects; gl_Position = projection * vec4(world_pos * dpi_scale, 0.0, 1.0); } diff --git a/draw/shapes.odin b/draw/shapes.odin index cca0140..75c3852 100644 --- a/draw/shapes.odin +++ b/draw/shapes.odin @@ -2,27 +2,11 @@ package draw import "core:math" -SMOOTH_CIRCLE_ERROR_RATE :: 0.1 - -// ----- Adaptive tessellation ---- - -auto_segments :: proc(radius: f32, arc_degrees: f32) -> int { - if radius <= 0 do return 4 - phys_radius := radius * GLOB.dpi_scaling - acos_arg := clamp(2 * math.pow(1 - SMOOTH_CIRCLE_ERROR_RATE / phys_radius, 2) - 1, -1, 1) - theta := math.acos(acos_arg) - if theta <= 0 do return 4 - full_circle_segments := int(math.ceil(2 * math.PI / theta)) - segments := int(f32(full_circle_segments) * arc_degrees / 360.0) - min_segments := max(int(math.ceil(f64(arc_degrees / 90.0))), 4) - return max(segments, min_segments) -} - // ----- Internal helpers ---- -@(private = "file") +// Internal extrude_line :: proc( - start, end_pos: [2]f32, + start, end_pos: Vec2, thickness: f32, color: Color, vertices: []Vertex, @@ -35,7 +19,7 @@ extrude_line :: proc( if length < 0.0001 do return 0 scale := thickness / (2 * length) - perpendicular := [2]f32{-delta_y * scale, delta_x * scale} + perpendicular := Vec2{-delta_y * scale, delta_x * scale} p0 := start + perpendicular p1 := start - perpendicular @@ -53,12 +37,12 @@ extrude_line :: proc( } // Create a vertex for solid-color shape drawing (no texture, UV defaults to zero). -@(private = "file") -solid_vertex :: proc(position: [2]f32, color: Color) -> Vertex { - return Vertex{position = position, color = color} +// 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)} } -@(private = "file") 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) @@ -68,7 +52,7 @@ emit_rectangle :: proc(x, y, width, height: f32, color: Color, vertices: []Verte vertices[offset + 5] = solid_vertex({x, y + height}, color) } -@(private = "file") +// Internal prepare_sdf_primitive_textured :: proc( layer: ^Layer, prim: Primitive, @@ -81,296 +65,8 @@ prepare_sdf_primitive_textured :: proc( append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1, texture_id, sampler) } -// ----- Drawing functions ---- - -pixel :: proc(layer: ^Layer, pos: [2]f32, color: Color) { - vertices: [6]Vertex - emit_rectangle(pos[0], pos[1], 1, 1, color, vertices[:], 0) - prepare_shape(layer, vertices[:]) -} - -rectangle_gradient :: proc( - layer: ^Layer, - rect: Rectangle, - top_left, top_right, bottom_left, bottom_right: Color, - temp_allocator := context.temp_allocator, -) { - vertices := make([]Vertex, 6, temp_allocator) - defer delete(vertices, temp_allocator) - - corner_top_left := [2]f32{rect.x, rect.y} - corner_top_right := [2]f32{rect.x + rect.width, rect.y} - corner_bottom_right := [2]f32{rect.x + rect.width, rect.y + rect.height} - corner_bottom_left := [2]f32{rect.x, rect.y + rect.height} - - vertices[0] = solid_vertex(corner_top_left, top_left) - vertices[1] = solid_vertex(corner_top_right, top_right) - vertices[2] = solid_vertex(corner_bottom_right, bottom_right) - vertices[3] = solid_vertex(corner_top_left, top_left) - vertices[4] = solid_vertex(corner_bottom_right, bottom_right) - vertices[5] = solid_vertex(corner_bottom_left, bottom_left) - - prepare_shape(layer, vertices) -} - -circle_sector :: proc( - layer: ^Layer, - center: [2]f32, - radius: f32, - start_angle, end_angle: f32, - color: Color, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - segments: int = 0, - temp_allocator := context.temp_allocator, -) { - arc_length := abs(end_angle - start_angle) - segment_count := segments > 0 ? segments : auto_segments(radius, arc_length) - - vertex_count := segment_count * 3 - vertices := make([]Vertex, vertex_count, temp_allocator) - defer delete(vertices, temp_allocator) - - start_radians := math.to_radians(start_angle) - end_radians := math.to_radians(end_angle) - step_angle := (end_radians - start_radians) / f32(segment_count) - - if !needs_transform(origin, rotation) { - for i in 0 ..< segment_count { - current_angle := start_radians + step_angle * f32(i) - next_angle := start_radians + step_angle * f32(i + 1) - - edge_current := center + [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} - edge_next := center + [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} - - idx := i * 3 - vertices[idx + 0] = solid_vertex(center, color) - vertices[idx + 1] = solid_vertex(edge_next, color) - vertices[idx + 2] = solid_vertex(edge_current, color) - } - } else { - transform := build_pivot_rotation(center, origin, rotation) - center_local := [2]f32{0, 0} - for i in 0 ..< segment_count { - current_angle := start_radians + step_angle * f32(i) - next_angle := start_radians + step_angle * f32(i + 1) - - edge_current := [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} - edge_next := [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} - - idx := i * 3 - vertices[idx + 0] = solid_vertex(apply_transform(transform, center_local), color) - vertices[idx + 1] = solid_vertex(apply_transform(transform, edge_next), color) - vertices[idx + 2] = solid_vertex(apply_transform(transform, edge_current), color) - } - } - - prepare_shape(layer, vertices) -} - -circle_gradient :: proc( - layer: ^Layer, - center: [2]f32, - radius: f32, - inner, outer: Color, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - segments: int = 0, - temp_allocator := context.temp_allocator, -) { - segment_count := segments > 0 ? segments : auto_segments(radius, 360) - - vertex_count := segment_count * 3 - vertices := make([]Vertex, vertex_count, temp_allocator) - defer delete(vertices, temp_allocator) - - step_angle := math.TAU / f32(segment_count) - - if !needs_transform(origin, rotation) { - for i in 0 ..< segment_count { - current_angle := step_angle * f32(i) - next_angle := step_angle * f32(i + 1) - - edge_current := center + [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} - edge_next := center + [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} - - idx := i * 3 - vertices[idx + 0] = solid_vertex(center, inner) - vertices[idx + 1] = solid_vertex(edge_next, outer) - vertices[idx + 2] = solid_vertex(edge_current, outer) - } - } else { - transform := build_pivot_rotation(center, origin, rotation) - center_local := [2]f32{0, 0} - for i in 0 ..< segment_count { - current_angle := step_angle * f32(i) - next_angle := step_angle * f32(i + 1) - - edge_current := [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} - edge_next := [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} - - idx := i * 3 - vertices[idx + 0] = solid_vertex(apply_transform(transform, center_local), inner) - vertices[idx + 1] = solid_vertex(apply_transform(transform, edge_next), outer) - vertices[idx + 2] = solid_vertex(apply_transform(transform, edge_current), outer) - } - } - - prepare_shape(layer, vertices) -} - -triangle :: proc( - layer: ^Layer, - v1, v2, v3: [2]f32, - color: Color, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, -) { - if !needs_transform(origin, rotation) { - vertices := [3]Vertex{solid_vertex(v1, color), solid_vertex(v2, color), solid_vertex(v3, color)} - prepare_shape(layer, vertices[:]) - return - } - bounds_min := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} - transform := build_pivot_rotation(bounds_min, origin, rotation) - local_v1 := v1 - bounds_min - local_v2 := v2 - bounds_min - local_v3 := v3 - bounds_min - vertices := [3]Vertex { - solid_vertex(apply_transform(transform, local_v1), color), - solid_vertex(apply_transform(transform, local_v2), color), - solid_vertex(apply_transform(transform, local_v3), color), - } - prepare_shape(layer, vertices[:]) -} - -triangle_lines :: proc( - layer: ^Layer, - v1, v2, v3: [2]f32, - color: Color, - thickness: f32 = 1, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - temp_allocator := context.temp_allocator, -) { - vertices := make([]Vertex, 18, temp_allocator) - defer delete(vertices, temp_allocator) - write_offset := 0 - - if !needs_transform(origin, rotation) { - write_offset += extrude_line(v1, v2, thickness, color, vertices, write_offset) - write_offset += extrude_line(v2, v3, thickness, color, vertices, write_offset) - write_offset += extrude_line(v3, v1, thickness, color, vertices, write_offset) - } else { - bounds_min := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} - transform := build_pivot_rotation(bounds_min, origin, rotation) - transformed_v1 := apply_transform(transform, v1 - bounds_min) - transformed_v2 := apply_transform(transform, v2 - bounds_min) - transformed_v3 := apply_transform(transform, v3 - bounds_min) - write_offset += extrude_line(transformed_v1, transformed_v2, thickness, color, vertices, write_offset) - write_offset += extrude_line(transformed_v2, transformed_v3, thickness, color, vertices, write_offset) - write_offset += extrude_line(transformed_v3, transformed_v1, thickness, color, vertices, write_offset) - } - - if write_offset > 0 { - prepare_shape(layer, vertices[:write_offset]) - } -} - -triangle_fan :: proc( - layer: ^Layer, - points: [][2]f32, - color: Color, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - temp_allocator := context.temp_allocator, -) { - if len(points) < 3 do return - - triangle_count := len(points) - 2 - vertex_count := triangle_count * 3 - vertices := make([]Vertex, vertex_count, temp_allocator) - defer delete(vertices, temp_allocator) - - if !needs_transform(origin, rotation) { - for i in 1 ..< len(points) - 1 { - idx := (i - 1) * 3 - vertices[idx + 0] = solid_vertex(points[0], color) - vertices[idx + 1] = solid_vertex(points[i], color) - vertices[idx + 2] = solid_vertex(points[i + 1], color) - } - } else { - bounds_min := [2]f32{max(f32), max(f32)} - for point in points { - bounds_min.x = min(bounds_min.x, point.x) - bounds_min.y = min(bounds_min.y, point.y) - } - transform := build_pivot_rotation(bounds_min, origin, rotation) - for i in 1 ..< len(points) - 1 { - idx := (i - 1) * 3 - vertices[idx + 0] = solid_vertex(apply_transform(transform, points[0] - bounds_min), color) - vertices[idx + 1] = solid_vertex(apply_transform(transform, points[i] - bounds_min), color) - vertices[idx + 2] = solid_vertex(apply_transform(transform, points[i + 1] - bounds_min), color) - } - } - - prepare_shape(layer, vertices) -} - -triangle_strip :: proc( - layer: ^Layer, - points: [][2]f32, - color: Color, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - temp_allocator := context.temp_allocator, -) { - if len(points) < 3 do return - - triangle_count := len(points) - 2 - vertex_count := triangle_count * 3 - vertices := make([]Vertex, vertex_count, temp_allocator) - defer delete(vertices, temp_allocator) - - if !needs_transform(origin, rotation) { - for i in 0 ..< triangle_count { - idx := i * 3 - if i % 2 == 0 { - vertices[idx + 0] = solid_vertex(points[i], color) - vertices[idx + 1] = solid_vertex(points[i + 1], color) - vertices[idx + 2] = solid_vertex(points[i + 2], color) - } else { - vertices[idx + 0] = solid_vertex(points[i + 1], color) - vertices[idx + 1] = solid_vertex(points[i], color) - vertices[idx + 2] = solid_vertex(points[i + 2], color) - } - } - } else { - bounds_min := [2]f32{max(f32), max(f32)} - for point in points { - bounds_min.x = min(bounds_min.x, point.x) - bounds_min.y = min(bounds_min.y, point.y) - } - transform := build_pivot_rotation(bounds_min, origin, rotation) - for i in 0 ..< triangle_count { - idx := i * 3 - if i % 2 == 0 { - vertices[idx + 0] = solid_vertex(apply_transform(transform, points[i] - bounds_min), color) - vertices[idx + 1] = solid_vertex(apply_transform(transform, points[i + 1] - bounds_min), color) - vertices[idx + 2] = solid_vertex(apply_transform(transform, points[i + 2] - bounds_min), color) - } else { - vertices[idx + 0] = solid_vertex(apply_transform(transform, points[i + 1] - bounds_min), color) - vertices[idx + 1] = solid_vertex(apply_transform(transform, points[i] - bounds_min), color) - vertices[idx + 2] = solid_vertex(apply_transform(transform, points[i + 2] - bounds_min), color) - } - } - } - - prepare_shape(layer, vertices) -} - -// ----- SDF drawing functions ---- - +//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 @@ -378,300 +74,409 @@ triangle_strip :: proc( // visual_center = center + R(θ) · (-origin) // When θ=0: visual_center = center - origin (pure positioning shift). // When origin={0,0}: visual_center = center (no change). -@(private = "file") -compute_pivot_center :: proc(center: [2]f32, origin: [2]f32, rotation_deg: f32) -> [2]f32 { +compute_pivot_center :: proc(center: Vec2, origin: Vec2, sin_angle, cos_angle: f32) -> Vec2 { if origin == {0, 0} do return center - theta := math.to_radians(rotation_deg) - cos_angle, sin_angle := math.cos(theta), math.sin(theta) 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 rotation_radians. -@(private = "file") -rotated_aabb_half_extents :: proc(half_width, half_height, rotation_radians: f32) -> [2]f32 { - cos_abs := abs(math.cos(rotation_radians)) - sin_abs := abs(math.sin(rotation_radians)) +// 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} } -// Draw a filled rectangle via SDF (analytical anti-aliasing at all orientations). -// `roundness` is a 0–1 fraction controlling uniform corner rounding — 0 is sharp, 1 is fully rounded. -// For per-corner pixel-precise rounding, use `rectangle_corners` instead. +// Pack sin/cos into the 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 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, +) -> 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 := 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 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, +) -> 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 := 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 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, +) -> 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 := 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 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, +) -> 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 := 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 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, +) -> ( + 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 := 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 gradient and outline effects to a primitive. Sets flags, uv.effects, and expands bounds. +// 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_shape_effects :: proc( + prim: ^Primitive, + kind: Shape_Kind, + gradient: Gradient, + outline_color: Color, + outline_width: f32, + extra_flags: Shape_Flags = {}, +) { + flags: Shape_Flags = extra_flags + gradient_dir_sc: u32 = 0 + + switch g in gradient { + case Linear_Gradient: + flags += {.Gradient} + prim.uv.effects.gradient_color = g.end_color + rad := math.to_radians(g.angle) + sin_a, cos_a := math.sincos(rad) + gradient_dir_sc = pack_f16_pair(f16(cos_a), f16(sin_a)) + case Radial_Gradient: + flags += {.Gradient, .Gradient_Radial} + prim.uv.effects.gradient_color = g.outer_color + case: + } + + outline_packed: u32 = 0 + if outline_width > 0 { + flags += {.Outline} + prim.uv.effects.outline_color = outline_color + 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.uv.effects.gradient_dir_sc = gradient_dir_sc + prim.uv.effects.outline_packed = outline_packed + prim.flags = pack_kind_flags(kind, flags) +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- 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. -// When `origin = center_of_rectangle(rect)`, `rect.x, rect.y` is the visual center. // Rotation always occurs around the anchor point. rectangle :: proc( layer: ^Layer, rect: Rectangle, color: Color, - roundness: f32 = 0, - origin: [2]f32 = {0, 0}, + gradient: Gradient = nil, + outline_color: Color = {}, + outline_width: f32 = 0, + radii: Rectangle_Radii = {}, + origin: Vec2 = {}, rotation: f32 = 0, - soft_px: f32 = 1.0, + feather_px: f32 = DFT_FEATHER_PX, ) { - cr := min(rect.width, rect.height) * clamp(roundness, 0, 1) * 0.5 - rectangle_corners(layer, rect, {cr, cr, cr, cr}, color, origin, rotation, soft_px) -} - -// Draw a stroked rectangle via SDF (analytical anti-aliasing at all orientations). -// `roundness` is a 0–1 fraction controlling uniform corner rounding — 0 is sharp, 1 is fully rounded. -// For per-corner pixel-precise rounding, use `rectangle_corners_lines` instead. -// Origin semantics: see `rectangle`. -rectangle_lines :: proc( - layer: ^Layer, - rect: Rectangle, - color: Color, - thickness: f32 = 1, - roundness: f32 = 0, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - soft_px: f32 = 1.0, -) { - cr := min(rect.width, rect.height) * clamp(roundness, 0, 1) * 0.5 - rectangle_corners_lines(layer, rect, {cr, cr, cr, cr}, color, thickness, origin, rotation, soft_px) -} - -// Draw a rectangle with per-corner rounding radii via SDF. -// Origin semantics: see `rectangle`. -rectangle_corners :: proc( - layer: ^Layer, - rect: Rectangle, - radii: [4]f32, - color: Color, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - soft_px: f32 = 1.0, -) { - max_radius := min(rect.width, rect.height) * 0.5 - top_left := clamp(radii[0], 0, max_radius) - top_right := clamp(radii[1], 0, max_radius) - bottom_right := clamp(radii[2], 0, max_radius) - bottom_left := clamp(radii[3], 0, max_radius) - - padding := soft_px / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - half_width := rect.width * 0.5 - half_height := rect.height * 0.5 - rotation_radians: f32 = 0 - center_x := rect.x + half_width - origin.x - center_y := rect.y + half_height - origin.y - - if needs_transform(origin, rotation) { - rotation_radians = math.to_radians(rotation) - transform := build_pivot_rotation({rect.x + origin.x, rect.y + origin.y}, origin, rotation) - 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 rotation_radians != 0 { - expanded := rotated_aabb_half_extents(half_width, half_height, rotation_radians) - bounds_half_width = expanded.x - bounds_half_height = expanded.y - } - - prim := 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, - }, - color = color, - kind_flags = pack_kind_flags(.RRect, {}), - rotation = rotation_radians, - } - prim.params.rrect = RRect_Params { - half_size = {half_width * dpi_scale, half_height * dpi_scale}, - radii = { - top_right * dpi_scale, - bottom_right * dpi_scale, - top_left * dpi_scale, - bottom_left * dpi_scale, - }, - soft_px = soft_px, - stroke_px = 0, - } + prim := build_rrect_primitive(rect, radii, origin, rotation, feather_px) + prim.color = color + apply_shape_effects(&prim, .RRect, gradient, outline_color, outline_width) prepare_sdf_primitive(layer, prim) } -// Draw a stroked rectangle with per-corner rounding radii via SDF. -// Origin semantics: see `rectangle`. -rectangle_corners_lines :: proc( - layer: ^Layer, - rect: Rectangle, - radii: [4]f32, - color: Color, - thickness: f32 = 1, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - soft_px: f32 = 1.0, -) { - max_radius := min(rect.width, rect.height) * 0.5 - top_left := clamp(radii[0], 0, max_radius) - top_right := clamp(radii[1], 0, max_radius) - bottom_right := clamp(radii[2], 0, max_radius) - bottom_left := clamp(radii[3], 0, max_radius) - - padding := (thickness * 0.5 + soft_px) / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - half_width := rect.width * 0.5 - half_height := rect.height * 0.5 - rotation_radians: f32 = 0 - center_x := rect.x + half_width - origin.x - center_y := rect.y + half_height - origin.y - - if needs_transform(origin, rotation) { - rotation_radians = math.to_radians(rotation) - transform := build_pivot_rotation({rect.x + origin.x, rect.y + origin.y}, origin, rotation) - 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 rotation_radians != 0 { - expanded := rotated_aabb_half_extents(half_width, half_height, rotation_radians) - bounds_half_width = expanded.x - bounds_half_height = expanded.y - } - - prim := 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, - }, - color = color, - kind_flags = pack_kind_flags(.RRect, {.Stroke}), - rotation = rotation_radians, - } - prim.params.rrect = RRect_Params { - half_size = {half_width * dpi_scale, half_height * dpi_scale}, - radii = { - top_right * dpi_scale, - bottom_right * dpi_scale, - top_left * dpi_scale, - bottom_left * dpi_scale, - }, - soft_px = soft_px, - stroke_px = thickness * dpi_scale, - } - prepare_sdf_primitive(layer, prim) -} - -// Draw a rectangle with a texture fill via SDF. Supports rounded corners via `roundness`, -// rotation, and analytical anti-aliasing on the shape silhouette. +// Draw a rectangle with a texture fill via SDF with optional per-corner rounding radii. +// Texture and gradient/outline are mutually exclusive (they share the same storage in the +// primitive). To outline a textured rect, draw the texture first, then a stroke-only rect on top. // Origin semantics: see `rectangle`. rectangle_texture :: proc( layer: ^Layer, rect: Rectangle, id: Texture_Id, - tint: Color = WHITE, - uv_rect: Rectangle = {0, 0, 1, 1}, - sampler: Sampler_Preset = .Linear_Clamp, - roundness: f32 = 0, - origin: [2]f32 = {0, 0}, + tint: Color = DFT_TINT, + uv_rect: Rectangle = DFT_UV_RECT, + sampler: Sampler_Preset = DFT_SAMPLER, + radii: Rectangle_Radii = {}, + origin: Vec2 = {}, rotation: f32 = 0, - soft_px: f32 = 1.0, + feather_px: f32 = DFT_FEATHER_PX, ) { - cr := min(rect.width, rect.height) * clamp(roundness, 0, 1) * 0.5 - rectangle_texture_corners( - layer, - rect, - {cr, cr, cr, cr}, - id, - tint, - uv_rect, - sampler, - origin, - rotation, - soft_px, - ) -} - -// Draw a rectangle with a texture fill and per-corner rounding radii via SDF. -// Origin semantics: see `rectangle`. -rectangle_texture_corners :: proc( - layer: ^Layer, - rect: Rectangle, - radii: [4]f32, - id: Texture_Id, - tint: Color = WHITE, - uv_rect: Rectangle = {0, 0, 1, 1}, - sampler: Sampler_Preset = .Linear_Clamp, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - soft_px: f32 = 1.0, -) { - max_radius := min(rect.width, rect.height) * 0.5 - top_left := clamp(radii[0], 0, max_radius) - top_right := clamp(radii[1], 0, max_radius) - bottom_right := clamp(radii[2], 0, max_radius) - bottom_left := clamp(radii[3], 0, max_radius) - - padding := soft_px / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - half_width := rect.width * 0.5 - half_height := rect.height * 0.5 - rotation_radians: f32 = 0 - center_x := rect.x + half_width - origin.x - center_y := rect.y + half_height - origin.y - - if needs_transform(origin, rotation) { - rotation_radians = math.to_radians(rotation) - transform := build_pivot_rotation({rect.x + origin.x, rect.y + origin.y}, origin, rotation) - 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 rotation_radians != 0 { - expanded := rotated_aabb_half_extents(half_width, half_height, rotation_radians) - bounds_half_width = expanded.x - bounds_half_height = expanded.y - } - - prim := 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, - }, - color = tint, - kind_flags = pack_kind_flags(.RRect, {.Textured}), - rotation = rotation_radians, - uv_rect = {uv_rect.x, uv_rect.y, uv_rect.width, uv_rect.height}, - } - prim.params.rrect = RRect_Params { - half_size = {half_width * dpi_scale, half_height * dpi_scale}, - radii = { - top_right * dpi_scale, - bottom_right * dpi_scale, - top_left * dpi_scale, - bottom_left * dpi_scale, - }, - soft_px = soft_px, - stroke_px = 0, + prim := build_rrect_primitive(rect, radii, origin, rotation, feather_px) + prim.color = tint + tex_flags: Shape_Flags = {.Textured} + if prim.rotation_sc != 0 { + tex_flags += {.Rotated} } + prim.flags = pack_kind_flags(.RRect, tex_flags) + prim.uv.uv_rect = {uv_rect.x, uv_rect.y, uv_rect.width, uv_rect.height} prepare_sdf_primitive_textured(layer, prim, id, sampler) } -// Draw a filled circle via SDF. +// --------------------------------------------------------------------------------------------------------------------- +// ----- 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 @@ -681,487 +486,291 @@ rectangle_texture_corners :: proc( // `center`, shifting the shape left by `r`. circle :: proc( layer: ^Layer, - center: [2]f32, + center: Vec2, radius: f32, color: Color, - origin: [2]f32 = {0, 0}, + gradient: Gradient = nil, + outline_color: Color = {}, + outline_width: f32 = 0, + origin: Vec2 = {}, rotation: f32 = 0, - soft_px: f32 = 1.0, + feather_px: f32 = DFT_FEATHER_PX, ) { - padding := soft_px / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - if origin != {0, 0} { - actual_center = compute_pivot_center(center, origin, rotation) - } - - prim := Primitive { - bounds = { - actual_center.x - radius - padding, - actual_center.y - radius - padding, - actual_center.x + radius + padding, - actual_center.y + radius + padding, - }, - color = color, - kind_flags = pack_kind_flags(.Circle, {}), - // rotation stays 0 — circle is rotationally symmetric - } - prim.params.circle = Circle_Params { - radius = radius * dpi_scale, - soft_px = soft_px, - } + prim := build_circle_primitive(center, radius, origin, rotation, feather_px) + prim.color = color + apply_shape_effects(&prim, .RRect, gradient, outline_color, outline_width) prepare_sdf_primitive(layer, prim) } -// Draw a stroked circle via SDF. -// Origin semantics: see `circle`. -circle_lines :: proc( - layer: ^Layer, - center: [2]f32, - radius: f32, - color: Color, - thickness: f32 = 1, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - soft_px: f32 = 1.0, -) { - padding := (thickness * 0.5 + soft_px) / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - if origin != {0, 0} { - actual_center = compute_pivot_center(center, origin, rotation) - } - - prim := Primitive { - bounds = { - actual_center.x - radius - padding, - actual_center.y - radius - padding, - actual_center.x + radius + padding, - actual_center.y + radius + padding, - }, - color = color, - kind_flags = pack_kind_flags(.Circle, {.Stroke}), - } - prim.params.circle = Circle_Params { - radius = radius * dpi_scale, - soft_px = soft_px, - stroke_px = thickness * dpi_scale, - } - prepare_sdf_primitive(layer, prim) -} +// --------------------------------------------------------------------------------------------------------------------- +// ----- SDF Ellipse procs (emit Ellipse primitives) --- +// --------------------------------------------------------------------------------------------------------------------- // Draw a filled ellipse via SDF. // Origin semantics: see `circle`. ellipse :: proc( layer: ^Layer, - center: [2]f32, - radius_h, radius_v: f32, + center: Vec2, + radius_horizontal, radius_vertical: f32, color: Color, - origin: [2]f32 = {0, 0}, + gradient: Gradient = nil, + outline_color: Color = {}, + outline_width: f32 = 0, + origin: Vec2 = {}, rotation: f32 = 0, - soft_px: f32 = 1.0, + feather_px: f32 = DFT_FEATHER_PX, ) { - padding := soft_px / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - rotation_radians: f32 = 0 - if needs_transform(origin, rotation) { - actual_center = compute_pivot_center(center, origin, rotation) - rotation_radians = math.to_radians(rotation) - } - - // When rotated, expand the bounds AABB to enclose the rotated ellipse - bound_h, bound_v := radius_h, radius_v - if rotation_radians != 0 { - expanded := rotated_aabb_half_extents(radius_h, radius_v, rotation_radians) - bound_h = expanded.x - bound_v = expanded.y - } - - prim := Primitive { - bounds = { - actual_center.x - bound_h - padding, - actual_center.y - bound_v - padding, - actual_center.x + bound_h + padding, - actual_center.y + bound_v + padding, - }, - color = color, - kind_flags = pack_kind_flags(.Ellipse, {}), - rotation = rotation_radians, - } - prim.params.ellipse = Ellipse_Params { - radii = {radius_h * dpi_scale, radius_v * dpi_scale}, - soft_px = soft_px, - } + prim := build_ellipse_primitive(center, radius_horizontal, radius_vertical, origin, rotation, feather_px) + prim.color = color + apply_shape_effects(&prim, .Ellipse, gradient, outline_color, outline_width) prepare_sdf_primitive(layer, prim) } -// Draw a stroked ellipse via SDF. +// --------------------------------------------------------------------------------------------------------------------- +// ----- 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`. -ellipse_lines :: proc( +polygon :: proc( layer: ^Layer, - center: [2]f32, - radius_h, radius_v: f32, + center: Vec2, + sides: int, + radius: f32, color: Color, - thickness: f32 = 1, - origin: [2]f32 = {0, 0}, + gradient: Gradient = nil, + outline_color: Color = {}, + outline_width: f32 = 0, + origin: Vec2 = {}, rotation: f32 = 0, - soft_px: f32 = 1.0, + feather_px: f32 = DFT_FEATHER_PX, ) { - // Extra 10% padding: iq's sdEllipse has precision degradation near the tips of highly - // eccentric ellipses, so the quad needs additional breathing room beyond the stroke width. - extra := max(radius_h, radius_v) * 0.1 + thickness * 0.5 - padding := (extra + soft_px) / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling + if sides < 3 do return - actual_center := center - rotation_radians: f32 = 0 - if needs_transform(origin, rotation) { - actual_center = compute_pivot_center(center, origin, rotation) - rotation_radians = math.to_radians(rotation) - } - - bound_h, bound_v := radius_h, radius_v - if rotation_radians != 0 { - expanded := rotated_aabb_half_extents(radius_h, radius_v, rotation_radians) - bound_h = expanded.x - bound_v = expanded.y - } - - prim := Primitive { - bounds = { - actual_center.x - bound_h - padding, - actual_center.y - bound_v - padding, - actual_center.x + bound_h + padding, - actual_center.y + bound_v + padding, - }, - color = color, - kind_flags = pack_kind_flags(.Ellipse, {.Stroke}), - rotation = rotation_radians, - } - prim.params.ellipse = Ellipse_Params { - radii = {radius_h * dpi_scale, radius_v * dpi_scale}, - soft_px = soft_px, - stroke_px = thickness * dpi_scale, - } + prim := build_polygon_primitive(center, sides, radius, origin, rotation, feather_px) + prim.color = color + apply_shape_effects(&prim, .NGon, gradient, outline_color, outline_width) prepare_sdf_primitive(layer, prim) } -// Draw a filled ring arc via SDF. +// --------------------------------------------------------------------------------------------------------------------- +// ----- 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: [2]f32, + center: Vec2, inner_radius, outer_radius: f32, - start_angle, end_angle: f32, color: Color, - origin: [2]f32 = {0, 0}, + gradient: Gradient = nil, + outline_color: Color = {}, + outline_width: f32 = 0, + start_angle: f32 = 0, + end_angle: f32 = DFT_CIRC_END_ANGLE, + origin: Vec2 = {}, rotation: f32 = 0, - soft_px: f32 = 1.0, + feather_px: f32 = DFT_FEATHER_PX, ) { - padding := soft_px / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - rotation_offset: f32 = 0 - if needs_transform(origin, rotation) { - actual_center = compute_pivot_center(center, origin, rotation) - rotation_offset = math.to_radians(rotation) - } - - prim := 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, - }, - color = color, - kind_flags = pack_kind_flags(.Ring_Arc, {}), - // No shader rotation — arc rotation handled by offsetting start/end angles - } - prim.params.ring_arc = Ring_Arc_Params { - inner_radius = inner_radius * dpi_scale, - outer_radius = outer_radius * dpi_scale, - start_rad = math.to_radians(start_angle) + rotation_offset, - end_rad = math.to_radians(end_angle) + rotation_offset, - soft_px = soft_px, - } + prim, arc_flags := build_ring_arc_primitive( + center, + inner_radius, + outer_radius, + start_angle, + end_angle, + origin, + rotation, + feather_px, + ) + prim.color = color + apply_shape_effects(&prim, .Ring_Arc, gradient, outline_color, outline_width, arc_flags) prepare_sdf_primitive(layer, prim) } -// Draw stroked ring arc outlines via SDF. -// Origin semantics: see `circle`. -ring_lines :: proc( +// --------------------------------------------------------------------------------------------------------------------- +// ----- 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, - center: [2]f32, - inner_radius, outer_radius: f32, - start_angle, end_angle: f32, + start_position, end_position: Vec2, color: Color, - thickness: f32 = 1, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - soft_px: f32 = 1.0, + thickness: f32 = DFT_STROKE_THICKNESS, + outline_color: Color = {}, + outline_width: f32 = 0, + feather_px: f32 = DFT_FEATHER_PX, ) { - // Compute effective angles and pivot-translated center up front - effective_start_angle := start_angle + rotation - effective_end_angle := end_angle + rotation + 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) - actual_center := center - if needs_transform(origin, rotation) { - actual_center = compute_pivot_center(center, origin, rotation) - } + center_x := (start_position.x + end_position.x) * 0.5 + center_y := (start_position.y + end_position.y) * 0.5 - // Inner arc outline (pass already-transformed center; no further origin/rotation) - ring( - layer, - actual_center, - max(0, inner_radius - thickness * 0.5), - inner_radius + thickness * 0.5, - effective_start_angle, - effective_end_angle, - color, - soft_px = soft_px, - ) - // Outer arc outline - ring( - layer, - actual_center, - max(0, outer_radius - thickness * 0.5), - outer_radius + thickness * 0.5, - effective_start_angle, - effective_end_angle, - color, - soft_px = soft_px, - ) - // Start cap - start_radians := math.to_radians(effective_start_angle) - end_radians := math.to_radians(effective_end_angle) - inner_start := - actual_center + {math.cos(start_radians) * inner_radius, math.sin(start_radians) * inner_radius} - outer_start := - actual_center + {math.cos(start_radians) * outer_radius, math.sin(start_radians) * outer_radius} - line(layer, inner_start, outer_start, color, thickness, soft_px) - // End cap - inner_end := actual_center + {math.cos(end_radians) * inner_radius, math.sin(end_radians) * inner_radius} - outer_end := actual_center + {math.cos(end_radians) * outer_radius, math.sin(end_radians) * outer_radius} - line(layer, inner_end, outer_end, color, thickness, soft_px) -} + half_length := seg_length * 0.5 + half_thickness := thickness * 0.5 + cap_radius := half_thickness -// Draw a line segment via SDF. -line :: proc(layer: ^Layer, start, end_pos: [2]f32, color: Color, thickness: f32 = 1, soft_px: f32 = 1.0) { - cap_padding := thickness * 0.5 + soft_px / GLOB.dpi_scaling - min_x := min(start.x, end_pos.x) - cap_padding - max_x := max(start.x, end_pos.x) + cap_padding - min_y := min(start.y, end_pos.y) - cap_padding - max_y := max(start.y, end_pos.y) + cap_padding + half_feather := feather_px * 0.5 + padding := half_feather / GLOB.dpi_scaling dpi_scale := GLOB.dpi_scaling - center := [2]f32{(min_x + max_x) * 0.5, (min_y + max_y) * 0.5} - local_start := (start - center) * dpi_scale - local_end := (end_pos - center) * dpi_scale + // Expand bounds for rotation + bounds_half := rotated_aabb_half_extents(half_length + cap_radius, half_thickness, cos_angle, sin_angle) prim := Primitive { - bounds = {min_x, min_y, max_x, max_y}, - color = color, - kind_flags = pack_kind_flags(.Segment, {}), + 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, + }, + color = color, + rotation_sc = pack_rotation_sc(sin_angle, cos_angle), } - prim.params.segment = Segment_Params { - a = local_start, - b = local_end, - width = thickness * dpi_scale, - soft_px = soft_px, + 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_shape_effects(&prim, .RRect, nil, outline_color, outline_width) prepare_sdf_primitive(layer, prim) } -// Draw a line strip via decomposed SDF segments. -line_strip :: proc(layer: ^Layer, points: [][2]f32, color: Color, thickness: f32 = 1, soft_px: f32 = 1.0) { +// Draw a line strip via decomposed SDF line segments. +line_strip :: proc( + layer: ^Layer, + points: []Vec2, + color: Color, + 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], color, thickness, soft_px) + line(layer, points[i], points[i + 1], color, thickness, outline_color, outline_width, feather_px) } } -// Draw a filled regular polygon via SDF. -polygon :: proc( - layer: ^Layer, - center: [2]f32, - sides: int, - radius: f32, - color: Color, - rotation: f32 = 0, - origin: [2]f32 = {0, 0}, - soft_px: f32 = 1.0, -) { - if sides < 3 do return - padding := soft_px / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - if origin != {0, 0} && rotation != 0 { - actual_center = compute_pivot_center(center, origin, rotation) - } - - prim := Primitive { - bounds = { - actual_center.x - radius - padding, - actual_center.y - radius - padding, - actual_center.x + radius + padding, - actual_center.y + radius + padding, - }, - color = color, - kind_flags = pack_kind_flags(.NGon, {}), - } - prim.params.ngon = NGon_Params { - radius = radius * math.cos(math.PI / f32(sides)) * dpi_scale, - rotation = math.to_radians(rotation), - sides = f32(sides), - soft_px = soft_px, - } - prepare_sdf_primitive(layer, prim) -} - -// Draw a stroked regular polygon via SDF. -polygon_lines :: proc( - layer: ^Layer, - center: [2]f32, - sides: int, - radius: f32, - color: Color, - rotation: f32 = 0, - origin: [2]f32 = {0, 0}, - thickness: f32 = 1, - soft_px: f32 = 1.0, -) { - if sides < 3 do return - padding := (thickness * 0.5 + soft_px) / GLOB.dpi_scaling - dpi_scale := GLOB.dpi_scaling - - actual_center := center - if origin != {0, 0} && rotation != 0 { - actual_center = compute_pivot_center(center, origin, rotation) - } - - prim := Primitive { - bounds = { - actual_center.x - radius - padding, - actual_center.y - radius - padding, - actual_center.x + radius + padding, - actual_center.y + radius + padding, - }, - color = color, - kind_flags = pack_kind_flags(.NGon, {.Stroke}), - } - prim.params.ngon = NGon_Params { - radius = radius * math.cos(math.PI / f32(sides)) * dpi_scale, - rotation = math.to_radians(rotation), - sides = f32(sides), - soft_px = soft_px, - stroke_px = thickness * dpi_scale, - } - prepare_sdf_primitive(layer, prim) -} // --------------------------------------------------------------------------------------------------------------------- -// ----- Anchor helpers ---------------- +// ----- Helpers ---------------- // --------------------------------------------------------------------------------------------------------------------- -// -// Return [2]f32 pixel offsets for use as the `origin` parameter of draw calls. + +// 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) -> [2]f32 { +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) -> [2]f32 { +top_left_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 { return {0, 0} } -top_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> [2]f32 { +top_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 { return {rectangle.width * 0.5, 0} } -top_right_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> [2]f32 { +top_right_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 { return {rectangle.width, 0} } -left_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> [2]f32 { +left_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 { return {0, rectangle.height * 0.5} } -right_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> [2]f32 { +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) -> [2]f32 { +bottom_left_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> Vec2 { return {0, rectangle.height} } -bottom_of_rectangle :: #force_inline proc(rectangle: Rectangle) -> [2]f32 { +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) -> [2]f32 { +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: [2]f32) -> [2]f32 { - bounds_min := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} +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: [2]f32) -> [2]f32 { +top_left_of_triangle :: #force_inline proc(v1, v2, v3: Vec2) -> Vec2 { return {0, 0} } -top_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { +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: [2]f32) -> [2]f32 { +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: [2]f32) -> [2]f32 { +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: [2]f32) -> [2]f32 { - bounds_min := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} - bounds_max := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)} +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: [2]f32) -> [2]f32 { +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: [2]f32) -> [2]f32 { - bounds_min := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} - bounds_max := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.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: [2]f32) -> [2]f32 { - bounds_min := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} - bounds_max := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.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 new file mode 100644 index 0000000..55caad6 --- /dev/null +++ b/draw/tess/tess.odin @@ -0,0 +1,330 @@ +package tess + +import "core:math" + +import draw ".." + +SMOOTH_CIRCLE_ERROR_RATE :: 0.1 + +auto_segments :: proc(radius: f32, arc_degrees: f32) -> int { + if radius <= 0 do return 4 + phys_radius := radius * draw.GLOB.dpi_scaling + acos_arg := clamp(2 * math.pow(1 - SMOOTH_CIRCLE_ERROR_RATE / phys_radius, 2) - 1, -1, 1) + theta := math.acos(acos_arg) + if theta <= 0 do return 4 + full_circle_segments := int(math.ceil(2 * math.PI / theta)) + segments := int(f32(full_circle_segments) * arc_degrees / 360.0) + min_segments := max(int(math.ceil(f64(arc_degrees / 90.0))), 4) + return max(segments, min_segments) +} + +// ----- Internal helpers ----- + +// 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)} +} + +emit_rectangle :: proc(x, y, width, height: f32, color: draw.Color, vertices: []draw.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) +} + +extrude_line :: proc( + start, end_pos: draw.Vec2, + thickness: f32, + color: draw.Color, + vertices: []draw.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 := draw.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 +} + +// ----- Public draw ----- + +pixel :: proc(layer: ^draw.Layer, pos: draw.Vec2, color: draw.Color) { + vertices: [6]draw.Vertex + emit_rectangle(pos[0], pos[1], 1, 1, color, vertices[:], 0) + draw.prepare_shape(layer, vertices[:]) +} + +triangle :: proc( + layer: ^draw.Layer, + v1, v2, v3: draw.Vec2, + color: draw.Color, + origin: draw.Vec2 = {}, + 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)} + draw.prepare_shape(layer, vertices[:]) + return + } + bounds_min := draw.Vec2{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + transform := draw.build_pivot_rotation(bounds_min, origin, rotation) + local_v1 := v1 - bounds_min + local_v2 := v2 - bounds_min + local_v3 := v3 - bounds_min + vertices := [3]draw.Vertex { + 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), + } + draw.prepare_shape(layer, vertices[:]) +} + +// Draw an anti-aliased triangle via extruded edge quads. +// Interior vertices get the full premultiplied color; outer fringe vertices get BLANK (0,0,0,0). +// The rasterizer linearly interpolates between them, producing a smooth 1-pixel AA band. +// `aa_px` controls the extrusion width in logical pixels (default 1.0). +// This proc emits 21 vertices (3 interior + 6 edge quads × 3 verts each). +triangle_aa :: proc( + layer: ^draw.Layer, + v1, v2, v3: draw.Vec2, + color: draw.Color, + aa_px: f32 = draw.DFT_FEATHER_PX, + origin: draw.Vec2 = {}, + rotation: f32 = 0, +) { + // Apply rotation if needed, then work in world space. + p0, p1, p2: draw.Vec2 + if !draw.needs_transform(origin, rotation) { + p0 = v1 + p1 = v2 + p2 = v3 + } else { + bounds_min := draw.Vec2{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + transform := draw.build_pivot_rotation(bounds_min, origin, rotation) + p0 = draw.apply_transform(transform, v1 - bounds_min) + p1 = draw.apply_transform(transform, v2 - bounds_min) + p2 = draw.apply_transform(transform, v3 - bounds_min) + } + + // Compute outward edge normals (unit length, pointing away from triangle interior). + // Winding-independent: we check against the centroid to ensure normals point outward. + centroid_x := (p0.x + p1.x + p2.x) / 3.0 + centroid_y := (p0.y + p1.y + p2.y) / 3.0 + + edge_normal :: proc(edge_start, edge_end: draw.Vec2, centroid_x, centroid_y: f32) -> draw.Vec2 { + delta_x := edge_end.x - edge_start.x + delta_y := edge_end.y - edge_start.y + length := math.sqrt(delta_x * delta_x + delta_y * delta_y) + if length < 0.0001 do return {0, 0} + inverse_length := 1.0 / length + // Perpendicular: (-delta_y, delta_x) normalized + normal_x := -delta_y * inverse_length + normal_y := delta_x * inverse_length + // Midpoint of the edge + midpoint_x := (edge_start.x + edge_end.x) * 0.5 + midpoint_y := (edge_start.y + edge_end.y) * 0.5 + // If normal points toward centroid, flip it + if normal_x * (centroid_x - midpoint_x) + normal_y * (centroid_y - midpoint_y) > 0 { + normal_x = -normal_x + normal_y = -normal_y + } + return {normal_x, normal_y} + } + + normal_01 := edge_normal(p0, p1, centroid_x, centroid_y) + normal_12 := edge_normal(p1, p2, centroid_x, centroid_y) + normal_20 := edge_normal(p2, p0, centroid_x, centroid_y) + + extrude_distance := aa_px * draw.GLOB.dpi_scaling + + // Outer fringe vertices: each edge vertex extruded outward + outer_0_01 := p0 + normal_01 * extrude_distance + outer_1_01 := p1 + normal_01 * extrude_distance + outer_1_12 := p1 + normal_12 * extrude_distance + outer_2_12 := p2 + normal_12 * extrude_distance + outer_2_20 := p2 + normal_20 * extrude_distance + outer_0_20 := p0 + normal_20 * extrude_distance + + // Premultiplied interior color (solid_vertex does premul internally). + // Outer fringe is BLANK = {0,0,0,0} which is already premul. + transparent := draw.BLANK + + // 3 interior + 6 × 3 edge-quad = 21 vertices + vertices: [21]draw.Vertex + + // Interior triangle + vertices[0] = solid_vertex(p0, color) + vertices[1] = solid_vertex(p1, color) + vertices[2] = solid_vertex(p2, color) + + // Edge quad: p0→p1 (2 triangles) + vertices[3] = solid_vertex(p0, color) + vertices[4] = solid_vertex(p1, color) + vertices[5] = solid_vertex(outer_1_01, transparent) + vertices[6] = solid_vertex(p0, color) + vertices[7] = solid_vertex(outer_1_01, transparent) + vertices[8] = solid_vertex(outer_0_01, transparent) + + // Edge quad: p1→p2 (2 triangles) + vertices[9] = solid_vertex(p1, color) + vertices[10] = solid_vertex(p2, color) + vertices[11] = solid_vertex(outer_2_12, transparent) + vertices[12] = solid_vertex(p1, color) + vertices[13] = solid_vertex(outer_2_12, transparent) + vertices[14] = solid_vertex(outer_1_12, transparent) + + // Edge quad: p2→p0 (2 triangles) + vertices[15] = solid_vertex(p2, color) + vertices[16] = solid_vertex(p0, color) + vertices[17] = solid_vertex(outer_0_20, transparent) + vertices[18] = solid_vertex(p2, color) + vertices[19] = solid_vertex(outer_0_20, transparent) + vertices[20] = solid_vertex(outer_2_20, transparent) + + draw.prepare_shape(layer, vertices[:]) +} + +triangle_lines :: proc( + layer: ^draw.Layer, + v1, v2, v3: draw.Vec2, + color: draw.Color, + thickness: f32 = draw.DFT_STROKE_THICKNESS, + origin: draw.Vec2 = {}, + rotation: f32 = 0, + temp_allocator := context.temp_allocator, +) { + vertices := make([]draw.Vertex, 18, temp_allocator) + defer delete(vertices, temp_allocator) + write_offset := 0 + + if !draw.needs_transform(origin, rotation) { + write_offset += extrude_line(v1, v2, thickness, color, vertices, write_offset) + write_offset += extrude_line(v2, v3, thickness, color, vertices, write_offset) + write_offset += extrude_line(v3, v1, thickness, color, vertices, write_offset) + } else { + bounds_min := draw.Vec2{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + transform := draw.build_pivot_rotation(bounds_min, origin, rotation) + transformed_v1 := draw.apply_transform(transform, v1 - bounds_min) + transformed_v2 := draw.apply_transform(transform, v2 - bounds_min) + transformed_v3 := draw.apply_transform(transform, v3 - bounds_min) + write_offset += extrude_line(transformed_v1, transformed_v2, thickness, color, vertices, write_offset) + write_offset += extrude_line(transformed_v2, transformed_v3, thickness, color, vertices, write_offset) + write_offset += extrude_line(transformed_v3, transformed_v1, thickness, color, vertices, write_offset) + } + + if write_offset > 0 { + draw.prepare_shape(layer, vertices[:write_offset]) + } +} + +triangle_fan :: proc( + layer: ^draw.Layer, + points: []draw.Vec2, + color: draw.Color, + origin: draw.Vec2 = {}, + rotation: f32 = 0, + temp_allocator := context.temp_allocator, +) { + if len(points) < 3 do return + + triangle_count := len(points) - 2 + vertex_count := triangle_count * 3 + vertices := make([]draw.Vertex, vertex_count, temp_allocator) + defer delete(vertices, temp_allocator) + + if !draw.needs_transform(origin, rotation) { + for i in 1 ..< len(points) - 1 { + idx := (i - 1) * 3 + vertices[idx + 0] = solid_vertex(points[0], color) + vertices[idx + 1] = solid_vertex(points[i], color) + vertices[idx + 2] = solid_vertex(points[i + 1], color) + } + } else { + bounds_min := draw.Vec2{max(f32), max(f32)} + for point in points { + bounds_min.x = min(bounds_min.x, point.x) + bounds_min.y = min(bounds_min.y, point.y) + } + transform := draw.build_pivot_rotation(bounds_min, origin, rotation) + for i in 1 ..< len(points) - 1 { + idx := (i - 1) * 3 + vertices[idx + 0] = solid_vertex(draw.apply_transform(transform, points[0] - bounds_min), color) + vertices[idx + 1] = solid_vertex(draw.apply_transform(transform, points[i] - bounds_min), color) + vertices[idx + 2] = solid_vertex(draw.apply_transform(transform, points[i + 1] - bounds_min), color) + } + } + + draw.prepare_shape(layer, vertices) +} + +triangle_strip :: proc( + layer: ^draw.Layer, + points: []draw.Vec2, + color: draw.Color, + origin: draw.Vec2 = {}, + rotation: f32 = 0, + temp_allocator := context.temp_allocator, +) { + if len(points) < 3 do return + + triangle_count := len(points) - 2 + vertex_count := triangle_count * 3 + vertices := make([]draw.Vertex, vertex_count, temp_allocator) + defer delete(vertices, temp_allocator) + + if !draw.needs_transform(origin, rotation) { + for i in 0 ..< triangle_count { + idx := i * 3 + if i % 2 == 0 { + vertices[idx + 0] = solid_vertex(points[i], color) + vertices[idx + 1] = solid_vertex(points[i + 1], color) + vertices[idx + 2] = solid_vertex(points[i + 2], color) + } else { + vertices[idx + 0] = solid_vertex(points[i + 1], color) + vertices[idx + 1] = solid_vertex(points[i], color) + vertices[idx + 2] = solid_vertex(points[i + 2], color) + } + } + } else { + bounds_min := draw.Vec2{max(f32), max(f32)} + for point in points { + bounds_min.x = min(bounds_min.x, point.x) + bounds_min.y = min(bounds_min.y, point.y) + } + transform := draw.build_pivot_rotation(bounds_min, origin, rotation) + for i in 0 ..< triangle_count { + idx := i * 3 + if i % 2 == 0 { + vertices[idx + 0] = solid_vertex(draw.apply_transform(transform, points[i] - bounds_min), color) + vertices[idx + 1] = solid_vertex(draw.apply_transform(transform, points[i + 1] - bounds_min), color) + vertices[idx + 2] = solid_vertex(draw.apply_transform(transform, points[i + 2] - bounds_min), color) + } else { + vertices[idx + 0] = solid_vertex(draw.apply_transform(transform, points[i + 1] - bounds_min), color) + vertices[idx + 1] = solid_vertex(draw.apply_transform(transform, points[i] - bounds_min), color) + vertices[idx + 2] = solid_vertex(draw.apply_transform(transform, points[i + 2] - bounds_min), color) + } + } + } + + draw.prepare_shape(layer, vertices) +} diff --git a/draw/text.odin b/draw/text.odin index 0a741b3..4ebdd1f 100644 --- a/draw/text.odin +++ b/draw/text.odin @@ -79,7 +79,7 @@ register_font :: proc(bytes: []u8) -> (id: Font_Id, ok: bool) #optional_ok { Text :: struct { sdl_text: ^sdl_ttf.Text, - position: [2]f32, + position: Vec2, color: Color, } @@ -129,11 +129,11 @@ cache_get_or_update :: proc(key: Cache_Key, c_str: cstring, font: ^sdl_ttf.Font) text :: proc( layer: ^Layer, text_string: string, - position: [2]f32, + position: Vec2, font_id: Font_Id, - font_size: u16 = 44, - color: Color = BLACK, - origin: [2]f32 = {0, 0}, + font_size: u16 = DFT_FONT_SIZE, + color: Color = DFT_TEXT_COLOR, + origin: Vec2 = {}, rotation: f32 = 0, id: Maybe(u32) = nil, temp_allocator := context.temp_allocator, @@ -177,9 +177,9 @@ text :: proc( measure_text :: proc( text_string: string, font_id: Font_Id, - font_size: u16 = 44, + font_size: u16 = DFT_FONT_SIZE, allocator := context.temp_allocator, -) -> [2]f32 { +) -> Vec2 { c_str := strings.clone_to_cstring(text_string, allocator) defer delete(c_str, allocator) width, height: c.int @@ -193,46 +193,46 @@ measure_text :: proc( // ----- Text anchor helpers ----------- // --------------------------------------------------------------------------------------------------------------------- -center_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +center_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { size := measure_text(text_string, font_id, font_size) return size * 0.5 } -top_left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +top_left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { return {0, 0} } -top_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +top_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { size := measure_text(text_string, font_id, font_size) return {size.x * 0.5, 0} } -top_right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +top_right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { size := measure_text(text_string, font_id, font_size) return {size.x, 0} } -left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { size := measure_text(text_string, font_id, font_size) return {0, size.y * 0.5} } -right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { size := measure_text(text_string, font_id, font_size) return {size.x, size.y * 0.5} } -bottom_left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +bottom_left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { size := measure_text(text_string, font_id, font_size) return {0, size.y} } -bottom_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +bottom_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { size := measure_text(text_string, font_id, font_size) return {size.x * 0.5, size.y} } -bottom_right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { +bottom_right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = DFT_FONT_SIZE) -> Vec2 { size := measure_text(text_string, font_id, font_size) return size }