From 7a21d6f253557c87b379327641f9cb92934e0586 Mon Sep 17 00:00:00 2001 From: Zachary Levy Date: Sun, 19 Apr 2026 18:28:42 -0700 Subject: [PATCH] Added improved non-clay text handling along with consistent origin and rotation API --- .zed/tasks.json | 5 + draw/draw.odin | 284 +++++++-- draw/examples/hellope.odin | 159 ++++- draw/pipeline_2d_base.odin | 3 +- draw/shaders/generated/base_2d.frag.metal | 123 ++-- draw/shaders/generated/base_2d.frag.spv | Bin 16820 -> 17776 bytes draw/shaders/generated/base_2d.vert.metal | 24 +- draw/shaders/generated/base_2d.vert.spv | Bin 4412 -> 4716 bytes draw/shaders/source/base_2d.frag | 38 +- draw/shaders/source/base_2d.vert | 6 +- draw/shapes.odin | 702 ++++++++++++++++------ draw/text.odin | 201 ++++++- 12 files changed, 1157 insertions(+), 388 deletions(-) diff --git a/.zed/tasks.json b/.zed/tasks.json index 13a618b..be18c0b 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -55,6 +55,11 @@ "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- hellope-shapes", "cwd": "$ZED_WORKTREE_ROOT", }, + { + "label": "Run draw hellope-text example", + "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- hellope-text", + "cwd": "$ZED_WORKTREE_ROOT", + }, // --------------------------------------------------------------------------------------------------------------------- // ----- Other ------------------------ // --------------------------------------------------------------------------------------------------------------------- diff --git a/draw/draw.odin b/draw/draw.odin index 457a938..4f9f95c 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -1,14 +1,15 @@ package draw -import clay "../vendor/clay" import "base:runtime" import "core:c" import "core:log" - +import "core:math" import "core:strings" import sdl "vendor:sdl3" import sdl_ttf "vendor:sdl3/ttf" +import clay "../vendor/clay" + when ODIN_OS == .Darwin { SHADER_TYPE :: sdl.GPUShaderFormat{.MSL} ENTRY_POINT :: "main0" @@ -89,34 +90,35 @@ Scissor :: struct { GLOB: Global Global :: struct { - odin_context: runtime.Context, - pipeline_2d_base: Pipeline_2D_Base, - text_cache: Text_Cache, - layers: [dynamic]Layer, - scissors: [dynamic]Scissor, - tmp_shape_verts: [dynamic]Vertex, - tmp_text_verts: [dynamic]Vertex, - tmp_text_indices: [dynamic]c.int, - tmp_text_batches: [dynamic]TextBatch, - tmp_primitives: [dynamic]Primitive, - tmp_sub_batches: [dynamic]Sub_Batch, - clay_mem: [^]u8, - msaa_texture: ^sdl.GPUTexture, - curr_layer_index: uint, - max_layers: int, - max_scissors: int, - max_shape_verts: int, - max_text_verts: int, - max_text_indices: int, - max_text_batches: int, - max_primitives: int, - max_sub_batches: int, - dpi_scaling: f32, - msaa_w: u32, - msaa_h: u32, - sample_count: sdl.GPUSampleCount, - clay_z_index: i16, - cleared: bool, + odin_context: runtime.Context, + pipeline_2d_base: Pipeline_2D_Base, + text_cache: Text_Cache, + layers: [dynamic]Layer, + scissors: [dynamic]Scissor, + tmp_shape_verts: [dynamic]Vertex, + tmp_text_verts: [dynamic]Vertex, + tmp_text_indices: [dynamic]c.int, + tmp_text_batches: [dynamic]TextBatch, + tmp_primitives: [dynamic]Primitive, + tmp_sub_batches: [dynamic]Sub_Batch, + tmp_uncached_text: [dynamic]^sdl_ttf.Text, // Uncached TTF_Text objects to destroy after end() + clay_mem: [^]u8, + msaa_texture: ^sdl.GPUTexture, + curr_layer_index: uint, + max_layers: int, + max_scissors: int, + max_shape_verts: int, + max_text_verts: int, + max_text_indices: int, + max_text_batches: int, + max_primitives: int, + max_sub_batches: int, + dpi_scaling: f32, + msaa_w: u32, + msaa_h: u32, + sample_count: sdl.GPUSampleCount, + clay_z_index: i16, + cleared: bool, } Init_Options :: struct { @@ -161,20 +163,21 @@ init :: proc( } GLOB = Global { - layers = make([dynamic]Layer, 0, INITIAL_LAYER_SIZE, allocator = allocator), - scissors = make([dynamic]Scissor, 0, INITIAL_SCISSOR_SIZE, allocator = allocator), - tmp_shape_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_indices = make([dynamic]c.int, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_batches = make([dynamic]TextBatch, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_primitives = make([dynamic]Primitive, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_sub_batches = make([dynamic]Sub_Batch, 0, BUFFER_INIT_SIZE, allocator = allocator), - odin_context = odin_context, - dpi_scaling = sdl.GetWindowDisplayScale(window), - clay_mem = make([^]u8, min_memory_size, allocator = allocator), - sample_count = resolved_sample_count, - pipeline_2d_base = pipeline, - text_cache = text_cache, + layers = make([dynamic]Layer, 0, INITIAL_LAYER_SIZE, allocator = allocator), + scissors = make([dynamic]Scissor, 0, INITIAL_SCISSOR_SIZE, allocator = allocator), + tmp_shape_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_text_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_text_indices = make([dynamic]c.int, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_text_batches = make([dynamic]TextBatch, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_primitives = make([dynamic]Primitive, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_sub_batches = make([dynamic]Sub_Batch, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_uncached_text = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator), + odin_context = odin_context, + dpi_scaling = sdl.GetWindowDisplayScale(window), + clay_mem = make([^]u8, min_memory_size, allocator = allocator), + sample_count = resolved_sample_count, + pipeline_2d_base = pipeline, + text_cache = text_cache, } log.debug("Window DPI scaling:", GLOB.dpi_scaling) arena := clay.CreateArenaWithCapacityAndMemory(min_memory_size, GLOB.clay_mem) @@ -182,7 +185,7 @@ init :: proc( sdl.GetWindowSize(window, &window_width, &window_height) clay.Initialize(arena, {f32(window_width), f32(window_height)}, {handler = clay_error_handler}) - clay.SetMeasureTextFunction(measure_text, nil) + clay.SetMeasureTextFunction(measure_text_clay, nil) return true } @@ -216,6 +219,8 @@ destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) { delete(GLOB.tmp_text_batches) delete(GLOB.tmp_primitives) delete(GLOB.tmp_sub_batches) + for t in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(t) + delete(GLOB.tmp_uncached_text) free(GLOB.clay_mem, allocator) if GLOB.msaa_texture != nil { sdl.ReleaseGPUTexture(device, GLOB.msaa_texture) @@ -229,6 +234,9 @@ clear_global :: proc() { GLOB.curr_layer_index = 0 GLOB.clay_z_index = 0 GLOB.cleared = false + // Destroy uncached TTF_Text objects from the previous frame (after end() has submitted draw data) + for t in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(t) + clear(&GLOB.tmp_uncached_text) clear(&GLOB.layers) clear(&GLOB.scissors) clear(&GLOB.tmp_shape_verts) @@ -244,7 +252,7 @@ clear_global :: proc() { // --------------------------------------------------------------------------------------------------------------------- @(private = "file") -measure_text :: proc "c" ( +measure_text_clay :: proc "c" ( text: clay.StringSlice, config: ^clay.TextElementConfig, user_data: rawptr, @@ -384,6 +392,54 @@ prepare_text :: proc(layer: ^Layer, txt: Text) { } } +// Submit a text element with a 2D affine transform applied to vertices. +// Used by the high-level `text` proc when rotation or a non-zero origin is specified. +// NOTE: xform must be in physical (DPI-scaled) pixel space — the caller pre-scales +// pos and origin by GLOB.dpi_scaling before building the transform. +prepare_text_transformed :: proc(layer: ^Layer, txt: Text, xform: Transform_2D) { + data := sdl_ttf.GetGPUTextDrawData(txt.ref) + if data == nil { + return + } + + scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + + for data != nil { + vertex_start := u32(len(GLOB.tmp_text_verts)) + index_start := u32(len(GLOB.tmp_text_indices)) + + for i in 0 ..< data.num_vertices { + pos := data.xy[i] + uv := data.uv[i] + // SDL_ttf gives glyph positions in physical pixels relative to text origin. + // The transform is already in physical-pixel space (caller pre-scaled), + // so we apply directly — no per-vertex DPI divide/multiply. + append( + &GLOB.tmp_text_verts, + Vertex{position = apply_transform(xform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = txt.color}, + ) + } + + append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices]) + + batch_idx := u32(len(GLOB.tmp_text_batches)) + append( + &GLOB.tmp_text_batches, + TextBatch { + atlas_texture = data.atlas_texture, + vertex_start = vertex_start, + vertex_count = u32(data.num_vertices), + index_start = index_start, + index_count = u32(data.num_indices), + }, + ) + + append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1) + + data = data.next + } +} + // Append a new sub-batch or extend the last one if same kind and contiguous. @(private) append_or_extend_sub_batch :: proc( @@ -466,26 +522,13 @@ prepare_clay_batch :: proc( render_data := render_command.renderData.text txt := string(render_data.stringContents.chars[:render_data.stringContents.length]) c_text := strings.clone_to_cstring(txt, context.temp_allocator) - sdl_text := GLOB.text_cache.cache[render_command.id] - - if sdl_text == nil { - // Cache a SDL text object - sdl_text = sdl_ttf.CreateText( - GLOB.text_cache.engine, - get_font(render_data.fontId, render_data.fontSize), - c_text, - 0, - ) - if sdl_text == nil { - log.panicf("Failed to create SDL text for clay render command: %s", sdl.GetError()) - } - GLOB.text_cache.cache[render_command.id] = sdl_text - } else { - if !sdl_ttf.SetTextString(sdl_text, c_text, 0) { - log.panicf("Failed to update SDL text string: %s", sdl.GetError()) - } - } - + // Clay's render_command.id is already hashed with the same Jenkins algorithm + // as text_cache_hash, so it shares the same keyspace. + sdl_text := cache_get_or_update( + render_command.id, + c_text, + get_font(render_data.fontId, render_data.fontSize), + ) prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)}) case clay.RenderCommandType.Image: case clay.RenderCommandType.ScissorStart: @@ -749,3 +792,112 @@ destroy_buffer :: proc(device: ^sdl.GPUDevice, buffer: ^Buffer) { sdl.ReleaseGPUBuffer(device, buffer.gpu) sdl.ReleaseGPUTransferBuffer(device, buffer.transfer) } + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Transform ------------------------ +// --------------------------------------------------------------------------------------------------------------------- + +// 2x3 affine transform for 2D pivot-rotation. +// Used internally by rotation-aware drawing procs. +Transform_2D :: struct { + m00, m01: f32, // row 0: rotation/scale + m10, m11: f32, // row 1: rotation/scale + tx, ty: f32, // translation +} + +// Build a pivot-rotation transform. +// +// Semantics (raylib-style): +// The point whose local coordinates equal `origin` lands at `pos` in world space. +// The rest of the shape rotates around that pivot. +// +// Formula: p_world = pos + R(θ) · (p_local - origin) +// +// Parameters: +// pos – world-space position where the pivot lands. +// origin – pivot point in local space (measured from the shape's natural reference point). +// rotation_deg – rotation in degrees, counter-clockwise. +// +build_pivot_rot :: proc(pos: [2]f32, origin: [2]f32, rotation_deg: f32) -> Transform_2D { + rad := math.to_radians(rotation_deg) + c := math.cos(rad) + s := math.sin(rad) + return Transform_2D { + m00 = c, + m01 = -s, + m10 = s, + m11 = c, + tx = pos.x - (c * origin.x - s * origin.y), + ty = pos.y - (s * origin.x + c * origin.y), + } +} + +// Apply the transform to a local-space point, producing a world-space point. +apply_transform :: #force_inline proc(t: Transform_2D, p: [2]f32) -> [2]f32 { + return {t.m00 * p.x + t.m01 * p.y + t.tx, t.m10 * p.x + t.m11 * p.y + t.ty} +} + +// Fast-path check callers use BEFORE building a transform. +// Returns true if either the origin is non-zero or rotation is non-zero, +// meaning a transform actually needs to be computed. +needs_transform :: #force_inline proc(origin: [2]f32, rotation: f32) -> bool { + return origin != {0, 0} || rotation != 0 +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Procedure Groups ------------------------ +// --------------------------------------------------------------------------------------------------------------------- + +center_of :: proc { + center_of_rect, + center_of_triangle, + center_of_text, +} + +top_left_of :: proc { + top_left_of_rect, + top_left_of_triangle, + top_left_of_text, +} + +top_of :: proc { + top_of_rect, + top_of_triangle, + top_of_text, +} + +top_right_of :: proc { + top_right_of_rect, + top_right_of_triangle, + top_right_of_text, +} + +left_of :: proc { + left_of_rect, + left_of_triangle, + left_of_text, +} + +right_of :: proc { + right_of_rect, + right_of_triangle, + right_of_text, +} + +bottom_left_of :: proc { + bottom_left_of_rect, + bottom_left_of_triangle, + bottom_left_of_text, +} + +bottom_of :: proc { + bottom_of_rect, + bottom_of_triangle, + bottom_of_text, +} + +bottom_right_of :: proc { + bottom_right_of_rect, + bottom_right_of_triangle, + bottom_right_of_text, +} diff --git a/draw/examples/hellope.odin b/draw/examples/hellope.odin index 55dd446..7cdcba5 100644 --- a/draw/examples/hellope.odin +++ b/draw/examples/hellope.odin @@ -2,10 +2,8 @@ package examples import "../../draw" import "../../vendor/clay" -import "core:c" import "core:os" import sdl "vendor:sdl3" -import sdl_ttf "vendor:sdl3/ttf" JETBRAINS_MONO_REGULAR_RAW :: #load("fonts/JetBrainsMono-Regular.ttf") JETBRAINS_MONO_REGULAR: draw.Font_Id = max(draw.Font_Id) // Max so we crash if registration is forgotten @@ -17,21 +15,24 @@ hellope_shapes :: proc() { if !sdl.ClaimWindowForGPUDevice(gpu, window) do os.exit(1) if !draw.init(gpu, window) do os.exit(1) + spin_angle: f32 = 0 + for { defer free_all(context.temp_allocator) ev: sdl.Event for sdl.PollEvent(&ev) { if ev.type == .QUIT do return } + spin_angle += 1 base_layer := draw.begin({w = 500, h = 500}) // Background draw.rectangle(base_layer, {0, 0, 500, 500}, {40, 40, 40, 255}) - // Shapes demo + // ----- 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, thick = 2) - draw.rectangle_rounded(base_layer, {240, 20, 240, 120}, 0.3, {200, 80, 80, 255}) + draw.rectangle(base_layer, {240, 20, 240, 120}, {200, 80, 80, 255}, roundness = 0.3) draw.rectangle_gradient( base_layer, {20, 160, 460, 60}, @@ -41,15 +42,66 @@ hellope_shapes :: proc() { {255, 255, 0, 255}, ) - draw.circle(base_layer, {120, 320}, 60, {100, 200, 100, 255}) - draw.circle_lines(base_layer, {120, 320}, 60, draw.WHITE, thick = 2) - draw.circle_gradient(base_layer, {300, 320}, 60, {255, 200, 50, 255}, {200, 50, 50, 255}) - draw.ring(base_layer, {430, 320}, 30, 55, 0, 270, {100, 100, 220, 255}) + // ----- Rotation demos ----- - draw.triangle(base_layer, {60, 420}, {180, 480}, {20, 480}, {220, 180, 60, 255}) - draw.line(base_layer, {220, 420}, {460, 480}, {255, 255, 100, 255}, thick = 3) - draw.poly(base_layer, {350, 450}, 6, 40, {180, 100, 220, 255}, rotation = 30) - draw.poly_lines(base_layer, {350, 450}, 6, 40, draw.WHITE, rotation = 30, thick = 2) + // Rectangle rotating around its center + rect := draw.Rectangle{100, 320, 80, 50} + 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, + thick = 2, + origin = draw.center_of(rect), + rotation = spin_angle, + ) + + // Rounded rectangle rotating around its center + rrect := draw.Rectangle{230, 300, 100, 80} + draw.rectangle( + base_layer, + rrect, + {200, 100, 200, 255}, + roundness = 0.4, + origin = draw.center_of(rrect), + rotation = spin_angle, + ) + + // Ellipse rotating around its center (tilted ellipse) + draw.ellipse(base_layer, {410, 340}, 50, 30, {255, 200, 50, 255}, rotation = spin_angle) + + // Circle orbiting a point (moon orbiting planet) + planet_pos := [2]f32{100, 450} + moon_pos := planet_pos + {0, -40} + draw.circle(base_layer, planet_pos, 8, {200, 200, 200, 255}) // planet (stationary) + draw.circle(base_layer, moon_pos, 5, {100, 150, 255, 255}, origin = {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) + + // Triangle rotating around its center + tv1 := [2]f32{350, 420} + tv2 := [2]f32{420, 480} + tv3 := [2]f32{340, 480} + draw.triangle( + base_layer, + tv1, + tv2, + tv3, + {220, 180, 60, 255}, + origin = draw.center_of(tv1, tv2, tv3), + rotation = spin_angle, + ) + + // Polygon rotating around its center (already had rotation; now with origin for orbit) + draw.poly(base_layer, {460, 450}, 6, 30, {180, 100, 220, 255}, rotation = spin_angle) + draw.poly_lines(base_layer, {460, 450}, 6, 30, draw.WHITE, rotation = spin_angle, thick = 2) draw.end(gpu, window) } @@ -57,17 +109,14 @@ hellope_shapes :: proc() { hellope_text :: proc() { if !sdl.Init({.VIDEO}) do os.exit(1) - window := sdl.CreateWindow("Hellope!", 500, 500, {.HIGH_PIXEL_DENSITY}) + window := sdl.CreateWindow("Hellope!", 600, 600, {.HIGH_PIXEL_DENSITY}) gpu := sdl.CreateGPUDevice({.MSL}, true, nil) if !sdl.ClaimWindowForGPUDevice(gpu, window) do os.exit(1) if !draw.init(gpu, window) do os.exit(1) JETBRAINS_MONO_REGULAR = draw.register_font(JETBRAINS_MONO_REGULAR_RAW) FONT_SIZE :: u16(24) - TEXT_ID :: u32(1) - - font := draw.get_font(JETBRAINS_MONO_REGULAR, FONT_SIZE) - dpi := sdl.GetWindowDisplayScale(window) + spin_angle: f32 = 0 for { defer free_all(context.temp_allocator) @@ -75,28 +124,74 @@ hellope_text :: proc() { for sdl.PollEvent(&ev) { if ev.type == .QUIT do return } - base_layer := draw.begin({w = 500, h = 500}) + spin_angle += 0.5 + base_layer := draw.begin({w = 600, h = 600}) // Grey background - draw.rectangle(base_layer, {0, 0, 500, 500}, {127, 127, 127, 255}) + draw.rectangle(base_layer, {0, 0, 600, 600}, {127, 127, 127, 255}) - // Measure and center text - tw, th: c.int - sdl_ttf.GetStringSize(font, "Hellope!", 0, &tw, &th) - text_w := f32(tw) / dpi - text_h := f32(th) / dpi - pos_x := (500.0 - text_w) / 2.0 - pos_y := (500.0 - text_h) / 2.0 + // ----- Text API demos ----- - txt := draw.text( - TEXT_ID, + // Cached text with id — TTF_Text reused across frames (good for text-heavy apps) + draw.text( + base_layer, "Hellope!", - {pos_x, pos_y}, + {300, 80}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, color = draw.WHITE, - font_id = JETBRAINS_MONO_REGULAR, - font_size = FONT_SIZE, + origin = draw.center_of("Hellope!", JETBRAINS_MONO_REGULAR, FONT_SIZE), + id = "hellope", + ) + + // Rotating sentence — verifies multi-word text rotation around center + draw.text( + base_layer, + "Hellope World!", + {300, 250}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, + color = {255, 200, 50, 255}, + origin = draw.center_of("Hellope World!", JETBRAINS_MONO_REGULAR, FONT_SIZE), + rotation = spin_angle, + id = "rotating_sentence", + ) + + // Uncached text (no id) — created and destroyed each frame, simplest usage + draw.text( + base_layer, + "Top-left anchored", + {20, 450}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, + color = draw.WHITE, + ) + + // 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.text( + base_layer, + "Measured!", + {300, 380}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, + color = draw.WHITE, + origin = draw.top_of("Measured!", JETBRAINS_MONO_REGULAR, FONT_SIZE), + id = "measured", + ) + + // Rotating text anchored at top-left (no origin offset) — spins around top-left corner + draw.text( + base_layer, + "Corner spin", + {150, 530}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, + color = {100, 200, 255, 255}, + rotation = spin_angle, + id = "corner_spin", ) - draw.prepare_text(base_layer, txt) draw.end(gpu, window) } diff --git a/draw/pipeline_2d_base.odin b/draw/pipeline_2d_base.odin index 2f95fe7..d17bc56 100644 --- a/draw/pipeline_2d_base.odin +++ b/draw/pipeline_2d_base.odin @@ -103,7 +103,8 @@ 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) - _pad: [2]f32, // 24: alignment to vec4 boundary + 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 } diff --git a/draw/shaders/generated/base_2d.frag.metal b/draw/shaders/generated/base_2d.frag.metal index 933919c..e03eb46 100644 --- a/draw/shaders/generated/base_2d.frag.metal +++ b/draw/shaders/generated/base_2d.frag.metal @@ -24,32 +24,41 @@ struct main0_in float4 f_params [[user(locn2)]]; float4 f_params2 [[user(locn3)]]; uint f_kind_flags [[user(locn4)]]; + float f_rotation [[user(locn5), flat]]; }; +static inline __attribute__((always_inline)) +float2 apply_rotation(thread const float2& p, thread const float& angle) +{ + 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 _56; + float2 _61; if (p.x > 0.0) { - _56 = r.xy; + _61 = r.xy; } else { - _56 = r.zw; + _61 = r.zw; } - r.x = _56.x; - r.y = _56.y; - float _73; + r.x = _61.x; + r.y = _61.y; + float _78; if (p.y > 0.0) { - _73 = r.x; + _78 = r.x; } else { - _73 = r.y; + _78 = r.y; } - r.x = _73; + 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; } @@ -142,16 +151,23 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur 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 param = in.f_local_or_uv; - float2 param_1 = b; - float4 param_2 = r; - float _453 = sdRoundedBox(param, param_1, param_2); - d = _453; + 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_3 = d; - float param_4 = stroke_px; - d = sdf_stroke(param_3, param_4); + float param_5 = d; + float param_6 = stroke_px; + d = sdf_stroke(param_5, param_6); } } else @@ -161,14 +177,14 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur 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_5 = in.f_local_or_uv; - float param_6 = radius; - d = sdCircle(param_5, param_6); + float2 param_7 = in.f_local_or_uv; + float param_8 = radius; + d = sdCircle(param_7, param_8); if ((flags & 1u) != 0u) { - float param_7 = d; - float param_8 = stroke_px_1; - d = sdf_stroke(param_7, param_8); + float param_9 = d; + float param_10 = stroke_px_1; + d = sdf_stroke(param_9, param_10); } } else @@ -178,15 +194,22 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur float2 ab = in.f_params.xy; soft = fast::max(in.f_params.z, 1.0); float stroke_px_2 = in.f_params.w; - float2 param_9 = in.f_local_or_uv; - float2 param_10 = ab; - float _511 = sdEllipse(param_9, param_10); - d = _511; + float2 p_local_1 = in.f_local_or_uv; + if (in.f_rotation != 0.0) + { + float2 param_11 = p_local_1; + float param_12 = in.f_rotation; + p_local_1 = apply_rotation(param_11, param_12); + } + float2 param_13 = p_local_1; + float2 param_14 = ab; + float _560 = sdEllipse(param_13, param_14); + d = _560; if ((flags & 1u) != 0u) { - float param_11 = d; - float param_12 = stroke_px_2; - d = sdf_stroke(param_11, param_12); + float param_15 = d; + float param_16 = stroke_px_2; + d = sdf_stroke(param_15, param_16); } } else @@ -197,10 +220,10 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur float2 b_1 = in.f_params.zw; float width = in.f_params2.x; soft = fast::max(in.f_params2.y, 1.0); - float2 param_13 = in.f_local_or_uv; - float2 param_14 = a; - float2 param_15 = b_1; - d = sdSegment(param_13, param_14, param_15) - (width * 0.5); + float2 param_17 = in.f_local_or_uv; + float2 param_18 = a; + float2 param_19 = b_1; + d = sdSegment(param_17, param_18, param_19) - (width * 0.5); } else { @@ -218,26 +241,18 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur { angle += 6.283185482025146484375; } - float ang_start = start_rad; - float ang_end = end_rad; - if (ang_start < 0.0) - { - ang_start += 6.283185482025146484375; - } - if (ang_end < 0.0) - { - ang_end += 6.283185482025146484375; - } - float _615; + float ang_start = mod(start_rad, 6.283185482025146484375); + float ang_end = mod(end_rad, 6.283185482025146484375); + float _654; if (ang_end > ang_start) { - _615 = float((angle >= ang_start) && (angle <= ang_end)); + _654 = float((angle >= ang_start) && (angle <= ang_end)); } else { - _615 = float((angle >= ang_start) || (angle <= ang_end)); + _654 = float((angle >= ang_start) || (angle <= ang_end)); } - float in_arc = _615; + float in_arc = _654; if (abs(ang_end - ang_start) >= 6.282185077667236328125) { in_arc = 1.0; @@ -262,9 +277,9 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur d = (length(p) * cos(bn)) - radius_1; if ((flags & 1u) != 0u) { - float param_16 = d; - float param_17 = stroke_px_3; - d = sdf_stroke(param_16, param_17); + float param_20 = d; + float param_21 = stroke_px_3; + d = sdf_stroke(param_20, param_21); } } } @@ -272,9 +287,9 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur } } } - float param_18 = d; - float param_19 = soft; - float alpha = sdf_alpha(param_18, param_19); + float param_22 = d; + float param_23 = soft; + float alpha = sdf_alpha(param_22, param_23); out.out_color = float4(in.f_color.xyz, in.f_color.w * alpha); return out; } diff --git a/draw/shaders/generated/base_2d.frag.spv b/draw/shaders/generated/base_2d.frag.spv index a06c2ca290f6abd5c7aa4c10b86afba177dabb9a..39179297819cb4e1f6a9a29737cab241a4c3ce03 100644 GIT binary patch literal 17776 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 literal 16820 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 w?>D~DKOt-D+A!Fc^!yv~GqU+10j14jZvX%Q literal 4412 zcmZvdiE>m`5QcBa0!mzv)rB}oKok&_O%x#sAPI^BBH%s_nF$U|X5wT4aRYH*sNywz z3}4En$|}F_+}mN8@}zoB_utFu?%U_2cg5(+EbGZuXRET?S-#d}D?l=^fwWJaJbQB2 z{6clt-hIz&@nF`Q47GWPKUW_#Td6nYH$jg5tsr#e?J&reqxwPc^i@Qm43Rfwp) z*`N%|GtG6TJl$%v+DSI=rDb^_)i6_Tv=44otxMSs5>|D?%c$>a}&EaxBl!H*m6FpF9Qh!W_F7nf?LONK~_KC z$9uuIInm6&*@NjBdKsZr0j)%@yRUt#+g8cS``{HiR~S-wkiDO zUFJH)Z^%3V*~4AfD6%W;?3vhj2WDI`Yn>#%_i7k&g**bUud;W|H^HSGZ-jA_)k$Gr z>XMD6KJ2#^vfs#BeP13-d|%?V^;@ai&aR*J$h%hO+cM_4V(ww5kCCJ^+m;xi@{6UUzzxb7IDLfV~9uKsyTAc~DX5Pxj3rzhayfrwB z>XoMoO#N%{`WsvMP4><_&lIwGMIX+?+ehs$6ng6m{d@42b(=b{HS;qp~`CF$|R;b>1yy`4RRl1;4d}-v;mb=Ity# z2bs4sYE0+ScU3&j*bVQD>f=1_gYc(ESn zG}Y$cAh%UF3OVn0h@tKk$ovjM=ChIg5agY+25VQ=?r-{NkL{;@HCN~J1mtW^kd<@! zI^-OJHw|EfA?%*LIZ&)bR5=Sv@D?ewwV=OBHYZGA4VzmxbZJI}6{&4J zIJ*fiCb>FuZy|T*J_jZq{-45&Ns;@_;+wa@jIT}HyLotV-#&Bpof0!=XKx|#ckiw< zx>(?Y+z0!&1exbJq|P|LJL1u|tMK+C90|+o&Ah($ diff --git a/draw/shaders/source/base_2d.frag b/draw/shaders/source/base_2d.frag index 013dfd1..e6af939 100644 --- a/draw/shaders/source/base_2d.frag +++ b/draw/shaders/source/base_2d.frag @@ -6,6 +6,7 @@ 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; // --- Output --- layout(location = 0) out vec4 out_color; @@ -82,6 +83,15 @@ float sdf_stroke(float d, float stroke_width) { return abs(d) - stroke_width * 0.5; } +// 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; +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -113,11 +123,16 @@ void main() { soft = max(f_params2.z, 1.0); float stroke_px = f_params2.w; - d = sdRoundedBox(f_local_or_uv, b, r); + 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); } else if (kind == 2u) { - // Circle + // Circle — rotationally symmetric, no rotation needed float radius = f_params.x; soft = max(f_params.y, 1.0); float stroke_px = f_params.z; @@ -131,11 +146,16 @@ void main() { soft = max(f_params.z, 1.0); float stroke_px = f_params.w; - d = sdEllipse(f_local_or_uv, ab); + 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); } else if (kind == 4u) { - // Segment (capsule line) + // 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; @@ -144,7 +164,7 @@ void main() { d = sdSegment(f_local_or_uv, a, b) - width * 0.5; } else if (kind == 5u) { - // Ring / Arc + // Ring / Arc — rotation handled by CPU angle offset, no shader rotation float inner = f_params.x; float outer = f_params.y; float start_rad = f_params.z; @@ -157,10 +177,8 @@ void main() { // 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 = start_rad; - float ang_end = end_rad; - if (ang_start < 0.0) ang_start += 2.0 * PI; - if (ang_end < 0.0) ang_end += 2.0 * PI; + float ang_start = mod(start_rad, 2.0 * PI); + float ang_end = mod(end_rad, 2.0 * PI); 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); @@ -169,7 +187,7 @@ void main() { d = in_arc > 0.5 ? d_ring : 1e30; } else if (kind == 6u) { - // Regular N-gon + // 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; diff --git a/draw/shaders/source/base_2d.vert b/draw/shaders/source/base_2d.vert index 2e09ec6..e72aa3b 100644 --- a/draw/shaders/source/base_2d.vert +++ b/draw/shaders/source/base_2d.vert @@ -11,6 +11,7 @@ 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; // ---------- Uniforms (single block — avoids spirv-cross reordering on Metal) ---------- layout(set = 1, binding = 0) uniform Uniforms { @@ -24,7 +25,8 @@ 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) - vec2 _pad; // 24-31: padding + 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 }; @@ -42,6 +44,7 @@ void main() { f_params = vec4(0.0); f_params2 = vec4(0.0); f_kind_flags = 0u; + f_rotation = 0.0; gl_Position = projection * vec4(v_position * dpi_scale, 0.0, 1.0); } else { @@ -57,6 +60,7 @@ void main() { f_params = p.params; f_params2 = p.params2; f_kind_flags = p.kind_flags; + f_rotation = p.rotation; gl_Position = projection * vec4(world_pos * dpi_scale, 0.0, 1.0); } diff --git a/draw/shapes.odin b/draw/shapes.odin index 0d75d74..ade129a 100644 --- a/draw/shapes.odin +++ b/draw/shapes.odin @@ -76,90 +76,6 @@ pixel :: proc(layer: ^Layer, pos: [2]f32, color: Color) { prepare_shape(layer, vertices[:]) } -rectangle :: proc( - layer: ^Layer, - rect: Rectangle, - color: Color, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - temp_allocator := context.temp_allocator, -) { - vertices := make([]Vertex, 6, temp_allocator) - - if rotation == 0 { - emit_rect(rect.x, rect.y, rect.w, rect.h, color, vertices, 0) - } else { - rad := math.to_radians(rotation) - cos_rotation := math.cos(rad) - sin_rotation := math.sin(rad) - - // Corners relative to origin - top_left := [2]f32{-origin[0], -origin[1]} - top_right := [2]f32{rect.w - origin[0], -origin[1]} - bottom_right := [2]f32{rect.w - origin[0], rect.h - origin[1]} - bottom_left := [2]f32{-origin[0], rect.h - origin[1]} - - // Translation to final position - translate := [2]f32{rect.x + origin[0], rect.y + origin[1]} - - // Rotate and translate each corner - tl := - [2]f32 { - cos_rotation * top_left[0] - sin_rotation * top_left[1], - sin_rotation * top_left[0] + cos_rotation * top_left[1], - } + - translate - tr := - [2]f32 { - cos_rotation * top_right[0] - sin_rotation * top_right[1], - sin_rotation * top_right[0] + cos_rotation * top_right[1], - } + - translate - br := - [2]f32 { - cos_rotation * bottom_right[0] - sin_rotation * bottom_right[1], - sin_rotation * bottom_right[0] + cos_rotation * bottom_right[1], - } + - translate - bl := - [2]f32 { - cos_rotation * bottom_left[0] - sin_rotation * bottom_left[1], - sin_rotation * bottom_left[0] + cos_rotation * bottom_left[1], - } + - translate - - vertices[0] = sv(tl, color) - vertices[1] = sv(tr, color) - vertices[2] = sv(br, color) - vertices[3] = sv(tl, color) - vertices[4] = sv(br, color) - vertices[5] = sv(bl, color) - } - - prepare_shape(layer, vertices) -} - -rectangle_lines :: proc( - layer: ^Layer, - rect: Rectangle, - color: Color, - thick: f32 = 1, - temp_allocator := context.temp_allocator, -) { - vertices := make([]Vertex, 24, temp_allocator) - - // Top edge - emit_rect(rect.x, rect.y, rect.w, thick, color, vertices, 0) - // Bottom edge - emit_rect(rect.x, rect.y + rect.h - thick, rect.w, thick, color, vertices, 6) - // Left edge - emit_rect(rect.x, rect.y + thick, thick, rect.h - thick * 2, color, vertices, 12) - // Right edge - emit_rect(rect.x + rect.w - thick, rect.y + thick, thick, rect.h - thick * 2, color, vertices, 18) - - prepare_shape(layer, vertices) -} - rectangle_gradient :: proc( layer: ^Layer, rect: Rectangle, @@ -189,6 +105,8 @@ circle_sector :: proc( 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, ) { @@ -202,17 +120,34 @@ circle_sector :: proc( end_rad := math.to_radians(end_angle) step_angle := (end_rad - start_rad) / f32(segs) - for i in 0 ..< segs { - current_angle := start_rad + step_angle * f32(i) - next_angle := start_rad + step_angle * f32(i + 1) + if !needs_transform(origin, rotation) { + for i in 0 ..< segs { + current_angle := start_rad + step_angle * f32(i) + next_angle := start_rad + 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} + 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] = sv(center, color) - vertices[idx + 1] = sv(edge_next, color) - vertices[idx + 2] = sv(edge_current, color) + idx := i * 3 + vertices[idx + 0] = sv(center, color) + vertices[idx + 1] = sv(edge_next, color) + vertices[idx + 2] = sv(edge_current, color) + } + } else { + xform := build_pivot_rot(center, origin, rotation) + center_local := [2]f32{0, 0} + for i in 0 ..< segs { + current_angle := start_rad + step_angle * f32(i) + next_angle := start_rad + 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] = sv(apply_transform(xform, center_local), color) + vertices[idx + 1] = sv(apply_transform(xform, edge_next), color) + vertices[idx + 2] = sv(apply_transform(xform, edge_current), color) + } } prepare_shape(layer, vertices) @@ -223,6 +158,8 @@ circle_gradient :: proc( center: [2]f32, radius: f32, inner, outer: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, segments: int = 0, temp_allocator := context.temp_allocator, ) { @@ -233,24 +170,61 @@ circle_gradient :: proc( step_angle := math.TAU / f32(segs) - for i in 0 ..< segs { - current_angle := step_angle * f32(i) - next_angle := step_angle * f32(i + 1) + if !needs_transform(origin, rotation) { + for i in 0 ..< segs { + 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} + 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] = sv(center, inner) - vertices[idx + 1] = sv(edge_next, outer) - vertices[idx + 2] = sv(edge_current, outer) + idx := i * 3 + vertices[idx + 0] = sv(center, inner) + vertices[idx + 1] = sv(edge_next, outer) + vertices[idx + 2] = sv(edge_current, outer) + } + } else { + xform := build_pivot_rot(center, origin, rotation) + center_local := [2]f32{0, 0} + for i in 0 ..< segs { + 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] = sv(apply_transform(xform, center_local), inner) + vertices[idx + 1] = sv(apply_transform(xform, edge_next), outer) + vertices[idx + 2] = sv(apply_transform(xform, edge_current), outer) + } } prepare_shape(layer, vertices) } -triangle :: proc(layer: ^Layer, v1, v2, v3: [2]f32, color: Color) { - vertices := [3]Vertex{sv(v1, color), sv(v2, color), sv(v3, color)} +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{sv(v1, color), sv(v2, color), sv(v3, color)} + prepare_shape(layer, vertices[:]) + return + } + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + xform := build_pivot_rot(mn, origin, rotation) + local_v1 := v1 - mn + local_v2 := v2 - mn + local_v3 := v3 - mn + vertices := [3]Vertex { + sv(apply_transform(xform, local_v1), color), + sv(apply_transform(xform, local_v2), color), + sv(apply_transform(xform, local_v3), color), + } prepare_shape(layer, vertices[:]) } @@ -259,13 +233,28 @@ triangle_lines :: proc( v1, v2, v3: [2]f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, temp_allocator := context.temp_allocator, ) { vertices := make([]Vertex, 18, temp_allocator) write_offset := 0 - write_offset += extrude_line(v1, v2, thick, color, vertices, write_offset) - write_offset += extrude_line(v2, v3, thick, color, vertices, write_offset) - write_offset += extrude_line(v3, v1, thick, color, vertices, write_offset) + + if !needs_transform(origin, rotation) { + write_offset += extrude_line(v1, v2, thick, color, vertices, write_offset) + write_offset += extrude_line(v2, v3, thick, color, vertices, write_offset) + write_offset += extrude_line(v3, v1, thick, color, vertices, write_offset) + } else { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + xform := build_pivot_rot(mn, origin, rotation) + tv1 := apply_transform(xform, v1 - mn) + tv2 := apply_transform(xform, v2 - mn) + tv3 := apply_transform(xform, v3 - mn) + write_offset += extrude_line(tv1, tv2, thick, color, vertices, write_offset) + write_offset += extrude_line(tv2, tv3, thick, color, vertices, write_offset) + write_offset += extrude_line(tv3, tv1, thick, color, vertices, write_offset) + } + if write_offset > 0 { prepare_shape(layer, vertices[:write_offset]) } @@ -275,6 +264,8 @@ 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 @@ -283,11 +274,26 @@ triangle_fan :: proc( vertex_count := triangle_count * 3 vertices := make([]Vertex, vertex_count, temp_allocator) - for i in 1 ..< len(points) - 1 { - idx := (i - 1) * 3 - vertices[idx + 0] = sv(points[0], color) - vertices[idx + 1] = sv(points[i], color) - vertices[idx + 2] = sv(points[i + 1], color) + if !needs_transform(origin, rotation) { + for i in 1 ..< len(points) - 1 { + idx := (i - 1) * 3 + vertices[idx + 0] = sv(points[0], color) + vertices[idx + 1] = sv(points[i], color) + vertices[idx + 2] = sv(points[i + 1], color) + } + } else { + mn := [2]f32{max(f32), max(f32)} + for p in points { + mn.x = min(mn.x, p.x) + mn.y = min(mn.y, p.y) + } + xform := build_pivot_rot(mn, origin, rotation) + for i in 1 ..< len(points) - 1 { + idx := (i - 1) * 3 + vertices[idx + 0] = sv(apply_transform(xform, points[0] - mn), color) + vertices[idx + 1] = sv(apply_transform(xform, points[i] - mn), color) + vertices[idx + 2] = sv(apply_transform(xform, points[i + 1] - mn), color) + } } prepare_shape(layer, vertices) @@ -297,6 +303,8 @@ 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 @@ -305,16 +313,37 @@ triangle_strip :: proc( vertex_count := triangle_count * 3 vertices := make([]Vertex, vertex_count, temp_allocator) - for i in 0 ..< triangle_count { - idx := i * 3 - if i % 2 == 0 { - vertices[idx + 0] = sv(points[i], color) - vertices[idx + 1] = sv(points[i + 1], color) - vertices[idx + 2] = sv(points[i + 2], color) - } else { - vertices[idx + 0] = sv(points[i + 1], color) - vertices[idx + 1] = sv(points[i], color) - vertices[idx + 2] = sv(points[i + 2], color) + if !needs_transform(origin, rotation) { + for i in 0 ..< triangle_count { + idx := i * 3 + if i % 2 == 0 { + vertices[idx + 0] = sv(points[i], color) + vertices[idx + 1] = sv(points[i + 1], color) + vertices[idx + 2] = sv(points[i + 2], color) + } else { + vertices[idx + 0] = sv(points[i + 1], color) + vertices[idx + 1] = sv(points[i], color) + vertices[idx + 2] = sv(points[i + 2], color) + } + } + } else { + mn := [2]f32{max(f32), max(f32)} + for p in points { + mn.x = min(mn.x, p.x) + mn.y = min(mn.y, p.y) + } + xform := build_pivot_rot(mn, origin, rotation) + for i in 0 ..< triangle_count { + idx := i * 3 + if i % 2 == 0 { + vertices[idx + 0] = sv(apply_transform(xform, points[i] - mn), color) + vertices[idx + 1] = sv(apply_transform(xform, points[i + 1] - mn), color) + vertices[idx + 2] = sv(apply_transform(xform, points[i + 2] - mn), color) + } else { + vertices[idx + 0] = sv(apply_transform(xform, points[i + 1] - mn), color) + vertices[idx + 1] = sv(apply_transform(xform, points[i] - mn), color) + vertices[idx + 2] = sv(apply_transform(xform, points[i + 2] - mn), color) + } } } @@ -323,8 +352,68 @@ triangle_strip :: proc( // ----- SDF drawing functions ---- +// Compute new center position after rotating a center-parametrized shape +// around a pivot point. The pivot is at (center + origin) in world space. +@(private = "file") +compute_pivot_center :: proc(center: [2]f32, origin: [2]f32, rotation_deg: f32) -> [2]f32 { + if origin == {0, 0} do return center + theta := math.to_radians(rotation_deg) + c, s := math.cos(theta), math.sin(theta) + // pivot = center + origin; new_center = pivot + R(θ) * (center - pivot) + return center + origin + {c * (-origin.x) - s * (-origin.y), s * (-origin.x) + c * (-origin.y)} +} + +// Compute the AABB half-extents of a rectangle with half-size (hx, hy) rotated by rot_rad. +@(private = "file") +rotated_aabb_half :: proc(hx, hy, rot_rad: f32) -> [2]f32 { + c_r := abs(math.cos(rot_rad)) + s_r := abs(math.sin(rot_rad)) + return {hx * c_r + hy * s_r, hx * s_r + hy * c_r} +} + +// 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. +rectangle :: proc( + layer: ^Layer, + rect: Rectangle, + color: Color, + roundness: f32 = 0, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + soft_px: f32 = 1.0, +) { + cr := min(rect.w, rect.h) * 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. +rectangle_lines :: proc( + layer: ^Layer, + rect: Rectangle, + color: Color, + thick: f32 = 1, + roundness: f32 = 0, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + soft_px: f32 = 1.0, +) { + cr := min(rect.w, rect.h) * clamp(roundness, 0, 1) * 0.5 + rectangle_corners_lines(layer, rect, {cr, cr, cr, cr}, color, thick, origin, rotation, soft_px) +} + // Draw a rectangle with per-corner rounding radii via SDF. -rectangle_corners :: proc(layer: ^Layer, rect: Rectangle, radii: [4]f32, color: Color, soft_px: f32 = 1.0) { +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.w, rect.h) * 0.5 tl := clamp(radii[0], 0, max_radius) tr := clamp(radii[1], 0, max_radius) @@ -334,13 +423,35 @@ rectangle_corners :: proc(layer: ^Layer, rect: Rectangle, radii: [4]f32, color: pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + hx := rect.w * 0.5 + hy := rect.h * 0.5 + rot_rad: f32 = 0 + center_x := rect.x + hx + center_y := rect.y + hy + + if needs_transform(origin, rotation) { + rot_rad = math.to_radians(rotation) + xform := build_pivot_rot({rect.x, rect.y}, origin, rotation) + new_center := apply_transform(xform, {hx, hy}) + center_x = new_center.x + center_y = new_center.y + } + + bhx, bhy := hx, hy + if rot_rad != 0 { + expanded := rotated_aabb_half(hx, hy, rot_rad) + bhx = expanded.x + bhy = expanded.y + } + prim := Primitive { - bounds = {rect.x - pad, rect.y - pad, rect.x + rect.w + pad, rect.y + rect.h + pad}, + bounds = {center_x - bhx - pad, center_y - bhy - pad, center_x + bhx + pad, center_y + bhy + pad}, color = color, kind_flags = pack_kind_flags(.RRect, {}), + rotation = rot_rad, } prim.params.rrect = RRect_Params { - half_size = {rect.w * 0.5 * dpi, rect.h * 0.5 * dpi}, + half_size = {hx * dpi, hy * dpi}, radii = {tr * dpi, br * dpi, tl * dpi, bl * dpi}, soft_px = soft_px, stroke_px = 0, @@ -355,6 +466,8 @@ rectangle_corners_lines :: proc( radii: [4]f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { max_radius := min(rect.w, rect.h) * 0.5 @@ -366,13 +479,35 @@ rectangle_corners_lines :: proc( pad := (thick * 0.5 + soft_px) / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + hx := rect.w * 0.5 + hy := rect.h * 0.5 + rot_rad: f32 = 0 + center_x := rect.x + hx + center_y := rect.y + hy + + if needs_transform(origin, rotation) { + rot_rad = math.to_radians(rotation) + xform := build_pivot_rot({rect.x, rect.y}, origin, rotation) + new_center := apply_transform(xform, {hx, hy}) + center_x = new_center.x + center_y = new_center.y + } + + bhx, bhy := hx, hy + if rot_rad != 0 { + expanded := rotated_aabb_half(hx, hy, rot_rad) + bhx = expanded.x + bhy = expanded.y + } + prim := Primitive { - bounds = {rect.x - pad, rect.y - pad, rect.x + rect.w + pad, rect.y + rect.h + pad}, + bounds = {center_x - bhx - pad, center_y - bhy - pad, center_x + bhx + pad, center_y + bhy + pad}, color = color, kind_flags = pack_kind_flags(.RRect, {.Stroke}), + rotation = rot_rad, } prim.params.rrect = RRect_Params { - half_size = {rect.w * 0.5 * dpi, rect.h * 0.5 * dpi}, + half_size = {hx * dpi, hy * dpi}, radii = {tr * dpi, br * dpi, tl * dpi, bl * dpi}, soft_px = soft_px, stroke_px = thick * dpi, @@ -380,47 +515,34 @@ rectangle_corners_lines :: proc( prepare_sdf_primitive(layer, prim) } -// Draw a rectangle with uniform corner rounding via SDF. -rectangle_rounded :: proc(layer: ^Layer, rect: Rectangle, roundness: f32, color: Color, soft_px: f32 = 1.0) { - cr := min(rect.w, rect.h) * clamp(roundness, 0, 1) * 0.5 - if cr < 1 { - rectangle(layer, rect, color) - return - } - rectangle_corners(layer, rect, {cr, cr, cr, cr}, color, soft_px) -} - -// Draw a stroked rectangle with uniform corner rounding via SDF. -rectangle_rounded_lines :: proc( +// Draw a filled circle via SDF. +circle :: proc( layer: ^Layer, - rect: Rectangle, - roundness: f32, + center: [2]f32, + radius: f32, color: Color, - thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { - cr := min(rect.w, rect.h) * clamp(roundness, 0, 1) * 0.5 - if cr < 1 { - rectangle_lines(layer, rect, color, thick) - return - } - rectangle_corners_lines(layer, rect, {cr, cr, cr, cr}, color, thick, soft_px) -} - -// Draw a filled circle via SDF. -circle :: proc(layer: ^Layer, center: [2]f32, radius: f32, color: Color, soft_px: f32 = 1.0) { pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + if origin != {0, 0} { + actual_center = compute_pivot_center(center, origin, rotation) + } + prim := Primitive { bounds = { - center.x - radius - pad, - center.y - radius - pad, - center.x + radius + pad, - center.y + radius + pad, + actual_center.x - radius - pad, + actual_center.y - radius - pad, + actual_center.x + radius + pad, + actual_center.y + radius + pad, }, color = color, kind_flags = pack_kind_flags(.Circle, {}), + // rotation stays 0 — circle is rotationally symmetric } prim.params.circle = Circle_Params { radius = radius * dpi, @@ -436,17 +558,24 @@ circle_lines :: proc( radius: f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { pad := (thick * 0.5 + soft_px) / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + if origin != {0, 0} { + actual_center = compute_pivot_center(center, origin, rotation) + } + prim := Primitive { bounds = { - center.x - radius - pad, - center.y - radius - pad, - center.x + radius + pad, - center.y + radius + pad, + actual_center.x - radius - pad, + actual_center.y - radius - pad, + actual_center.x + radius + pad, + actual_center.y + radius + pad, }, color = color, kind_flags = pack_kind_flags(.Circle, {.Stroke}), @@ -460,19 +589,43 @@ circle_lines :: proc( } // Draw a filled ellipse via SDF. -ellipse :: proc(layer: ^Layer, center: [2]f32, radius_h, radius_v: f32, color: Color, soft_px: f32 = 1.0) { +ellipse :: proc( + layer: ^Layer, + center: [2]f32, + radius_h, radius_v: f32, + color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + soft_px: f32 = 1.0, +) { pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + rot_rad: f32 = 0 + if needs_transform(origin, rotation) { + actual_center = compute_pivot_center(center, origin, rotation) + rot_rad = math.to_radians(rotation) + } + + // When rotated, expand the bounds AABB to enclose the rotated ellipse + bound_h, bound_v := radius_h, radius_v + if rot_rad != 0 { + expanded := rotated_aabb_half(radius_h, radius_v, rot_rad) + bound_h = expanded.x + bound_v = expanded.y + } + prim := Primitive { bounds = { - center.x - radius_h - pad, - center.y - radius_v - pad, - center.x + radius_h + pad, - center.y + radius_v + pad, + actual_center.x - bound_h - pad, + actual_center.y - bound_v - pad, + actual_center.x + bound_h + pad, + actual_center.y + bound_v + pad, }, color = color, kind_flags = pack_kind_flags(.Ellipse, {}), + rotation = rot_rad, } prim.params.ellipse = Ellipse_Params { radii = {radius_h * dpi, radius_v * dpi}, @@ -488,22 +641,40 @@ ellipse_lines :: proc( radius_h, radius_v: f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { // 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. - pad := (max(radius_h, radius_v) * 0.1 + thick * 0.5 + soft_px) / GLOB.dpi_scaling + extra := max(radius_h, radius_v) * 0.1 + thick * 0.5 + pad := (extra + soft_px) / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + rot_rad: f32 = 0 + if needs_transform(origin, rotation) { + actual_center = compute_pivot_center(center, origin, rotation) + rot_rad = math.to_radians(rotation) + } + + bound_h, bound_v := radius_h, radius_v + if rot_rad != 0 { + expanded := rotated_aabb_half(radius_h, radius_v, rot_rad) + bound_h = expanded.x + bound_v = expanded.y + } + prim := Primitive { bounds = { - center.x - radius_h - pad, - center.y - radius_v - pad, - center.x + radius_h + pad, - center.y + radius_v + pad, + actual_center.x - bound_h - pad, + actual_center.y - bound_v - pad, + actual_center.x + bound_h + pad, + actual_center.y + bound_v + pad, }, color = color, kind_flags = pack_kind_flags(.Ellipse, {.Stroke}), + rotation = rot_rad, } prim.params.ellipse = Ellipse_Params { radii = {radius_h * dpi, radius_v * dpi}, @@ -520,26 +691,36 @@ ring :: proc( inner_radius, outer_radius: f32, start_angle, end_angle: f32, color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { pad := soft_px / GLOB.dpi_scaling dpi := 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 = { - center.x - outer_radius - pad, - center.y - outer_radius - pad, - center.x + outer_radius + pad, - center.y + outer_radius + pad, + actual_center.x - outer_radius - pad, + actual_center.y - outer_radius - pad, + actual_center.x + outer_radius + pad, + actual_center.y + outer_radius + pad, }, 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, outer_radius = outer_radius * dpi, - start_rad = math.to_radians(start_angle), - end_rad = math.to_radians(end_angle), + start_rad = math.to_radians(start_angle) + rotation_offset, + end_rad = math.to_radians(end_angle) + rotation_offset, soft_px = soft_px, } prepare_sdf_primitive(layer, prim) @@ -553,39 +734,50 @@ ring_lines :: proc( start_angle, end_angle: f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { - // Inner arc outline + // Compute effective angles and pivot-translated center up front + eff_start := start_angle + rotation + eff_end := end_angle + rotation + + actual_center := center + if needs_transform(origin, rotation) { + actual_center = compute_pivot_center(center, origin, rotation) + } + + // Inner arc outline (pass already-transformed center; no further origin/rotation) ring( layer, - center, + actual_center, max(0, inner_radius - thick * 0.5), inner_radius + thick * 0.5, - start_angle, - end_angle, + eff_start, + eff_end, color, - soft_px, + soft_px = soft_px, ) // Outer arc outline ring( layer, - center, + actual_center, max(0, outer_radius - thick * 0.5), outer_radius + thick * 0.5, - start_angle, - end_angle, + eff_start, + eff_end, color, - soft_px, + soft_px = soft_px, ) // Start cap - start_rad := math.to_radians(start_angle) - end_rad := math.to_radians(end_angle) - inner_start := center + {math.cos(start_rad) * inner_radius, math.sin(start_rad) * inner_radius} - outer_start := center + {math.cos(start_rad) * outer_radius, math.sin(start_rad) * outer_radius} + start_rad := math.to_radians(eff_start) + end_rad := math.to_radians(eff_end) + inner_start := actual_center + {math.cos(start_rad) * inner_radius, math.sin(start_rad) * inner_radius} + outer_start := actual_center + {math.cos(start_rad) * outer_radius, math.sin(start_rad) * outer_radius} line(layer, inner_start, outer_start, color, thick, soft_px) // End cap - inner_end := center + {math.cos(end_rad) * inner_radius, math.sin(end_rad) * inner_radius} - outer_end := center + {math.cos(end_rad) * outer_radius, math.sin(end_rad) * outer_radius} + inner_end := actual_center + {math.cos(end_rad) * inner_radius, math.sin(end_rad) * inner_radius} + outer_end := actual_center + {math.cos(end_rad) * outer_radius, math.sin(end_rad) * outer_radius} line(layer, inner_end, outer_end, color, thick, soft_px) } @@ -632,18 +824,24 @@ poly :: proc( radius: f32, color: Color, rotation: f32 = 0, + origin: [2]f32 = {0, 0}, soft_px: f32 = 1.0, ) { if sides < 3 do return pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + if origin != {0, 0} && rotation != 0 { + actual_center = compute_pivot_center(center, origin, rotation) + } + prim := Primitive { bounds = { - center.x - radius - pad, - center.y - radius - pad, - center.x + radius + pad, - center.y + radius + pad, + actual_center.x - radius - pad, + actual_center.y - radius - pad, + actual_center.x + radius + pad, + actual_center.y + radius + pad, }, color = color, kind_flags = pack_kind_flags(.NGon, {}), @@ -665,6 +863,7 @@ poly_lines :: proc( radius: f32, color: Color, rotation: f32 = 0, + origin: [2]f32 = {0, 0}, thick: f32 = 1, soft_px: f32 = 1.0, ) { @@ -672,12 +871,17 @@ poly_lines :: proc( pad := (thick * 0.5 + soft_px) / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + if origin != {0, 0} && rotation != 0 { + actual_center = compute_pivot_center(center, origin, rotation) + } + prim := Primitive { bounds = { - center.x - radius - pad, - center.y - radius - pad, - center.x + radius + pad, - center.y + radius + pad, + actual_center.x - radius - pad, + actual_center.y - radius - pad, + actual_center.x + radius + pad, + actual_center.y + radius + pad, }, color = color, kind_flags = pack_kind_flags(.NGon, {.Stroke}), @@ -691,3 +895,103 @@ poly_lines :: proc( } prepare_sdf_primitive(layer, prim) } + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Anchor helpers ---------------- +// --------------------------------------------------------------------------------------------------------------------- +// +// Return [2]f32 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 rect's top-left) -------------------------------------------------- + +center_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w * 0.5, r.h * 0.5} +} + +top_left_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {0, 0} +} + +top_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w * 0.5, 0} +} + +top_right_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w, 0} +} + +left_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {0, r.h * 0.5} +} + +right_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w, r.h * 0.5} +} + +bottom_left_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {0, r.h} +} + +bottom_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w * 0.5, r.h} +} + +bottom_right_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w, r.h} +} + +// ----- Triangle anchors (origin measured from AABB top-left) ----------------------------------------------------- + +center_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + return (v1 + v2 + v3) / 3 - mn +} + +top_left_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + return {0, 0} +} + +top_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn_x := min(v1.x, v2.x, v3.x) + mx_x := max(v1.x, v2.x, v3.x) + return {(mx_x - mn_x) * 0.5, 0} +} + +top_right_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn_x := min(v1.x, v2.x, v3.x) + mx_x := max(v1.x, v2.x, v3.x) + return {mx_x - mn_x, 0} +} + +left_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn_y := min(v1.y, v2.y, v3.y) + mx_y := max(v1.y, v2.y, v3.y) + return {0, (mx_y - mn_y) * 0.5} +} + +right_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + mx := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)} + return {mx.x - mn.x, (mx.y - mn.y) * 0.5} +} + +bottom_left_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn_y := min(v1.y, v2.y, v3.y) + mx_y := max(v1.y, v2.y, v3.y) + return {0, mx_y - mn_y} +} + +bottom_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + mx := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)} + return {(mx.x - mn.x) * 0.5, mx.y - mn.y} +} + +bottom_right_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + mx := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)} + return mx - mn +} diff --git a/draw/text.odin b/draw/text.odin index 4c04bda..742053b 100644 --- a/draw/text.odin +++ b/draw/text.odin @@ -1,6 +1,9 @@ package draw +import "core:c" +import "core:hash" import "core:log" +import "core:strings" import sdl "vendor:sdl3" import sdl_ttf "vendor:sdl3/ttf" @@ -71,32 +74,195 @@ Text :: struct { color: Color, } -text :: proc( - id: u32, - txt: cstring, - pos: [2]f32, - font_id: Font_Id, - font_size: u16 = 44, - color: Color = {0, 0, 0, 255}, -) -> Text { - sdl_text := GLOB.text_cache.cache[id] - if sdl_text == nil { - sdl_text = sdl_ttf.CreateText(GLOB.text_cache.engine, get_font(font_id, font_size), txt, 0) +// --------------------------------------------------------------------------------------------------------------------- +// ----- Text cache hashing ------------ +// --------------------------------------------------------------------------------------------------------------------- + +// Hash a string to a u32 cache key using the same Jenkins one-at-a-time algorithm as Clay. +// This means Clay element IDs and user-chosen string IDs share the same keyspace — same +// string produces the same cache key regardless of whether it came from Clay or user code. +text_cache_hash :: #force_inline proc(s: string) -> u32 { + return hash.jenkins(transmute([]u8)s) + 1 // +1 reserves 0 as "no entry" (matches Clay convention) +} + +// Shared cache lookup/create/update logic used by both the `text` proc and the Clay render path. +// Returns the cached (or newly created) TTF_Text pointer. +@(private) +cache_get_or_update :: proc(cache_id: u32, c_str: cstring, font: ^sdl_ttf.Font) -> ^sdl_ttf.Text { + existing, found := GLOB.text_cache.cache[cache_id] + if !found { + sdl_text := sdl_ttf.CreateText(GLOB.text_cache.engine, font, c_str, 0) if sdl_text == nil { log.panicf("Failed to create SDL text: %s", sdl.GetError()) } - GLOB.text_cache.cache[id] = sdl_text + GLOB.text_cache.cache[cache_id] = sdl_text + return sdl_text } else { - //TODO if IDs are always unique and never change the underlying text - // can get rid of this - if !sdl_ttf.SetTextString(sdl_text, txt, 0) { + if !sdl_ttf.SetTextString(existing, c_str, 0) { log.panicf("Failed to update SDL text string: %s", sdl.GetError()) } + return existing + } +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Text drawing ------------------ +// --------------------------------------------------------------------------------------------------------------------- + +// Draw text at a position with optional rotation and origin. +// +// When `id` is nil (the default), the text is created and destroyed each frame — simple and +// leak-free, appropriate for HUDs and moderate UI (up to ~50 text elements per frame). +// +// When `id` is set, the TTF_Text object is cached across frames using a hash of the provided +// string (same algorithm as Clay's element IDs). This avoids per-frame HarfBuzz shaping and +// allocation, which matters for text-heavy apps (editors, terminals, chat). The user is +// responsible for choosing unique ID strings per logical text element and calling +// `clear_text_cache` or `clear_text_cache_entry` when cached entries are no longer needed. +// +// `origin` is in pixels from the text block's top-left corner (raylib convention). +// The point whose local coords equal `origin` lands at `pos` in world space. +// `rotation` is in degrees, counter-clockwise. +text :: proc( + layer: ^Layer, + str: string, + pos: [2]f32, + font_id: Font_Id, + font_size: u16 = 44, + color: Color = BLACK, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + id: Maybe(string) = nil, + temp_allocator := context.temp_allocator, +) { + c_str := strings.clone_to_cstring(str, temp_allocator) + + sdl_text: ^sdl_ttf.Text + cached := false + + if id_str, ok := id.?; ok { + cached = true + sdl_text = cache_get_or_update(text_cache_hash(id_str), c_str, get_font(font_id, font_size)) + } else { + sdl_text = sdl_ttf.CreateText(GLOB.text_cache.engine, get_font(font_id, font_size), c_str, 0) + if sdl_text == nil { + log.panicf("Failed to create SDL text: %s", sdl.GetError()) + } } - return Text{sdl_text, pos, color} + if needs_transform(origin, rotation) { + dpi := GLOB.dpi_scaling + xform := build_pivot_rot(pos * dpi, origin * dpi, rotation) + prepare_text_transformed(layer, Text{sdl_text, {0, 0}, color}, xform) + } else { + prepare_text(layer, Text{sdl_text, pos, color}) + } + + if !cached { + // Don't destroy now — the draw data (atlas texture, vertices) is still referenced + // by the batch buffers until end() submits to the GPU. Deferred to clear_global(). + append(&GLOB.tmp_uncached_text, sdl_text) + } } +// --------------------------------------------------------------------------------------------------------------------- +// ----- Public text measurement ------- +// --------------------------------------------------------------------------------------------------------------------- + +// Measure a string in logical pixels (pre-DPI-scaling) using the same font backend as the renderer. +measure_text :: proc( + str: string, + font_id: Font_Id, + font_size: u16 = 44, + allocator := context.temp_allocator, +) -> [2]f32 { + c_str := strings.clone_to_cstring(str, allocator) + w, h: c.int + if !sdl_ttf.GetStringSize(get_font(font_id, font_size), c_str, 0, &w, &h) { + log.panicf("Failed to measure text: %s", sdl.GetError()) + } + return {f32(w) / GLOB.dpi_scaling, f32(h) / GLOB.dpi_scaling} +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Text anchor helpers ----------- +// --------------------------------------------------------------------------------------------------------------------- + +center_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return size * 0.5 +} + +top_left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + return {0, 0} +} + +top_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {size.x * 0.5, 0} +} + +top_right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {size.x, 0} +} + +left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {0, size.y * 0.5} +} + +right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {size.x, size.y * 0.5} +} + +bottom_left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {0, size.y} +} + +bottom_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {size.x * 0.5, size.y} +} + +bottom_right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return size +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Cache management -------------- +// --------------------------------------------------------------------------------------------------------------------- + +// Destroy all cached text objects. Call on scene transitions, view changes, or periodically +// in apps that produce many distinct cached text entries over time. +// After calling this, subsequent text draws with an `id` will re-create their cache entries. +clear_text_cache :: proc() { + for _, sdl_text in GLOB.text_cache.cache { + sdl_ttf.DestroyText(sdl_text) + } + clear(&GLOB.text_cache.cache) +} + +// Destroy a specific cached text entry by its string id (same string used when drawing). +// Uses the same hash as Clay's element IDs, so this also works for clearing Clay text entries +// by passing the same string used in clay.ID("..."). +// No-op if the id is not in the cache. +clear_text_cache_entry :: proc(id: string) { + key := text_cache_hash(id) + sdl_text, ok := GLOB.text_cache.cache[key] + if ok { + sdl_ttf.DestroyText(sdl_text) + delete_key(&GLOB.text_cache.cache, key) + } +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Internal cache lifecycle ------ +// --------------------------------------------------------------------------------------------------------------------- + @(private, require_results) init_text_cache :: proc( device: ^sdl.GPUDevice, @@ -132,6 +298,9 @@ destroy_text_cache :: proc() { for _, font in GLOB.text_cache.sdl_fonts { sdl_ttf.CloseFont(font) } + for _, sdl_text in GLOB.text_cache.cache { + sdl_ttf.DestroyText(sdl_text) + } delete(GLOB.text_cache.sdl_fonts) delete(GLOB.text_cache.font_bytes) delete(GLOB.text_cache.cache)