Texture Rendering (#9)

Co-authored-by: Zachary Levy <zachary@sunforge.is>
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-04-22 00:05:08 +00:00
parent 64de816647
commit 0d424cbd6e
19 changed files with 1765 additions and 357 deletions

View File

@@ -63,15 +63,17 @@ Rectangle :: struct {
}
Sub_Batch_Kind :: enum u8 {
Shapes, // non-indexed, white texture, mode 0
Shapes, // non-indexed, white texture or user texture, mode 0
Text, // indexed, atlas texture, mode 0
SDF, // instanced unit quad, white texture, mode 1
SDF, // instanced unit quad, white texture or user texture, mode 1
}
Sub_Batch :: struct {
kind: Sub_Batch_Kind,
offset: u32, // Shapes: vertex offset; Text: text_batch index; SDF: primitive index
count: u32, // Shapes: vertex count; Text: always 1; SDF: primitive count
kind: Sub_Batch_Kind,
offset: u32, // Shapes: vertex offset; Text: text_batch index; SDF: primitive index
count: u32, // Shapes: vertex count; Text: always 1; SDF: primitive count
texture_id: Texture_Id,
sampler: Sampler_Preset,
}
Layer :: struct {
@@ -95,35 +97,60 @@ 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,
tmp_uncached_text: [dynamic]^sdl_ttf.Text, // Uncached TTF_Text objects to destroy after end()
clay_memory: [^]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_width: u32,
msaa_height: u32,
sample_count: sdl.GPUSampleCount,
clay_z_index: i16,
cleared: bool,
// -- Per-frame staging (hottest — touched by every prepare/upload/clear cycle) --
tmp_shape_verts: [dynamic]Vertex, // Tessellated shape vertices staged for GPU upload.
tmp_text_verts: [dynamic]Vertex, // Text vertices staged for GPU upload.
tmp_text_indices: [dynamic]c.int, // Text index buffer staged for GPU upload.
tmp_text_batches: [dynamic]TextBatch, // Text atlas batch metadata for indexed drawing.
tmp_primitives: [dynamic]Primitive, // SDF primitives staged for GPU storage buffer upload.
tmp_sub_batches: [dynamic]Sub_Batch, // Sub-batch records that drive draw call dispatch.
tmp_uncached_text: [dynamic]^sdl_ttf.Text, // Uncached TTF_Text objects destroyed after end() submits.
layers: [dynamic]Layer, // Draw layers, each with its own scissor stack.
scissors: [dynamic]Scissor, // Scissor rects that clip drawing within each layer.
// -- Per-frame scalars (accessed during prepare and draw_layer) --
curr_layer_index: uint, // Index of the currently active layer.
dpi_scaling: f32, // Window DPI scale factor applied to all pixel coordinates.
clay_z_index: i16, // Tracks z-index for layer splitting during Clay batch processing.
cleared: bool, // Whether the render target has been cleared this frame.
// -- Pipeline (accessed every draw_layer call) --
pipeline_2d_base: Pipeline_2D_Base, // The unified 2D GPU pipeline (shaders, buffers, samplers).
device: ^sdl.GPUDevice, // GPU device handle, stored at init.
samplers: [SAMPLER_PRESET_COUNT]^sdl.GPUSampler, // Lazily-created sampler objects, one per Sampler_Preset.
// -- Deferred release (processed once per frame at frame boundary) --
pending_texture_releases: [dynamic]Texture_Id, // Deferred GPU texture releases, processed next frame.
pending_text_releases: [dynamic]^sdl_ttf.Text, // Deferred TTF_Text destroys, processed next frame.
// -- Textures (registration is occasional, binding is per draw call) --
texture_slots: [dynamic]Texture_Slot, // Registered texture slots indexed by Texture_Id.
texture_free_list: [dynamic]u32, // Recycled slot indices available for reuse.
// -- MSAA (once per frame in end()) --
msaa_texture: ^sdl.GPUTexture, // Intermediate render target for multi-sample resolve.
msaa_width: u32, // Cached width to detect when MSAA texture needs recreation.
msaa_height: u32, // Cached height to detect when MSAA texture needs recreation.
sample_count: sdl.GPUSampleCount, // Sample count chosen at init (._1 means MSAA disabled).
// -- Clay (once per frame in prepare_clay_batch) --
clay_memory: [^]u8, // Raw memory block backing Clay's internal arena.
// -- Text (occasional — font registration and text cache lookups) --
text_cache: Text_Cache, // Font registry, SDL_ttf engine, and cached TTF_Text objects.
// -- Resize tracking (cold — checked once per frame in resize_global) --
max_layers: int, // High-water marks for dynamic array shrink heuristic.
max_scissors: int,
max_shape_verts: int,
max_text_verts: int,
max_text_indices: int,
max_text_batches: int,
max_primitives: int,
max_sub_batches: int,
// -- Init-only (coldest — set once at init, never written again) --
odin_context: runtime.Context, // Odin context captured at init for use in callbacks.
}
Init_Options :: struct {
@@ -168,22 +195,30 @@ 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),
tmp_uncached_text = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator),
odin_context = odin_context,
dpi_scaling = sdl.GetWindowDisplayScale(window),
clay_memory = make([^]u8, min_memory_size, allocator = allocator),
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),
device = device,
texture_slots = make([dynamic]Texture_Slot, 0, 16, allocator = allocator),
texture_free_list = make([dynamic]u32, 0, 16, allocator = allocator),
pending_texture_releases = make([dynamic]Texture_Id, 0, 16, allocator = allocator),
pending_text_releases = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator),
odin_context = odin_context,
dpi_scaling = sdl.GetWindowDisplayScale(window),
clay_memory = make([^]u8, min_memory_size, allocator = allocator),
sample_count = resolved_sample_count,
pipeline_2d_base = pipeline,
text_cache = text_cache,
}
// Reserve slot 0 for INVALID_TEXTURE
append(&GLOB.texture_slots, Texture_Slot{})
log.debug("Window DPI scaling:", GLOB.dpi_scaling)
arena := clay.CreateArenaWithCapacityAndMemory(min_memory_size, GLOB.clay_memory)
window_width, window_height: c.int
@@ -230,12 +265,23 @@ destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) {
if GLOB.msaa_texture != nil {
sdl.ReleaseGPUTexture(device, GLOB.msaa_texture)
}
process_pending_texture_releases()
destroy_all_textures()
destroy_sampler_pool()
for ttf_text in GLOB.pending_text_releases do sdl_ttf.DestroyText(ttf_text)
delete(GLOB.pending_text_releases)
destroy_pipeline_2d_base(device, &GLOB.pipeline_2d_base)
destroy_text_cache()
}
// Internal
clear_global :: proc() {
// Process deferred texture releases from the previous frame
process_pending_texture_releases()
// Process deferred text releases from the previous frame
for ttf_text in GLOB.pending_text_releases do sdl_ttf.DestroyText(ttf_text)
clear(&GLOB.pending_text_releases)
GLOB.curr_layer_index = 0
GLOB.clay_z_index = 0
GLOB.cleared = false
@@ -265,6 +311,7 @@ measure_text_clay :: proc "c" (
context = GLOB.odin_context
text := string(text.chars[:text.length])
c_text := strings.clone_to_cstring(text, context.temp_allocator)
defer delete(c_text, context.temp_allocator)
width, height: c.int
if !sdl_ttf.GetStringSize(get_font(config.fontId, config.fontSize), c_text, 0, &width, &height) {
log.panicf("Failed to measure text: %s", sdl.GetError())
@@ -454,15 +501,24 @@ append_or_extend_sub_batch :: proc(
kind: Sub_Batch_Kind,
offset: u32,
count: u32,
texture_id: Texture_Id = INVALID_TEXTURE,
sampler: Sampler_Preset = .Linear_Clamp,
) {
if scissor.sub_batch_len > 0 {
last := &GLOB.tmp_sub_batches[scissor.sub_batch_start + scissor.sub_batch_len - 1]
if last.kind == kind && kind != .Text && last.offset + last.count == offset {
if last.kind == kind &&
kind != .Text &&
last.offset + last.count == offset &&
last.texture_id == texture_id &&
last.sampler == sampler {
last.count += count
return
}
}
append(&GLOB.tmp_sub_batches, Sub_Batch{kind = kind, offset = offset, count = count})
append(
&GLOB.tmp_sub_batches,
Sub_Batch{kind = kind, offset = offset, count = count, texture_id = texture_id, sampler = sampler},
)
scissor.sub_batch_len += 1
layer.sub_batch_len += 1
}
@@ -502,6 +558,7 @@ prepare_clay_batch :: proc(
mouse_wheel_delta: [2]f32,
frame_time: f32 = 0,
custom_draw: Custom_Draw = nil,
temp_allocator := context.temp_allocator,
) {
mouse_pos: [2]f32
mouse_flags := sdl.GetMouseState(&mouse_pos.x, &mouse_pos.y)
@@ -541,7 +598,8 @@ prepare_clay_batch :: proc(
case clay.RenderCommandType.Text:
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)
c_text := strings.clone_to_cstring(txt, temp_allocator)
defer delete(c_text, temp_allocator)
// 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(
@@ -551,6 +609,46 @@ prepare_clay_batch :: proc(
)
prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)})
case clay.RenderCommandType.Image:
render_data := render_command.renderData.image
if render_data.imageData == nil do continue
img_data := (^Clay_Image_Data)(render_data.imageData)^
cr := render_data.cornerRadius
radii := [4]f32{cr.topLeft, cr.topRight, cr.bottomRight, cr.bottomLeft}
// Background color behind the image (Clay allows it)
bg := color_from_clay(render_data.backgroundColor)
if bg[3] > 0 {
if radii == {0, 0, 0, 0} {
rectangle(layer, bounds, bg)
} else {
rectangle_corners(layer, bounds, radii, bg)
}
}
// Compute fit UVs
uv, sampler, inner := fit_params(img_data.fit, bounds, img_data.texture_id)
// Draw the image — route by cornerRadius
if radii == {0, 0, 0, 0} {
rectangle_texture(
layer,
inner,
img_data.texture_id,
tint = img_data.tint,
uv_rect = uv,
sampler = sampler,
)
} else {
rectangle_texture_corners(
layer,
inner,
radii,
img_data.texture_id,
tint = img_data.tint,
uv_rect = uv,
sampler = sampler,
)
}
case clay.RenderCommandType.ScissorStart:
if bounds.width == 0 || bounds.height == 0 do continue