Custom draw

tuneup
This commit is contained in:
Zachary Levy
2026-04-19 21:11:26 -07:00
parent 0953462b3b
commit 90fba74243
5 changed files with 180 additions and 41 deletions

View File

@@ -352,6 +352,11 @@ prepare_text :: proc(layer: ^Layer, text: Text) {
scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
// Snap base position to integer physical pixels to avoid atlas sub-pixel
// sampling blur (and the off-by-one bottom-row clip that comes with it).
base_x := math.round(text.position[0] * GLOB.dpi_scaling)
base_y := math.round(text.position[1] * GLOB.dpi_scaling)
for data != nil {
vertex_start := u32(len(GLOB.tmp_text_verts))
index_start := u32(len(GLOB.tmp_text_indices))
@@ -363,7 +368,7 @@ prepare_text :: proc(layer: ^Layer, text: Text) {
append(
&GLOB.tmp_text_verts,
Vertex {
position = {pos.x + text.position[0] * GLOB.dpi_scaling, -pos.y + text.position[1] * GLOB.dpi_scaling},
position = {pos.x + base_x, -pos.y + base_y},
uv = {uv.x, uv.y},
color = text.color,
},
@@ -471,6 +476,19 @@ clay_error_handler :: proc "c" (errorData: clay.ErrorData) {
log.error("Clay error:", errorData.errorType, errorData.errorText)
}
// Called for each Clay `RenderCommandType.Custom` render command that
// `prepare_clay_batch` encounters.
//
// - `layer` is the layer the command belongs to (post-z-index promotion).
// - `bounds` is already translated into the active layer's coordinate system
// and pre-DPI, matching what the built-in shape procs expect.
// - `render_data` is Clay's `CustomRenderData` for the element, exposing
// `backgroundColor`, `cornerRadius`, and the `customData` pointer the caller
// attached to `clay.CustomElementConfig.customData`.
//
// The callback must not call `new_layer` or `prepare_clay_batch`.
Custom_Draw :: #type proc(layer: ^Layer, bounds: Rectangle, render_data: clay.CustomRenderData)
ClayBatch :: struct {
bounds: Rectangle,
cmds: clay.ClayArray(clay.RenderCommand),
@@ -482,6 +500,7 @@ prepare_clay_batch :: proc(
batch: ^ClayBatch,
mouse_wheel_delta: [2]f32,
frame_time: f32 = 0,
custom_draw: Custom_Draw = nil,
) {
mouse_pos: [2]f32
mouse_flags := sdl.GetMouseState(&mouse_pos.x, &mouse_pos.y)
@@ -522,10 +541,10 @@ 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)
// Clay's render_command.id is already hashed with the same Jenkins algorithm
// as text_cache_hash, so it shares the same keyspace.
// Clay render-command IDs are derived via Clay's internal HashNumber (Jenkins-family)
// and namespaced with .Clay so they can never collide with user-provided custom text IDs.
sdl_text := cache_get_or_update(
render_command.id,
Cache_Key{render_command.id, .Clay},
c_text,
get_font(render_data.fontId, render_data.fontSize),
)
@@ -581,7 +600,9 @@ prepare_clay_batch :: proc(
} else {
rectangle_corners_lines(layer, bounds, radii, color, thickness)
}
case clay.RenderCommandType.Custom:
case clay.RenderCommandType.Custom: if custom_draw != nil {
custom_draw(layer, bounds, render_command.renderData.custom)
}
}
}
}

View File

@@ -2,6 +2,7 @@ package examples
import "../../draw"
import "../../vendor/clay"
import "core:math"
import "core:os"
import sdl "vendor:sdl3"
@@ -108,6 +109,11 @@ hellope_shapes :: proc() {
}
hellope_text :: proc() {
HELLOPE_ID :: 1
ROTATING_SENTENCE_ID :: 2
MEASURED_ID :: 3
CORNER_SPIN_ID :: 4
if !sdl.Init({.VIDEO}) do os.exit(1)
window := sdl.CreateWindow("Hellope!", 600, 600, {.HIGH_PIXEL_DENSITY})
gpu := sdl.CreateGPUDevice({.MSL}, true, nil)
@@ -141,7 +147,7 @@ hellope_text :: proc() {
FONT_SIZE,
color = draw.WHITE,
origin = draw.center_of("Hellope!", JETBRAINS_MONO_REGULAR, FONT_SIZE),
id = "hellope",
id = HELLOPE_ID,
)
// Rotating sentence — verifies multi-word text rotation around center
@@ -154,7 +160,7 @@ hellope_text :: proc() {
color = {255, 200, 50, 255},
origin = draw.center_of("Hellope World!", JETBRAINS_MONO_REGULAR, FONT_SIZE),
rotation = spin_angle,
id = "rotating_sentence",
id = ROTATING_SENTENCE_ID,
)
// Uncached text (no id) — created and destroyed each frame, simplest usage
@@ -178,7 +184,7 @@ hellope_text :: proc() {
FONT_SIZE,
color = draw.WHITE,
origin = draw.top_of("Measured!", JETBRAINS_MONO_REGULAR, FONT_SIZE),
id = "measured",
id = MEASURED_ID,
)
// Rotating text anchored at top-left (no origin offset) — spins around top-left corner
@@ -190,7 +196,7 @@ hellope_text :: proc() {
FONT_SIZE,
color = {100, 200, 255, 255},
rotation = spin_angle,
id = "corner_spin",
id = CORNER_SPIN_ID,
)
draw.end(gpu, window)
@@ -207,7 +213,7 @@ hellope_clay :: proc() {
text_config := clay.TextElementConfig {
fontId = JETBRAINS_MONO_REGULAR,
fontSize = 24,
fontSize = 36,
textColor = {255, 255, 255, 255},
}
@@ -240,3 +246,106 @@ hellope_clay :: proc() {
draw.end(gpu, window)
}
}
hellope_custom :: proc() {
if !sdl.Init({.VIDEO}) do os.exit(1)
window := sdl.CreateWindow("Hellope Custom!", 600, 400, {.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)
text_config := clay.TextElementConfig {
fontId = JETBRAINS_MONO_REGULAR,
fontSize = 24,
textColor = {255, 255, 255, 255},
}
gauge := Gauge {
value = 0.73,
color = {50, 200, 100, 255},
}
gauge2 := Gauge {
value = 0.45,
color = {200, 100, 50, 255},
}
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
gauge.value = (math.sin(spin_angle * 0.02) + 1) * 0.5
gauge2.value = (math.cos(spin_angle * 0.03) + 1) * 0.5
base_layer := draw.begin({width = 600, height = 400})
clay.SetLayoutDimensions({width = base_layer.bounds.width, height = base_layer.bounds.height})
clay.BeginLayout()
if clay.UI()(
{
id = clay.ID("outer"),
layout = {
sizing = {clay.SizingGrow({}), clay.SizingGrow({})},
childAlignment = {x = .Center, y = .Center},
layoutDirection = .TopToBottom,
childGap = 20,
},
backgroundColor = {50, 50, 50, 255},
},
) {
if clay.UI()({id = clay.ID("title"), layout = {sizing = {clay.SizingFit({}), clay.SizingFit({})}}}) {
clay.Text("Custom Draw Demo", &text_config)
}
if clay.UI()(
{
id = clay.ID("gauge"),
layout = {sizing = {clay.SizingFixed(300), clay.SizingFixed(30)}},
custom = {customData = &gauge},
backgroundColor = {80, 80, 80, 255},
},
) {}
if clay.UI()(
{
id = clay.ID("gauge2"),
layout = {sizing = {clay.SizingFixed(300), clay.SizingFixed(30)}},
custom = {customData = &gauge2},
backgroundColor = {80, 80, 80, 255},
},
) {}
}
clay_batch := draw.ClayBatch {
bounds = base_layer.bounds,
cmds = clay.EndLayout(),
}
draw.prepare_clay_batch(base_layer, &clay_batch, {0, 0}, custom_draw = draw_custom)
draw.end(gpu, window)
}
Gauge :: struct {
value: f32,
color: draw.Color,
}
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)
// 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)
}
}

View File

@@ -57,17 +57,18 @@ main :: proc() {
args := os.args
if len(args) < 2 {
fmt.eprintln("Usage: examples <example_name>")
fmt.eprintln("Available examples: hellope-shapes, hellope-text, hellope-clay")
fmt.eprintln("Available examples: hellope-shapes, hellope-text, hellope-clay, hellope-custom")
os.exit(1)
}
switch args[1] {
case "hellope-clay": hellope_clay()
case "hellope-custom": hellope_custom()
case "hellope-shapes": hellope_shapes()
case "hellope-text": hellope_text()
case:
fmt.eprintf("Unknown example: %v\n", args[1])
fmt.eprintln("Available examples: hellope-shapes, hellope-text, hellope-clay")
fmt.eprintln("Available examples: hellope-shapes, hellope-text, hellope-clay, hellope-custom")
os.exit(1)
}
}

View File

@@ -1,7 +1,6 @@
package draw
import "core:c"
import "core:hash"
import "core:log"
import "core:strings"
import sdl "vendor:sdl3"
@@ -14,11 +13,21 @@ Font_Key :: struct {
size: u16,
}
Cache_Source :: enum u8 {
Custom,
Clay,
}
Cache_Key :: struct {
id: u32,
source: Cache_Source,
}
Text_Cache :: struct {
engine: ^sdl_ttf.TextEngine,
font_bytes: [dynamic][]u8,
sdl_fonts: map[Font_Key]^sdl_ttf.Font,
cache: map[u32]^sdl_ttf.Text,
cache: map[Cache_Key]^sdl_ttf.Text,
}
// Internal for fetching SDL TTF font pointer for rendering
@@ -75,27 +84,20 @@ Text :: struct {
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Text cache hashing ------------
// ----- Text cache lookup -------------
// ---------------------------------------------------------------------------------------------------------------------
// 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(text_string: string) -> u32 {
return hash.jenkins(transmute([]u8)text_string) + 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]
cache_get_or_update :: proc(key: Cache_Key, c_str: cstring, font: ^sdl_ttf.Font) -> ^sdl_ttf.Text {
existing, found := GLOB.text_cache.cache[key]
if !found {
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[cache_id] = sdl_text
GLOB.text_cache.cache[key] = sdl_text
return sdl_text
} else {
if !sdl_ttf.SetTextString(existing, c_str, 0) {
@@ -114,11 +116,12 @@ cache_get_or_update :: proc(cache_id: u32, c_str: cstring, font: ^sdl_ttf.Font)
// 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.
// When `id` is set, the TTF_Text object is cached across frames keyed by the provided u32.
// This avoids per-frame HarfBuzz shaping and allocation, which matters for text-heavy apps
// (editors, terminals, chat). The user is responsible for choosing unique IDs per logical text
// element and calling `clear_text_cache` or `clear_text_cache_entry` when cached entries are
// no longer needed. Custom text IDs occupy a separate namespace from Clay text IDs, so
// collisions between the two are impossible.
//
// `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.
@@ -132,7 +135,7 @@ text :: proc(
color: Color = BLACK,
origin: [2]f32 = {0, 0},
rotation: f32 = 0,
id: Maybe(string) = nil,
id: Maybe(u32) = nil,
temp_allocator := context.temp_allocator,
) {
c_str := strings.clone_to_cstring(text_string, temp_allocator)
@@ -140,9 +143,9 @@ text :: proc(
sdl_text: ^sdl_ttf.Text
cached := false
if id_string, ok := id.?; ok {
if cache_id, ok := id.?; ok {
cached = true
sdl_text = cache_get_or_update(text_cache_hash(id_string), c_str, get_font(font_id, font_size))
sdl_text = cache_get_or_update(Cache_Key{cache_id, .Custom}, 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 {
@@ -236,8 +239,8 @@ bottom_right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u
// ----- 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.
// Destroy all cached text objects (both custom and Clay entries). 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 {
@@ -246,12 +249,12 @@ clear_text_cache :: proc() {
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("...").
// Destroy a specific cached custom text entry by its u32 id (the same value passed to the
// `text` proc's `id` parameter). This only affects custom text entries — Clay text entries
// are managed internally and are not addressable by the user.
// No-op if the id is not in the cache.
clear_text_cache_entry :: proc(id: string) {
key := text_cache_hash(id)
clear_text_cache_entry :: proc(id: u32) {
key := Cache_Key{id, .Custom}
sdl_text, ok := GLOB.text_cache.cache[key]
if ok {
sdl_ttf.DestroyText(sdl_text)
@@ -287,7 +290,7 @@ init_text_cache :: proc(
text_cache = Text_Cache {
engine = engine,
cache = make(map[u32]^sdl_ttf.Text, allocator = allocator),
cache = make(map[Cache_Key]^sdl_ttf.Text, allocator = allocator),
}
log.debug("Done initializing text cache")