Added improved non-clay text handling along with consistent origin and rotation API

This commit is contained in:
Zachary Levy
2026-04-19 18:28:42 -07:00
parent 30b72128b2
commit 7a21d6f253
12 changed files with 1157 additions and 388 deletions

View File

@@ -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,
}