Added draw package as renderer focused on mixed use layout / 2D / 3D scene applications (#7)

Co-authored-by: Zachary Levy <zachary@sunforge.is>
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-04-20 20:14:56 +00:00
parent 59c600d630
commit 274289bd47
26 changed files with 5331 additions and 1 deletions

938
draw/draw.odin Normal file
View File

@@ -0,0 +1,938 @@
package draw
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 {
PLATFORM_SHADER_FORMAT_FLAG :: sdl.GPUShaderFormatFlag.MSL
SHADER_ENTRY :: cstring("main0")
BASE_VERT_2D_RAW :: #load("shaders/generated/base_2d.vert.metal")
BASE_FRAG_2D_RAW :: #load("shaders/generated/base_2d.frag.metal")
} else {
PLATFORM_SHADER_FORMAT_FLAG :: sdl.GPUShaderFormatFlag.SPIRV
SHADER_ENTRY :: cstring("main")
BASE_VERT_2D_RAW :: #load("shaders/generated/base_2d.vert.spv")
BASE_FRAG_2D_RAW :: #load("shaders/generated/base_2d.frag.spv")
}
PLATFORM_SHADER_FORMAT :: sdl.GPUShaderFormat{PLATFORM_SHADER_FORMAT_FLAG}
BUFFER_INIT_SIZE :: 256
INITIAL_LAYER_SIZE :: 5
INITIAL_SCISSOR_SIZE :: 10
// ---------------------------------------------------------------------------------------------------------------------
// ----- Color -------------------------
// ---------------------------------------------------------------------------------------------------------------------
Color :: distinct [4]u8
BLACK :: Color{0, 0, 0, 255}
WHITE :: Color{255, 255, 255, 255}
RED :: Color{255, 0, 0, 255}
GREEN :: Color{0, 255, 0, 255}
BLUE :: Color{0, 0, 255, 255}
BLANK :: Color{0, 0, 0, 0}
// Convert clay.Color ([4]c.float in 0255 range) to Color.
color_from_clay :: proc(clay_color: clay.Color) -> Color {
return Color{u8(clay_color[0]), u8(clay_color[1]), u8(clay_color[2]), u8(clay_color[3])}
}
// Convert Color to [4]f32 in 0.01.0 range. Useful for SDL interop (e.g. clear color).
color_to_f32 :: proc(color: Color) -> [4]f32 {
INV :: 1.0 / 255.0
return {f32(color[0]) * INV, f32(color[1]) * INV, f32(color[2]) * INV, f32(color[3]) * INV}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Core types --------------------
// ---------------------------------------------------------------------------------------------------------------------
Rectangle :: struct {
x: f32,
y: f32,
width: f32,
height: f32,
}
Sub_Batch_Kind :: enum u8 {
Shapes, // non-indexed, white texture, mode 0
Text, // indexed, atlas texture, mode 0
SDF, // instanced unit quad, white 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
}
Layer :: struct {
bounds: Rectangle,
sub_batch_start: u32,
sub_batch_len: u32,
scissor_start: u32,
scissor_len: u32,
}
Scissor :: struct {
bounds: sdl.Rect,
sub_batch_start: u32,
sub_batch_len: u32,
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Global state ------------------
// ---------------------------------------------------------------------------------------------------------------------
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,
}
Init_Options :: struct {
// MSAA sample count. Default is ._1 (no MSAA). SDF rendering does not benefit from MSAA
// because SDF fragments compute coverage analytically via `smoothstep`. MSAA helps for
// text glyph edges and tessellated user geometry. Set to ._4 or ._8 for text-heavy UIs,
// or use `MSAA_MAX` to request the highest sample count the GPU supports for the swapchain
// format.
msaa_samples: sdl.GPUSampleCount,
}
// Sentinel value: when passed as msaa_samples, `init` will use the maximum MSAA sample count
// supported by the GPU for the swapchain format.
MSAA_MAX :: sdl.GPUSampleCount(0xFF)
// Initialize the renderer. Returns false if GPU pipeline or text engine creation fails.
@(require_results)
init :: proc(
device: ^sdl.GPUDevice,
window: ^sdl.Window,
options: Init_Options = {},
allocator := context.allocator,
odin_context := context,
) -> (
ok: bool,
) {
min_memory_size: c.size_t = cast(c.size_t)clay.MinMemorySize()
resolved_sample_count := options.msaa_samples
if resolved_sample_count == MSAA_MAX {
resolved_sample_count = max_sample_count(device, window)
}
pipeline, pipeline_ok := create_pipeline_2d_base(device, window, resolved_sample_count)
if !pipeline_ok {
return false
}
text_cache, text_ok := init_text_cache(device, allocator)
if !text_ok {
destroy_pipeline_2d_base(device, &pipeline)
return false
}
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,
}
log.debug("Window DPI scaling:", GLOB.dpi_scaling)
arena := clay.CreateArenaWithCapacityAndMemory(min_memory_size, GLOB.clay_memory)
window_width, window_height: c.int
sdl.GetWindowSize(window, &window_width, &window_height)
clay.Initialize(arena, {f32(window_width), f32(window_height)}, {handler = clay_error_handler})
clay.SetMeasureTextFunction(measure_text_clay, nil)
return true
}
// TODO Either every x frames nuke max values in case of edge cases where max gets set very high
// or leave to application code to decide the right time for resize
resize_global :: proc() {
if len(GLOB.layers) > GLOB.max_layers do GLOB.max_layers = len(GLOB.layers)
shrink(&GLOB.layers, GLOB.max_layers)
if len(GLOB.scissors) > GLOB.max_scissors do GLOB.max_scissors = len(GLOB.scissors)
shrink(&GLOB.scissors, GLOB.max_scissors)
if len(GLOB.tmp_shape_verts) > GLOB.max_shape_verts do GLOB.max_shape_verts = len(GLOB.tmp_shape_verts)
shrink(&GLOB.tmp_shape_verts, GLOB.max_shape_verts)
if len(GLOB.tmp_text_verts) > GLOB.max_text_verts do GLOB.max_text_verts = len(GLOB.tmp_text_verts)
shrink(&GLOB.tmp_text_verts, GLOB.max_text_verts)
if len(GLOB.tmp_text_indices) > GLOB.max_text_indices do GLOB.max_text_indices = len(GLOB.tmp_text_indices)
shrink(&GLOB.tmp_text_indices, GLOB.max_text_indices)
if len(GLOB.tmp_text_batches) > GLOB.max_text_batches do GLOB.max_text_batches = len(GLOB.tmp_text_batches)
shrink(&GLOB.tmp_text_batches, GLOB.max_text_batches)
if len(GLOB.tmp_primitives) > GLOB.max_primitives do GLOB.max_primitives = len(GLOB.tmp_primitives)
shrink(&GLOB.tmp_primitives, GLOB.max_primitives)
if len(GLOB.tmp_sub_batches) > GLOB.max_sub_batches do GLOB.max_sub_batches = len(GLOB.tmp_sub_batches)
shrink(&GLOB.tmp_sub_batches, GLOB.max_sub_batches)
}
destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) {
delete(GLOB.layers)
delete(GLOB.scissors)
delete(GLOB.tmp_shape_verts)
delete(GLOB.tmp_text_verts)
delete(GLOB.tmp_text_indices)
delete(GLOB.tmp_text_batches)
delete(GLOB.tmp_primitives)
delete(GLOB.tmp_sub_batches)
for ttf_text in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(ttf_text)
delete(GLOB.tmp_uncached_text)
free(GLOB.clay_memory, allocator)
if GLOB.msaa_texture != nil {
sdl.ReleaseGPUTexture(device, GLOB.msaa_texture)
}
destroy_pipeline_2d_base(device, &GLOB.pipeline_2d_base)
destroy_text_cache()
}
// Internal
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 ttf_text in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(ttf_text)
clear(&GLOB.tmp_uncached_text)
clear(&GLOB.layers)
clear(&GLOB.scissors)
clear(&GLOB.tmp_shape_verts)
clear(&GLOB.tmp_text_verts)
clear(&GLOB.tmp_text_indices)
clear(&GLOB.tmp_text_batches)
clear(&GLOB.tmp_primitives)
clear(&GLOB.tmp_sub_batches)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Text measurement (Clay) -------
// ---------------------------------------------------------------------------------------------------------------------
@(private = "file")
measure_text_clay :: proc "c" (
text: clay.StringSlice,
config: ^clay.TextElementConfig,
user_data: rawptr,
) -> clay.Dimensions {
context = GLOB.odin_context
text := string(text.chars[:text.length])
c_text := strings.clone_to_cstring(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())
}
return clay.Dimensions{width = f32(width) / GLOB.dpi_scaling, height = f32(height) / GLOB.dpi_scaling}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Frame lifecycle ---------------
// ---------------------------------------------------------------------------------------------------------------------
// Sets up renderer to begin upload to the GPU. Returns starting `Layer` to begin processing primitives for.
begin :: proc(bounds: Rectangle) -> ^Layer {
// Cleanup
clear_global()
// Begin new layer + start a new scissor
scissor := Scissor {
bounds = sdl.Rect {
x = i32(bounds.x * GLOB.dpi_scaling),
y = i32(bounds.y * GLOB.dpi_scaling),
w = i32(bounds.width * GLOB.dpi_scaling),
h = i32(bounds.height * GLOB.dpi_scaling),
},
}
append(&GLOB.scissors, scissor)
layer := Layer {
bounds = bounds,
scissor_len = 1,
}
append(&GLOB.layers, layer)
return &GLOB.layers[GLOB.curr_layer_index]
}
// Creates a new layer
new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer {
layer := Layer {
bounds = bounds,
sub_batch_start = prev_layer.sub_batch_start + prev_layer.sub_batch_len,
scissor_start = prev_layer.scissor_start + prev_layer.scissor_len,
scissor_len = 1,
}
append(&GLOB.layers, layer)
GLOB.curr_layer_index += 1
log.debug("Added new layer; curr index", GLOB.curr_layer_index)
scissor := Scissor {
sub_batch_start = u32(len(GLOB.tmp_sub_batches)),
bounds = sdl.Rect {
x = i32(bounds.x * GLOB.dpi_scaling),
y = i32(bounds.y * GLOB.dpi_scaling),
w = i32(bounds.width * GLOB.dpi_scaling),
h = i32(bounds.height * GLOB.dpi_scaling),
},
}
append(&GLOB.scissors, scissor)
return &GLOB.layers[GLOB.curr_layer_index]
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Built-in primitive processing --
// ---------------------------------------------------------------------------------------------------------------------
// Submit shape vertices (colored triangles) to the given layer for rendering.
prepare_shape :: proc(layer: ^Layer, vertices: []Vertex) {
if len(vertices) == 0 do return
offset := u32(len(GLOB.tmp_shape_verts))
append(&GLOB.tmp_shape_verts, ..vertices)
scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
append_or_extend_sub_batch(scissor, layer, .Shapes, offset, u32(len(vertices)))
}
// Submit an SDF primitive to the given layer for rendering.
prepare_sdf_primitive :: proc(layer: ^Layer, prim: Primitive) {
offset := u32(len(GLOB.tmp_primitives))
append(&GLOB.tmp_primitives, prim)
scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1)
}
// Submit a text element to the given layer for rendering.
// Copies SDL_ttf vertices directly (with baked position) and copies indices for indexed drawing.
prepare_text :: proc(layer: ^Layer, text: Text) {
data := sdl_ttf.GetGPUTextDrawData(text.sdl_text)
if data == nil {
return // nil is normal for empty 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))
// Copy vertices with baked position offset
for i in 0 ..< data.num_vertices {
pos := data.xy[i]
uv := data.uv[i]
append(
&GLOB.tmp_text_verts,
Vertex{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = text.color},
)
}
// Copy indices directly
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),
},
)
// Each atlas chunk is a separate sub-batch (different atlas textures can't coalesce)
append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1)
data = data.next
}
}
// 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, text: Text, transform: Transform_2D) {
data := sdl_ttf.GetGPUTextDrawData(text.sdl_text)
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(transform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = text.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(
scissor: ^Scissor,
layer: ^Layer,
kind: Sub_Batch_Kind,
offset: u32,
count: u32,
) {
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 {
last.count += count
return
}
}
append(&GLOB.tmp_sub_batches, Sub_Batch{kind = kind, offset = offset, count = count})
scissor.sub_batch_len += 1
layer.sub_batch_len += 1
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Clay ------------------------
// ---------------------------------------------------------------------------------------------------------------------
@(private = "file")
clay_error_handler :: proc "c" (errorData: clay.ErrorData) {
context = GLOB.odin_context
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),
}
// Process Clay render commands into shape and text primitives.
prepare_clay_batch :: proc(
base_layer: ^Layer,
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)
// Update clay internals
clay.SetPointerState(
clay.Vector2{mouse_pos.x - base_layer.bounds.x, mouse_pos.y - base_layer.bounds.y},
.LEFT in mouse_flags,
)
clay.UpdateScrollContainers(true, mouse_wheel_delta, frame_time)
layer := base_layer
// Parse render commands
for i in 0 ..< int(batch.cmds.length) {
render_command := clay.RenderCommandArray_Get(&batch.cmds, cast(i32)i)
// Translate bounding box of the primitive by the layer position
bounds := Rectangle {
x = render_command.boundingBox.x + layer.bounds.x,
y = render_command.boundingBox.y + layer.bounds.y,
width = render_command.boundingBox.width,
height = render_command.boundingBox.height,
}
if render_command.zIndex > GLOB.clay_z_index {
log.debug("Higher zIndex found, creating new layer & setting z_index to", render_command.zIndex)
layer = new_layer(layer, bounds)
// Update bounds to new layer offset
bounds.x = render_command.boundingBox.x + layer.bounds.x
bounds.y = render_command.boundingBox.y + layer.bounds.y
GLOB.clay_z_index = render_command.zIndex
}
switch (render_command.commandType) {
case clay.RenderCommandType.None:
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)
// 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(
Cache_Key{render_command.id, .Clay},
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:
if bounds.width == 0 || bounds.height == 0 do continue
curr_scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
if curr_scissor.sub_batch_len != 0 {
// Scissor has some content, need to make a new scissor
new := Scissor {
sub_batch_start = curr_scissor.sub_batch_start + curr_scissor.sub_batch_len,
bounds = sdl.Rect {
c.int(bounds.x * GLOB.dpi_scaling),
c.int(bounds.y * GLOB.dpi_scaling),
c.int(bounds.width * GLOB.dpi_scaling),
c.int(bounds.height * GLOB.dpi_scaling),
},
}
append(&GLOB.scissors, new)
layer.scissor_len += 1
} else {
curr_scissor.bounds = sdl.Rect {
c.int(bounds.x * GLOB.dpi_scaling),
c.int(bounds.y * GLOB.dpi_scaling),
c.int(bounds.width * GLOB.dpi_scaling),
c.int(bounds.height * GLOB.dpi_scaling),
}
}
case clay.RenderCommandType.ScissorEnd:
case clay.RenderCommandType.Rectangle:
render_data := render_command.renderData.rectangle
cr := render_data.cornerRadius
color := color_from_clay(render_data.backgroundColor)
radii := [4]f32{cr.topLeft, cr.topRight, cr.bottomRight, cr.bottomLeft}
if radii == {0, 0, 0, 0} {
rectangle(layer, bounds, color)
} else {
rectangle_corners(layer, bounds, radii, color)
}
case clay.RenderCommandType.Border:
render_data := render_command.renderData.border
cr := render_data.cornerRadius
color := color_from_clay(render_data.color)
thickness := f32(render_data.width.top)
radii := [4]f32{cr.topLeft, cr.topRight, cr.bottomRight, cr.bottomLeft}
if radii == {0, 0, 0, 0} {
rectangle_lines(layer, bounds, color, thickness)
} else {
rectangle_corners_lines(layer, bounds, radii, color, thickness)
}
case clay.RenderCommandType.Custom: if custom_draw != nil {
custom_draw(layer, bounds, render_command.renderData.custom)
}
}
}
}
// Render primitives. clear_color is the background fill before any layers are drawn.
end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = BLACK) {
cmd_buffer := sdl.AcquireGPUCommandBuffer(device)
if cmd_buffer == nil {
log.panicf("Failed to acquire GPU command buffer: %s", sdl.GetError())
}
// Upload primitives to GPU
copy_pass := sdl.BeginGPUCopyPass(cmd_buffer)
upload(device, copy_pass)
sdl.EndGPUCopyPass(copy_pass)
swapchain_texture: ^sdl.GPUTexture
width, height: u32
if !sdl.WaitAndAcquireGPUSwapchainTexture(cmd_buffer, window, &swapchain_texture, &width, &height) {
log.panicf("Failed to acquire swapchain texture: %s", sdl.GetError())
}
if swapchain_texture == nil {
// Window is minimized or not visible — submit and skip this frame
if !sdl.SubmitGPUCommandBuffer(cmd_buffer) {
log.panicf("Failed to submit GPU command buffer (minimized window): %s", sdl.GetError())
}
return
}
use_msaa := GLOB.sample_count != ._1
render_texture := swapchain_texture
if use_msaa {
ensure_msaa_texture(device, sdl.GetGPUSwapchainTextureFormat(device, window), width, height)
render_texture = GLOB.msaa_texture
}
clear_color_f32 := color_to_f32(clear_color)
// Draw layers. One render pass per layer; sub-batches draw in submission order within each scissor.
for &layer, index in GLOB.layers {
log.debug("Drawing layer", index)
draw_layer(device, window, cmd_buffer, render_texture, width, height, clear_color_f32, &layer)
}
// Resolve MSAA render texture to the swapchain.
if use_msaa {
resolve_pass := sdl.BeginGPURenderPass(
cmd_buffer,
&sdl.GPUColorTargetInfo {
texture = render_texture,
load_op = .LOAD,
store_op = .RESOLVE,
resolve_texture = swapchain_texture,
},
1,
nil,
)
sdl.EndGPURenderPass(resolve_pass)
}
if !sdl.SubmitGPUCommandBuffer(cmd_buffer) {
log.panicf("Failed to submit GPU command buffer: %s", sdl.GetError())
}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- MSAA --------------------------
// ---------------------------------------------------------------------------------------------------------------------
// Query the highest MSAA sample count supported by the GPU for the swapchain format.
max_sample_count :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window) -> sdl.GPUSampleCount {
format := sdl.GetGPUSwapchainTextureFormat(device, window)
counts := [?]sdl.GPUSampleCount{._8, ._4, ._2}
for count in counts {
if sdl.GPUTextureSupportsSampleCount(device, format, count) do return count
}
return ._1
}
@(private = "file")
ensure_msaa_texture :: proc(device: ^sdl.GPUDevice, format: sdl.GPUTextureFormat, width, height: u32) {
if GLOB.msaa_texture != nil && GLOB.msaa_width == width && GLOB.msaa_height == height {
return
}
if GLOB.msaa_texture != nil {
sdl.ReleaseGPUTexture(device, GLOB.msaa_texture)
}
GLOB.msaa_texture = sdl.CreateGPUTexture(
device,
sdl.GPUTextureCreateInfo {
type = .D2,
format = format,
usage = {.COLOR_TARGET},
width = width,
height = height,
layer_count_or_depth = 1,
num_levels = 1,
sample_count = GLOB.sample_count,
},
)
if GLOB.msaa_texture == nil {
log.panicf("Failed to create MSAA texture (%dx%d): %s", width, height, sdl.GetError())
}
GLOB.msaa_width = width
GLOB.msaa_height = height
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Utility -----------------------
// ---------------------------------------------------------------------------------------------------------------------
ortho_rh :: proc(left: f32, right: f32, bottom: f32, top: f32, near: f32, far: f32) -> matrix[4, 4]f32 {
return matrix[4, 4]f32{
2.0 / (right - left), 0.0, 0.0, -(right + left) / (right - left),
0.0, 2.0 / (top - bottom), 0.0, -(top + bottom) / (top - bottom),
0.0, 0.0, -2.0 / (far - near), -(far + near) / (far - near),
0.0, 0.0, 0.0, 1.0,
}
}
Draw_Mode :: enum u32 {
Tessellated = 0,
SDF = 1,
}
Vertex_Uniforms :: struct {
projection: matrix[4, 4]f32,
scale: f32,
mode: Draw_Mode,
}
// Push projection, dpi scale, and rendering mode as a single uniform block (slot 0).
push_globals :: proc(
cmd_buffer: ^sdl.GPUCommandBuffer,
width: f32,
height: f32,
mode: Draw_Mode = .Tessellated,
) {
globals := Vertex_Uniforms {
projection = ortho_rh(
left = 0.0,
top = 0.0,
right = f32(width),
bottom = f32(height),
near = -1.0,
far = 1.0,
),
scale = GLOB.dpi_scaling,
mode = mode,
}
sdl.PushGPUVertexUniformData(cmd_buffer, 0, &globals, size_of(Vertex_Uniforms))
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Buffer ------------------------
// ---------------------------------------------------------------------------------------------------------------------
Buffer :: struct {
gpu: ^sdl.GPUBuffer,
transfer: ^sdl.GPUTransferBuffer,
size: u32,
}
@(require_results)
create_buffer :: proc(
device: ^sdl.GPUDevice,
size: u32,
gpu_usage: sdl.GPUBufferUsageFlags,
) -> (
buffer: Buffer,
ok: bool,
) {
gpu := sdl.CreateGPUBuffer(device, sdl.GPUBufferCreateInfo{usage = gpu_usage, size = size})
if gpu == nil {
log.errorf("Failed to create GPU buffer (size=%d): %s", size, sdl.GetError())
return buffer, false
}
transfer := sdl.CreateGPUTransferBuffer(
device,
sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size},
)
if transfer == nil {
sdl.ReleaseGPUBuffer(device, gpu)
log.errorf("Failed to create GPU transfer buffer (size=%d): %s", size, sdl.GetError())
return buffer, false
}
return Buffer{gpu, transfer, size}, true
}
grow_buffer_if_needed :: proc(
device: ^sdl.GPUDevice,
buffer: ^Buffer,
new_size: u32,
gpu_usage: sdl.GPUBufferUsageFlags,
) {
if new_size > buffer.size {
log.debug("Resizing buffer from", buffer.size, "to", new_size)
destroy_buffer(device, buffer)
buffer.gpu = sdl.CreateGPUBuffer(device, sdl.GPUBufferCreateInfo{usage = gpu_usage, size = new_size})
if buffer.gpu == nil {
log.panicf("Failed to grow GPU buffer (new_size=%d): %s", new_size, sdl.GetError())
}
buffer.transfer = sdl.CreateGPUTransferBuffer(
device,
sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = new_size},
)
if buffer.transfer == nil {
log.panicf("Failed to grow GPU transfer buffer (new_size=%d): %s", new_size, sdl.GetError())
}
buffer.size = new_size
}
}
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_rotation :: proc(position: [2]f32, origin: [2]f32, rotation_deg: f32) -> Transform_2D {
radians := math.to_radians(rotation_deg)
cos_angle := math.cos(radians)
sin_angle := math.sin(radians)
return Transform_2D {
m00 = cos_angle,
m01 = -sin_angle,
m10 = sin_angle,
m11 = cos_angle,
tx = position.x - (cos_angle * origin.x - sin_angle * origin.y),
ty = position.y - (sin_angle * origin.x + cos_angle * origin.y),
}
}
// Apply the transform to a local-space point, producing a world-space point.
apply_transform :: #force_inline proc(transform: Transform_2D, point: [2]f32) -> [2]f32 {
return {
transform.m00 * point.x + transform.m01 * point.y + transform.tx,
transform.m10 * point.x + transform.m11 * point.y + transform.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_rectangle,
center_of_triangle,
center_of_text,
}
top_left_of :: proc {
top_left_of_rectangle,
top_left_of_triangle,
top_left_of_text,
}
top_of :: proc {
top_of_rectangle,
top_of_triangle,
top_of_text,
}
top_right_of :: proc {
top_right_of_rectangle,
top_right_of_triangle,
top_right_of_text,
}
left_of :: proc {
left_of_rectangle,
left_of_triangle,
left_of_text,
}
right_of :: proc {
right_of_rectangle,
right_of_triangle,
right_of_text,
}
bottom_left_of :: proc {
bottom_left_of_rectangle,
bottom_left_of_triangle,
bottom_left_of_text,
}
bottom_of :: proc {
bottom_of_rectangle,
bottom_of_triangle,
bottom_of_text,
}
bottom_right_of :: proc {
bottom_right_of_rectangle,
bottom_right_of_triangle,
bottom_right_of_text,
}