Files
levlib/draw/core_2d.odin
T
Zachary Levy 87d4c9a0b5 Major reorg
2026-04-30 22:23:51 -07:00

1602 lines
54 KiB
Odin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package draw
import "core:c"
import "core:log"
import "core:math"
import "core:mem"
import sdl "vendor:sdl3"
import sdl_ttf "vendor:sdl3/ttf"
//----- Vertex layout ----------------------------------
// Vertex layout for tessellated and text geometry.
// IMPORTANT: `color` must be premultiplied alpha (RGB channels pre-scaled by alpha).
// The tessellated fragment shader passes vertex color through directly — it does NOT
// premultiply. The blend state is ONE, ONE_MINUS_SRC_ALPHA (premultiplied-over).
// Use `premultiply_color` when constructing vertices manually for `prepare_shape`.
Vertex_2D :: struct {
position: Vec2,
uv: [2]f32,
color: Color,
}
//INTERNAL
Text_Batch :: struct {
atlas_texture: ^sdl.GPUTexture,
vertex_start: u32,
vertex_count: u32,
index_start: u32,
index_count: u32,
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Primitive types ------------
// ---------------------------------------------------------------------------------------------------------------------
// The SDF path evaluates one of four signed distance functions per primitive, dispatched
// by Shape_Kind encoded in the low byte of Core_2D_Primitive.flags:
//
// RRect — rounded rectangle with per-corner radii (sdRoundedBox). Also covers circles
// (uniform radii = half-size), capsule-style line segments (rotated, max rounding),
// and other RRect-reducible shapes.
// NGon — regular polygon with N sides and optional rounding.
// Ellipse — approximate ellipse (non-exact SDF, suitable for UI but not for shape merging).
// Ring_Arc — annular ring with optional angular clipping. Covers full rings, partial arcs,
// pie slices (inner_radius = 0), and loading spinners.
//INTERNAL
Shape_Kind :: enum u8 {
Solid = 0, // tessellated path (mode marker; not a real SDF kind)
RRect = 1,
NGon = 2,
Ellipse = 3,
Ring_Arc = 4,
}
//INTERNAL
Shape_Flag :: enum u8 {
Textured, // bit 0: sample texture using uv_rect (mutually exclusive with Gradient via Brush union)
Gradient, // bit 1: 2-color gradient using effects.gradient_color as end/outer color
Gradient_Radial, // bit 2: if set with Gradient, radial from center; else linear at angle
Outline, // bit 3: outer outline band using effects.outline_color; CPU expands bounds by outline_width
Rotated, // bit 4: shape has non-zero rotation; rotation_sc contains packed sin/cos
Arc_Narrow, // bit 5: ring arc span ≤ π — intersect half-planes. Neither Arc bit = full ring.
Arc_Wide, // bit 6: ring arc span > π — union half-planes. Neither Arc bit = full ring.
}
//INTERNAL
Shape_Flags :: bit_set[Shape_Flag;u8]
//INTERNAL
RRect_Params :: struct {
half_size: [2]f32,
radii: [4]f32,
half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
_: f32,
}
//INTERNAL
NGon_Params :: struct {
radius: f32,
sides: f32,
half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
_: [5]f32,
}
//INTERNAL
Ellipse_Params :: struct {
radii: [2]f32,
half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
_: [5]f32,
}
//INTERNAL
Ring_Arc_Params :: struct {
inner_radius: f32, // inner radius in physical pixels (0 for pie slice)
outer_radius: f32, // outer radius in physical pixels
normal_start: [2]f32, // pre-computed outward normal of start edge: (sin(start), -cos(start))
normal_end: [2]f32, // pre-computed outward normal of end edge: (-sin(end), cos(end))
half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
_: f32,
}
//INTERNAL
Shape_Params :: struct #raw_union {
rrect: RRect_Params,
ngon: NGon_Params,
ellipse: Ellipse_Params,
ring_arc: Ring_Arc_Params,
raw: [8]f32,
}
#assert(size_of(Shape_Params) == 32)
// GPU-side storage for 2-color gradient parameters and/or outline parameters.
// Packed into 16 bytes. Independent from uv_rect — texture and outline can coexist.
// The shader reads gradient_color and outline_color via unpackUnorm4x8.
// gradient_dir_sc stores the pre-computed gradient direction as (cos, sin) in f16 pair
// via unpackHalf2x16. outline_packed stores outline_width as f16 via unpackHalf2x16.
//INTERNAL
Gradient_Outline :: struct {
gradient_color: Color, // 0: end (linear) or outer (radial) gradient color
outline_color: Color, // 4: outline band color
gradient_dir_sc: u32, // 8: packed f16 pair: low = cos(angle), high = sin(angle) — pre-computed gradient direction
outline_packed: u32, // 12: packed f16 pair: low = outline_width (f16, physical pixels), high = reserved
}
#assert(size_of(Gradient_Outline) == 16)
// GPU layout: 96 bytes, std430-compatible. The shader declares this as a storage buffer struct.
// The low byte of `flags` encodes the Shape_Kind (0 = tessellated, 1-4 = SDF kinds).
// Bits 8-15 encode Shape_Flags (Textured, Gradient, Gradient_Radial, Outline, Rotated, Arc_Narrow, Arc_Wide).
// rotation_sc stores pre-computed sin/cos of the rotation angle as a packed f16 pair,
// avoiding per-pixel trigonometry in the fragment shader. Only read when .Rotated is set.
//
// Named Core_2D_Primitive (not just Primitive) to disambiguate from Gaussian_Blur_Primitive
// (and any future per-effect primitive types) in backdrop.odin. Each path/effect's primitive
// type has its own GPU layout and fragment-shader contract; pairing each with its own
// primitive type keeps cross-references unambiguous when grepping the codebase.
//INTERNAL
Core_2D_Primitive :: struct {
bounds: [4]f32, // 0: min_x, min_y, max_x, max_y (world-space, pre-DPI)
color: Color, // 16: u8x4, fill color / gradient start color / texture tint
flags: u32, // 20: low byte = Shape_Kind, bits 8+ = Shape_Flags
rotation_sc: u32, // 24: packed f16 pair: low = sin(angle), high = cos(angle). Requires .Rotated flag.
_pad: f32, // 28: reserved for future use
params: Shape_Params, // 32: per-kind shape parameters (raw union, 32 bytes)
uv_rect: [4]f32, // 64: texture UV coordinates (u_min, v_min, u_max, v_max). Read when .Textured.
effects: Gradient_Outline, // 80: gradient and/or outline parameters. Read when .Gradient and/or .Outline.
}
#assert(size_of(Core_2D_Primitive) == 96)
// Pack shape kind and flags into the Core_2D_Primitive.flags field. The low byte encodes the
// Shape_Kind (which also serves as the SDF mode marker — kind > 0 means SDF path). The
// tessellated path leaves the field at 0 (Solid kind, set by vertex shader zero-initialization).
//INTERNAL
pack_kind_flags :: #force_inline proc(kind: Shape_Kind, flags: Shape_Flags) -> u32 {
return u32(kind) | (u32(transmute(u8)flags) << 8)
}
// Pack two f16 values into a single u32 for GPU consumption via unpackHalf2x16.
// Used to pack gradient_dir_sc (cos/sin) and outline_packed (width/reserved) in Gradient_Outline.
//INTERNAL
pack_f16_pair :: #force_inline proc(low, high: f16) -> u32 {
return u32(transmute(u16)low) | (u32(transmute(u16)high) << 16)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Subsystem lifecycle ------------
// ---------------------------------------------------------------------------------------------------------------------
//INTERNAL
Core_2D :: struct {
sdl_pipeline: ^sdl.GPUGraphicsPipeline,
vertex_buffer: Buffer,
index_buffer: Buffer,
unit_quad_buffer: ^sdl.GPUBuffer,
primitive_buffer: Buffer,
white_texture: ^sdl.GPUTexture,
sampler: ^sdl.GPUSampler,
}
// MSAA is not supported by levlib (see init's doc comment in draw.odin); the PSO is hard-wired
// to single-sample. SDF text and shapes provide analytical AA via smoothstep; tessellated user
// geometry is not anti-aliased.
//INTERNAL
create_core_2d :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window) -> (core_2d: Core_2D, ok: bool) {
// On failure, clean up any partially-created resources
defer if !ok {
if core_2d.sampler != nil do sdl.ReleaseGPUSampler(device, core_2d.sampler)
if core_2d.white_texture != nil do sdl.ReleaseGPUTexture(device, core_2d.white_texture)
if core_2d.unit_quad_buffer != nil do sdl.ReleaseGPUBuffer(device, core_2d.unit_quad_buffer)
if core_2d.primitive_buffer.gpu != nil do destroy_buffer(device, &core_2d.primitive_buffer)
if core_2d.index_buffer.gpu != nil do destroy_buffer(device, &core_2d.index_buffer)
if core_2d.vertex_buffer.gpu != nil do destroy_buffer(device, &core_2d.vertex_buffer)
if core_2d.sdl_pipeline != nil do sdl.ReleaseGPUGraphicsPipeline(device, core_2d.sdl_pipeline)
}
active_shader_formats := sdl.GetGPUShaderFormats(device)
if PLATFORM_SHADER_FORMAT_FLAG not_in active_shader_formats {
log.errorf(
"draw: no embedded shader matches active GPU formats; this build supports %v but device reports %v",
PLATFORM_SHADER_FORMAT,
active_shader_formats,
)
return core_2d, false
}
log.debug("Loaded", len(BASE_VERT_2D_RAW), "vert bytes")
log.debug("Loaded", len(BASE_FRAG_2D_RAW), "frag bytes")
vert_info := sdl.GPUShaderCreateInfo {
code_size = len(BASE_VERT_2D_RAW),
code = raw_data(BASE_VERT_2D_RAW),
entrypoint = SHADER_ENTRY,
format = {PLATFORM_SHADER_FORMAT_FLAG},
stage = .VERTEX,
num_uniform_buffers = 1,
num_storage_buffers = 1,
}
frag_info := sdl.GPUShaderCreateInfo {
code_size = len(BASE_FRAG_2D_RAW),
code = raw_data(BASE_FRAG_2D_RAW),
entrypoint = SHADER_ENTRY,
format = {PLATFORM_SHADER_FORMAT_FLAG},
stage = .FRAGMENT,
num_samplers = 1,
}
vert_shader := sdl.CreateGPUShader(device, vert_info)
if vert_shader == nil {
log.errorf("Could not create draw vertex shader: %s", sdl.GetError())
return core_2d, false
}
frag_shader := sdl.CreateGPUShader(device, frag_info)
if frag_shader == nil {
sdl.ReleaseGPUShader(device, vert_shader)
log.errorf("Could not create draw fragment shader: %s", sdl.GetError())
return core_2d, false
}
vertex_attributes: [3]sdl.GPUVertexAttribute = {
// position (GLSL location 0)
sdl.GPUVertexAttribute{buffer_slot = 0, location = 0, format = .FLOAT2, offset = 0},
// uv (GLSL location 1)
sdl.GPUVertexAttribute{buffer_slot = 0, location = 1, format = .FLOAT2, offset = size_of([2]f32)},
// color (GLSL location 2, u8x4 normalized to float by GPU)
sdl.GPUVertexAttribute{buffer_slot = 0, location = 2, format = .UBYTE4_NORM, offset = size_of([2]f32) * 2},
}
pipeline_info := sdl.GPUGraphicsPipelineCreateInfo {
vertex_shader = vert_shader,
fragment_shader = frag_shader,
primitive_type = .TRIANGLELIST,
multisample_state = sdl.GPUMultisampleState{sample_count = ._1},
target_info = sdl.GPUGraphicsPipelineTargetInfo {
color_target_descriptions = &sdl.GPUColorTargetDescription {
format = sdl.GetGPUSwapchainTextureFormat(device, window),
// Premultiplied-alpha blending: src outputs RGB pre-multiplied by alpha,
// so src factor is ONE (not SRC_ALPHA). This eliminates the per-pixel
// divide in the outline path and is the standard blend mode used by
// Skia, Flutter, and GPUI.
blend_state = sdl.GPUColorTargetBlendState {
enable_blend = true,
enable_color_write_mask = true,
src_color_blendfactor = .ONE,
dst_color_blendfactor = .ONE_MINUS_SRC_ALPHA,
color_blend_op = .ADD,
src_alpha_blendfactor = .ONE,
dst_alpha_blendfactor = .ONE_MINUS_SRC_ALPHA,
alpha_blend_op = .ADD,
color_write_mask = sdl.GPUColorComponentFlags{.R, .G, .B, .A},
},
},
num_color_targets = 1,
},
vertex_input_state = sdl.GPUVertexInputState {
vertex_buffer_descriptions = &sdl.GPUVertexBufferDescription {
slot = 0,
input_rate = .VERTEX,
pitch = size_of(Vertex_2D),
},
num_vertex_buffers = 1,
vertex_attributes = raw_data(vertex_attributes[:]),
num_vertex_attributes = 3,
},
}
core_2d.sdl_pipeline = sdl.CreateGPUGraphicsPipeline(device, pipeline_info)
// Shaders are no longer needed regardless of pipeline creation success
sdl.ReleaseGPUShader(device, vert_shader)
sdl.ReleaseGPUShader(device, frag_shader)
if core_2d.sdl_pipeline == nil {
log.errorf("Failed to create draw graphics pipeline: %s", sdl.GetError())
return core_2d, false
}
// Create vertex buffer
vert_buf_ok: bool
core_2d.vertex_buffer, vert_buf_ok = create_buffer(
device,
size_of(Vertex_2D) * BUFFER_INIT_SIZE,
sdl.GPUBufferUsageFlags{.VERTEX},
)
if !vert_buf_ok do return core_2d, false
// Create index buffer (used by text)
idx_buf_ok: bool
core_2d.index_buffer, idx_buf_ok = create_buffer(
device,
size_of(c.int) * BUFFER_INIT_SIZE,
sdl.GPUBufferUsageFlags{.INDEX},
)
if !idx_buf_ok do return core_2d, false
// Create primitive storage buffer (used by SDF instanced drawing)
prim_buf_ok: bool
core_2d.primitive_buffer, prim_buf_ok = create_buffer(
device,
size_of(Core_2D_Primitive) * BUFFER_INIT_SIZE,
sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ},
)
if !prim_buf_ok do return core_2d, false
// Create static 6-vertex unit quad buffer (two triangles, TRIANGLELIST)
core_2d.unit_quad_buffer = sdl.CreateGPUBuffer(
device,
sdl.GPUBufferCreateInfo{usage = {.VERTEX}, size = 6 * size_of(Vertex_2D)},
)
if core_2d.unit_quad_buffer == nil {
log.errorf("Failed to create unit quad buffer: %s", sdl.GetError())
return core_2d, false
}
// Create 1x1 white pixel texture
core_2d.white_texture = sdl.CreateGPUTexture(
device,
sdl.GPUTextureCreateInfo {
type = .D2,
format = .R8G8B8A8_UNORM,
usage = {.SAMPLER},
width = 1,
height = 1,
layer_count_or_depth = 1,
num_levels = 1,
sample_count = ._1,
},
)
if core_2d.white_texture == nil {
log.errorf("Failed to create white pixel texture: %s", sdl.GetError())
return core_2d, false
}
// Upload white pixel and unit quad data in a single command buffer
white_pixel := Color{255, 255, 255, 255}
white_transfer_buf := sdl.CreateGPUTransferBuffer(
device,
sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(white_pixel)},
)
if white_transfer_buf == nil {
log.errorf("Failed to create white pixel transfer buffer: %s", sdl.GetError())
return core_2d, false
}
defer sdl.ReleaseGPUTransferBuffer(device, white_transfer_buf)
white_ptr := sdl.MapGPUTransferBuffer(device, white_transfer_buf, false)
if white_ptr == nil {
log.errorf("Failed to map white pixel transfer buffer: %s", sdl.GetError())
return core_2d, false
}
mem.copy(white_ptr, &white_pixel, size_of(white_pixel))
sdl.UnmapGPUTransferBuffer(device, white_transfer_buf)
quad_verts := [6]Vertex_2D {
{position = {0, 0}},
{position = {1, 0}},
{position = {0, 1}},
{position = {0, 1}},
{position = {1, 0}},
{position = {1, 1}},
}
quad_transfer_buf := sdl.CreateGPUTransferBuffer(
device,
sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(quad_verts)},
)
if quad_transfer_buf == nil {
log.errorf("Failed to create unit quad transfer buffer: %s", sdl.GetError())
return core_2d, false
}
defer sdl.ReleaseGPUTransferBuffer(device, quad_transfer_buf)
quad_ptr := sdl.MapGPUTransferBuffer(device, quad_transfer_buf, false)
if quad_ptr == nil {
log.errorf("Failed to map unit quad transfer buffer: %s", sdl.GetError())
return core_2d, false
}
mem.copy(quad_ptr, &quad_verts, size_of(quad_verts))
sdl.UnmapGPUTransferBuffer(device, quad_transfer_buf)
upload_cmd_buffer := sdl.AcquireGPUCommandBuffer(device)
if upload_cmd_buffer == nil {
log.errorf("Failed to acquire command buffer for init upload: %s", sdl.GetError())
return core_2d, false
}
upload_pass := sdl.BeginGPUCopyPass(upload_cmd_buffer)
sdl.UploadToGPUTexture(
upload_pass,
sdl.GPUTextureTransferInfo{transfer_buffer = white_transfer_buf},
sdl.GPUTextureRegion{texture = core_2d.white_texture, w = 1, h = 1, d = 1},
false,
)
sdl.UploadToGPUBuffer(
upload_pass,
sdl.GPUTransferBufferLocation{transfer_buffer = quad_transfer_buf},
sdl.GPUBufferRegion{buffer = core_2d.unit_quad_buffer, offset = 0, size = size_of(quad_verts)},
false,
)
sdl.EndGPUCopyPass(upload_pass)
if !sdl.SubmitGPUCommandBuffer(upload_cmd_buffer) {
log.errorf("Failed to submit init upload command buffer: %s", sdl.GetError())
return core_2d, false
}
log.debug("White pixel texture and unit quad buffer created and uploaded")
// Create sampler (shared by shapes and text)
core_2d.sampler = sdl.CreateGPUSampler(
device,
sdl.GPUSamplerCreateInfo {
min_filter = .LINEAR,
mag_filter = .LINEAR,
mipmap_mode = .LINEAR,
address_mode_u = .CLAMP_TO_EDGE,
address_mode_v = .CLAMP_TO_EDGE,
address_mode_w = .CLAMP_TO_EDGE,
},
)
if core_2d.sampler == nil {
log.errorf("Could not create GPU sampler: %s", sdl.GetError())
return core_2d, false
}
log.debug("Done creating core 2D subsystem")
return core_2d, true
}
//INTERNAL
destroy_core_2d :: proc(device: ^sdl.GPUDevice, core: ^Core_2D) {
destroy_buffer(device, &core.vertex_buffer)
destroy_buffer(device, &core.index_buffer)
destroy_buffer(device, &core.primitive_buffer)
if core.unit_quad_buffer != nil {
sdl.ReleaseGPUBuffer(device, core.unit_quad_buffer)
}
sdl.ReleaseGPUTexture(device, core.white_texture)
sdl.ReleaseGPUSampler(device, core.sampler)
sdl.ReleaseGPUGraphicsPipeline(device, core.sdl_pipeline)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Upload and render ------------
// ---------------------------------------------------------------------------------------------------------------------
//----- Vertex uniforms ----------------------------------
//INTERNAL
Core_2D_Mode :: enum u32 {
Tessellated = 0,
SDF = 1,
}
//INTERNAL
Vertex_Uniforms_2D :: struct {
projection: matrix[4, 4]f32,
scale: f32,
mode: Core_2D_Mode,
}
// Push projection, dpi scale, and rendering mode as a single uniform block (slot 0).
//INTERNAL
push_globals :: proc(
cmd_buffer: ^sdl.GPUCommandBuffer,
width: f32,
height: f32,
mode: Core_2D_Mode = .Tessellated,
) {
globals := Vertex_Uniforms_2D {
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_2D))
}
//----- Per-frame upload ----------------------------------
//INTERNAL
upload :: proc(device: ^sdl.GPUDevice, pass: ^sdl.GPUCopyPass) {
// Upload vertices (shapes then text into one buffer)
shape_vert_count := u32(len(GLOB.tmp_shape_verts))
text_vert_count := u32(len(GLOB.tmp_text_verts))
total_vert_count := shape_vert_count + text_vert_count
if total_vert_count > 0 {
total_vert_size := total_vert_count * size_of(Vertex_2D)
shape_vert_size := shape_vert_count * size_of(Vertex_2D)
text_vert_size := text_vert_count * size_of(Vertex_2D)
grow_buffer_if_needed(
device,
&GLOB.core_2d.vertex_buffer,
total_vert_size,
sdl.GPUBufferUsageFlags{.VERTEX},
)
vert_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.vertex_buffer.transfer, false)
if vert_array == nil {
log.panicf("Failed to map vertex transfer buffer: %s", sdl.GetError())
}
if shape_vert_size > 0 {
mem.copy(vert_array, raw_data(GLOB.tmp_shape_verts), int(shape_vert_size))
}
if text_vert_size > 0 {
mem.copy(
rawptr(uintptr(vert_array) + uintptr(shape_vert_size)),
raw_data(GLOB.tmp_text_verts),
int(text_vert_size),
)
}
sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.vertex_buffer.transfer)
sdl.UploadToGPUBuffer(
pass,
sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.vertex_buffer.transfer},
sdl.GPUBufferRegion{buffer = GLOB.core_2d.vertex_buffer.gpu, offset = 0, size = total_vert_size},
false,
)
}
// Upload text indices
index_count := u32(len(GLOB.tmp_text_indices))
if index_count > 0 {
index_size := index_count * size_of(c.int)
grow_buffer_if_needed(device, &GLOB.core_2d.index_buffer, index_size, sdl.GPUBufferUsageFlags{.INDEX})
idx_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.index_buffer.transfer, false)
if idx_array == nil {
log.panicf("Failed to map index transfer buffer: %s", sdl.GetError())
}
mem.copy(idx_array, raw_data(GLOB.tmp_text_indices), int(index_size))
sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.index_buffer.transfer)
sdl.UploadToGPUBuffer(
pass,
sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.index_buffer.transfer},
sdl.GPUBufferRegion{buffer = GLOB.core_2d.index_buffer.gpu, offset = 0, size = index_size},
false,
)
}
// Upload SDF primitives
prim_count := u32(len(GLOB.tmp_primitives))
if prim_count > 0 {
prim_size := prim_count * size_of(Core_2D_Primitive)
grow_buffer_if_needed(
device,
&GLOB.core_2d.primitive_buffer,
prim_size,
sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ},
)
prim_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.primitive_buffer.transfer, false)
if prim_array == nil {
log.panicf("Failed to map primitive transfer buffer: %s", sdl.GetError())
}
mem.copy(prim_array, raw_data(GLOB.tmp_primitives), int(prim_size))
sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.primitive_buffer.transfer)
sdl.UploadToGPUBuffer(
pass,
sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.primitive_buffer.transfer},
sdl.GPUBufferRegion{buffer = GLOB.core_2d.primitive_buffer.gpu, offset = 0, size = prim_size},
false,
)
}
}
//----- Layer dispatch ----------------------------------
//INTERNAL
draw_layer :: proc(
device: ^sdl.GPUDevice,
window: ^sdl.Window,
cmd_buffer: ^sdl.GPUCommandBuffer,
render_texture: ^sdl.GPUTexture,
swapchain_width: u32,
swapchain_height: u32,
clear_color: [4]f32,
layer: ^Layer,
) {
if layer.sub_batch_len == 0 {
if !GLOB.cleared {
pass := sdl.BeginGPURenderPass(
cmd_buffer,
&sdl.GPUColorTargetInfo {
texture = render_texture,
clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]},
load_op = .CLEAR,
store_op = .STORE,
},
1,
nil,
)
sdl.EndGPURenderPass(pass)
GLOB.cleared = true
}
return
}
bracket_start_abs := find_first_backdrop_in_layer(layer)
layer_end_abs := int(layer.sub_batch_start + layer.sub_batch_len)
if bracket_start_abs < 0 {
// Fast path: no backdrop in this layer; render the whole sub-batch range in one pass.
render_layer_sub_batch_range(
cmd_buffer,
render_texture,
swapchain_width,
swapchain_height,
clear_color,
layer,
int(layer.sub_batch_start),
layer_end_abs,
)
return
}
// Bracketed layer: Pass A → backdrop bracket → Pass B.
// See README.md § "Backdrop pipeline" for the full ordering semantics.
render_layer_sub_batch_range(
cmd_buffer,
render_texture,
swapchain_width,
swapchain_height,
clear_color,
layer,
int(layer.sub_batch_start),
bracket_start_abs,
)
run_backdrop_bracket(cmd_buffer, layer, swapchain_width, swapchain_height)
// Pass B: render the [bracket_start_abs, layer_end_abs) range. .Backdrop sub-batches in
// this range are dispatched by the bracket above and ignored here (the .Backdrop case in
// the inner switch is a no-op). LOAD is implied because Pass A or the bracket's V-
// composite has already touched render_texture.
render_layer_sub_batch_range(
cmd_buffer,
render_texture,
swapchain_width,
swapchain_height,
clear_color,
layer,
bracket_start_abs,
layer_end_abs,
)
}
// Render a sub-range of a layer's sub-batches in a single render pass. Iterates the layer's
// scissors and walks each scissor's sub-batches, dispatching by kind. The `range_start_abs`
// and `range_end_abs` parameters are absolute indices into GLOB.tmp_sub_batches; only sub-
// batches within `[range_start_abs, range_end_abs)` are drawn.
//
// .Backdrop sub-batches in the range are always silently skipped — they are dispatched by
// run_backdrop_bracket, not here. The empty .Backdrop case in the inner switch enforces this.
//
// Render-pass setup mirrors the original draw_layer: clear-or-load based on GLOB.cleared,
// pipeline + storage + index buffer bound up front, then per-batch state tracking. After this
// proc returns, GLOB.cleared is guaranteed true.
//
// If the range is empty after filtering (no eligible sub-batches at all), this proc still
// honors the no-clear-yet contract by issuing a clear-only pass when needed; otherwise it
// returns without opening a render pass.
//INTERNAL
render_layer_sub_batch_range :: proc(
cmd_buffer: ^sdl.GPUCommandBuffer,
render_texture: ^sdl.GPUTexture,
swapchain_width: u32,
swapchain_height: u32,
clear_color: [4]f32,
layer: ^Layer,
range_start_abs: int,
range_end_abs: int,
) {
if range_start_abs >= range_end_abs {
// Empty range. If we still owe a clear, do a clear-only pass; otherwise nothing to do.
if !GLOB.cleared {
pass := sdl.BeginGPURenderPass(
cmd_buffer,
&sdl.GPUColorTargetInfo {
texture = render_texture,
clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]},
load_op = .CLEAR,
store_op = .STORE,
},
1,
nil,
)
sdl.EndGPURenderPass(pass)
GLOB.cleared = true
}
return
}
render_pass := sdl.BeginGPURenderPass(
cmd_buffer,
&sdl.GPUColorTargetInfo {
texture = render_texture,
clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]},
load_op = GLOB.cleared ? .LOAD : .CLEAR,
store_op = .STORE,
},
1,
nil,
)
GLOB.cleared = true
sdl.BindGPUGraphicsPipeline(render_pass, GLOB.core_2d.sdl_pipeline)
// Bind storage buffer (read by vertex shader in SDF mode)
sdl.BindGPUVertexStorageBuffers(render_pass, 0, ([^]^sdl.GPUBuffer)(&GLOB.core_2d.primitive_buffer.gpu), 1)
// Always bind index buffer — harmless if no indexed draws are issued
sdl.BindGPUIndexBuffer(
render_pass,
sdl.GPUBufferBinding{buffer = GLOB.core_2d.index_buffer.gpu, offset = 0},
._32BIT,
)
// Shorthand aliases for frequently-used pipeline resources
main_vert_buf := GLOB.core_2d.vertex_buffer.gpu
unit_quad := GLOB.core_2d.unit_quad_buffer
white_texture := GLOB.core_2d.white_texture
sampler := GLOB.core_2d.sampler
width := f32(swapchain_width)
height := f32(swapchain_height)
// Initial GPU state: tessellated mode, main vertex buffer, no atlas bound yet
push_globals(cmd_buffer, width, height, .Tessellated)
sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1)
current_mode: Core_2D_Mode = .Tessellated
current_vert_buf := main_vert_buf
current_atlas: ^sdl.GPUTexture
current_sampler := sampler
// Text vertices live after shape vertices in the GPU vertex buffer
text_vertex_gpu_base := u32(len(GLOB.tmp_shape_verts))
for &scissor in GLOB.scissors[layer.scissor_start:][:layer.scissor_len] {
// Intersect this scissor's sub-batch span with the requested range.
scissor_start := int(scissor.sub_batch_start)
scissor_end := scissor_start + int(scissor.sub_batch_len)
effective_start := max(scissor_start, range_start_abs)
effective_end := min(scissor_end, range_end_abs)
if effective_start >= effective_end do continue
sdl.SetGPUScissor(render_pass, scissor.bounds)
for abs_idx in effective_start ..< effective_end {
batch := &GLOB.tmp_sub_batches[abs_idx]
switch batch.kind {
case .Tessellated:
if current_mode != .Tessellated {
push_globals(cmd_buffer, width, height, .Tessellated)
current_mode = .Tessellated
}
if current_vert_buf != main_vert_buf {
sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1)
current_vert_buf = main_vert_buf
}
// Determine texture and sampler for this batch
batch_texture: ^sdl.GPUTexture = white_texture
batch_sampler: ^sdl.GPUSampler = sampler
if batch.texture_id != INVALID_TEXTURE {
if bound_texture := texture_gpu_handle(batch.texture_id); bound_texture != nil {
batch_texture = bound_texture
}
batch_sampler = get_sampler(batch.sampler)
}
if current_atlas != batch_texture || current_sampler != batch_sampler {
sdl.BindGPUFragmentSamplers(
render_pass,
0,
&sdl.GPUTextureSamplerBinding{texture = batch_texture, sampler = batch_sampler},
1,
)
current_atlas = batch_texture
current_sampler = batch_sampler
}
sdl.DrawGPUPrimitives(render_pass, batch.count, 1, batch.offset, 0)
case .Text:
if current_mode != .Tessellated {
push_globals(cmd_buffer, width, height, .Tessellated)
current_mode = .Tessellated
}
if current_vert_buf != main_vert_buf {
sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1)
current_vert_buf = main_vert_buf
}
text_batch := &GLOB.tmp_text_batches[batch.offset]
if current_atlas != text_batch.atlas_texture {
sdl.BindGPUFragmentSamplers(
render_pass,
0,
&sdl.GPUTextureSamplerBinding{texture = text_batch.atlas_texture, sampler = sampler},
1,
)
current_atlas = text_batch.atlas_texture
}
sdl.DrawGPUIndexedPrimitives(
render_pass,
text_batch.index_count,
1,
text_batch.index_start,
i32(text_vertex_gpu_base + text_batch.vertex_start),
0,
)
case .SDF:
if current_mode != .SDF {
push_globals(cmd_buffer, width, height, .SDF)
current_mode = .SDF
}
if current_vert_buf != unit_quad {
sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = unit_quad, offset = 0}, 1)
current_vert_buf = unit_quad
}
// Determine texture and sampler for this batch
batch_texture: ^sdl.GPUTexture = white_texture
batch_sampler: ^sdl.GPUSampler = sampler
if batch.texture_id != INVALID_TEXTURE {
if bound_texture := texture_gpu_handle(batch.texture_id); bound_texture != nil {
batch_texture = bound_texture
}
batch_sampler = get_sampler(batch.sampler)
}
if current_atlas != batch_texture || current_sampler != batch_sampler {
sdl.BindGPUFragmentSamplers(
render_pass,
0,
&sdl.GPUTextureSamplerBinding{texture = batch_texture, sampler = batch_sampler},
1,
)
current_atlas = batch_texture
current_sampler = batch_sampler
}
sdl.DrawGPUPrimitives(render_pass, 6, batch.count, 0, batch.offset)
case .Backdrop:
// Always a no-op here. Backdrop sub-batches are dispatched by run_backdrop_bracket;
// when this proc encounters one (only possible in Pass B, since Pass A and the no-
// backdrop fast path both stop their range before any .Backdrop index), we skip it.
}
}
}
sdl.EndGPURenderPass(render_pass)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Submission helpers ------------
// ---------------------------------------------------------------------------------------------------------------------
// Submit shape vertices (colored triangles) to the given layer for rendering.
// TODO: Should probably be renamed to better match tesselated naming conventions in the library.
prepare_shape :: proc(layer: ^Layer, vertices: []Vertex_2D) {
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, .Tessellated, offset, u32(len(vertices)))
}
// Submit an SDF primitive to the given layer for rendering. Requires the caller to build a
// Core_2D_Primitive directly, which is the internal GPU-layout struct.
//INTERNAL
prepare_sdf_primitive :: proc(layer: ^Layer, prim: Core_2D_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 an SDF primitive with optional texture binding.
// The texture-aware counterpart of `prepare_sdf_primitive`; lets shape procs route a
// texture_id and sampler into the sub-batch without growing the public API.
//INTERNAL
prepare_sdf_primitive_ex :: proc(
layer: ^Layer,
prim: Core_2D_Primitive,
texture_id: Texture_Id = INVALID_TEXTURE,
sampler: Sampler_Preset = DFT_SAMPLER,
) {
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, texture_id, sampler)
}
// Submit a text element to the given layer for rendering.
// Copies SDL_ttf vertices directly (with baked position) and copies indices for indexed drawing.
//INTERNAL
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)
// Premultiply text color once — reused across all glyph vertices.
pm_color := premultiply_color(text.color)
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_2D{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = pm_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,
Text_Batch {
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.
//INTERNAL
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]
// Premultiply text color once — reused across all glyph vertices.
pm_color := premultiply_color(text.color)
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_2D{position = apply_transform(transform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = pm_color},
)
}
append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices])
batch_idx := u32(len(GLOB.tmp_text_batches))
append(
&GLOB.tmp_text_batches,
Text_Batch {
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
}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Primitive builders ------------
// ---------------------------------------------------------------------------------------------------------------------
//----- Internal helpers ----------------------------------
// Resolve Texture_Fill zero-initialized fields to their defaults.
// Odin structs zero-initialize; Color{} and Rectangle{} are all-zero which is not a
// useful tint or UV rect. This proc substitutes sensible defaults for zero values.
//INTERNAL
resolve_texture_defaults :: #force_inline proc(
tf: Texture_Fill,
) -> (
tint: Color,
uv: Rectangle,
sampler: Sampler_Preset,
) {
tint = tf.tint == Color{} ? DFT_TINT : tf.tint
uv = tf.uv_rect == Rectangle{} ? DFT_UV_RECT : tf.uv_rect
sampler = tf.sampler
return
}
// Compute the visual center of a center-parametrized shape after applying
// Convention B origin semantics: `center` is where the origin-point lands in
// world space; the visual center is offset by -origin and then rotated around
// the landing point.
// visual_center = center + R(θ) · (-origin)
// When θ=0: visual_center = center - origin (pure positioning shift).
// When origin={0,0}: visual_center = center (no change).
//INTERNAL
compute_pivot_center :: proc(center: Vec2, origin: Vec2, sin_angle, cos_angle: f32) -> Vec2 {
if origin == {0, 0} do return center
return(
center +
{cos_angle * (-origin.x) - sin_angle * (-origin.y), sin_angle * (-origin.x) + cos_angle * (-origin.y)} \
)
}
// Compute the AABB half-extents of a rectangle with half-size (half_width, half_height) rotated by the given cos/sin.
//INTERNAL
rotated_aabb_half_extents :: proc(half_width, half_height, cos_angle, sin_angle: f32) -> [2]f32 {
cos_abs := abs(cos_angle)
sin_abs := abs(sin_angle)
return {half_width * cos_abs + half_height * sin_abs, half_width * sin_abs + half_height * cos_abs}
}
// Pack sin/cos into the Core_2D_Primitive.rotation_sc field as two f16 values.
//INTERNAL
pack_rotation_sc :: #force_inline proc(sin_angle, cos_angle: f32) -> u32 {
return pack_f16_pair(f16(sin_angle), f16(cos_angle))
}
//----- Shape builders ----------------------------------
// Build an RRect Core_2D_Primitive with bounds, params, and rotation computed from rectangle geometry.
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_rrect_primitive :: proc(
rect: Rectangle,
radii: Rectangle_Radii,
origin: Vec2,
rotation: f32,
feather_px: f32,
) -> Core_2D_Primitive {
max_radius := min(rect.width, rect.height) * 0.5
clamped_top_left := clamp(radii.top_left, 0, max_radius)
clamped_top_right := clamp(radii.top_right, 0, max_radius)
clamped_bottom_right := clamp(radii.bottom_right, 0, max_radius)
clamped_bottom_left := clamp(radii.bottom_left, 0, max_radius)
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
half_width := rect.width * 0.5
half_height := rect.height * 0.5
center_x := rect.x + half_width - origin.x
center_y := rect.y + half_height - origin.y
sin_angle: f32 = 0
cos_angle: f32 = 1
has_rotation := false
if needs_transform(origin, rotation) {
rotation_radians := math.to_radians(rotation)
sin_angle, cos_angle = math.sincos(rotation_radians)
has_rotation = rotation != 0
transform := build_pivot_rotation_sc({rect.x + origin.x, rect.y + origin.y}, origin, cos_angle, sin_angle)
new_center := apply_transform(transform, {half_width, half_height})
center_x = new_center.x
center_y = new_center.y
}
bounds_half_width, bounds_half_height := half_width, half_height
if has_rotation {
expanded := rotated_aabb_half_extents(half_width, half_height, cos_angle, sin_angle)
bounds_half_width = expanded.x
bounds_half_height = expanded.y
}
prim := Core_2D_Primitive {
bounds = {
center_x - bounds_half_width - padding,
center_y - bounds_half_height - padding,
center_x + bounds_half_width + padding,
center_y + bounds_half_height + padding,
},
rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0,
}
prim.params.rrect = RRect_Params {
half_size = {half_width * dpi_scale, half_height * dpi_scale},
radii = {
clamped_bottom_right * dpi_scale,
clamped_top_right * dpi_scale,
clamped_bottom_left * dpi_scale,
clamped_top_left * dpi_scale,
},
half_feather = half_feather,
}
return prim
}
// Build an RRect Core_2D_Primitive for a circle (fully-rounded square RRect).
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_circle_primitive :: proc(
center: Vec2,
radius: f32,
origin: Vec2,
rotation: f32,
feather_px: f32,
) -> Core_2D_Primitive {
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
actual_center := center
if origin != {0, 0} {
sin_a, cos_a := math.sincos(math.to_radians(rotation))
actual_center = compute_pivot_center(center, origin, sin_a, cos_a)
}
prim := Core_2D_Primitive {
bounds = {
actual_center.x - radius - padding,
actual_center.y - radius - padding,
actual_center.x + radius + padding,
actual_center.y + radius + padding,
},
}
scaled_radius := radius * dpi_scale
prim.params.rrect = RRect_Params {
half_size = {scaled_radius, scaled_radius},
radii = {scaled_radius, scaled_radius, scaled_radius, scaled_radius},
half_feather = half_feather,
}
return prim
}
// Build an Ellipse Core_2D_Primitive with bounds, params, and rotation computed from ellipse geometry.
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_ellipse_primitive :: proc(
center: Vec2,
radius_horizontal, radius_vertical: f32,
origin: Vec2,
rotation: f32,
feather_px: f32,
) -> Core_2D_Primitive {
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
actual_center := center
sin_angle: f32 = 0
cos_angle: f32 = 1
has_rotation := false
if needs_transform(origin, rotation) {
rotation_radians := math.to_radians(rotation)
sin_angle, cos_angle = math.sincos(rotation_radians)
actual_center = compute_pivot_center(center, origin, sin_angle, cos_angle)
has_rotation = rotation != 0
}
bound_horizontal, bound_vertical := radius_horizontal, radius_vertical
if has_rotation {
expanded := rotated_aabb_half_extents(radius_horizontal, radius_vertical, cos_angle, sin_angle)
bound_horizontal = expanded.x
bound_vertical = expanded.y
}
prim := Core_2D_Primitive {
bounds = {
actual_center.x - bound_horizontal - padding,
actual_center.y - bound_vertical - padding,
actual_center.x + bound_horizontal + padding,
actual_center.y + bound_vertical + padding,
},
rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0,
}
prim.params.ellipse = Ellipse_Params {
radii = {radius_horizontal * dpi_scale, radius_vertical * dpi_scale},
half_feather = half_feather,
}
return prim
}
// Build an NGon Core_2D_Primitive with bounds, params, and rotation computed from polygon geometry.
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_polygon_primitive :: proc(
center: Vec2,
sides: int,
radius: f32,
origin: Vec2,
rotation: f32,
feather_px: f32,
) -> Core_2D_Primitive {
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
actual_center := center
if origin != {0, 0} && rotation != 0 {
sin_a, cos_a := math.sincos(math.to_radians(rotation))
actual_center = compute_pivot_center(center, origin, sin_a, cos_a)
}
rotation_radians := math.to_radians(rotation)
sin_rot, cos_rot := math.sincos(rotation_radians)
prim := Core_2D_Primitive {
bounds = {
actual_center.x - radius - padding,
actual_center.y - radius - padding,
actual_center.x + radius + padding,
actual_center.y + radius + padding,
},
rotation_sc = rotation != 0 ? pack_rotation_sc(sin_rot, cos_rot) : 0,
}
prim.params.ngon = NGon_Params {
radius = radius * math.cos(math.PI / f32(sides)) * dpi_scale,
sides = f32(sides),
half_feather = half_feather,
}
return prim
}
// Build a Ring_Arc Core_2D_Primitive with bounds and params computed from ring/arc geometry.
// Pre-computes the angular boundary normals on the CPU so the fragment shader needs
// no per-pixel sin/cos. The radial SDF uses max(inner-r, r-outer) which correctly
// handles pie slices (inner_radius = 0) and full rings.
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_ring_arc_primitive :: proc(
center: Vec2,
inner_radius, outer_radius: f32,
start_angle: f32,
end_angle: f32,
origin: Vec2,
rotation: f32,
feather_px: f32,
) -> (
Core_2D_Primitive,
Shape_Flags,
) {
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
actual_center := center
rotation_offset: f32 = 0
if needs_transform(origin, rotation) {
sin_a, cos_a := math.sincos(math.to_radians(rotation))
actual_center = compute_pivot_center(center, origin, sin_a, cos_a)
rotation_offset = math.to_radians(rotation)
}
start_rad := math.to_radians(start_angle) + rotation_offset
end_rad := math.to_radians(end_angle) + rotation_offset
// Normalize arc span to [0, 2π]
arc_span := end_rad - start_rad
if arc_span < 0 {
arc_span += 2 * math.PI
}
// Pre-compute edge normals and arc flags on CPU — no per-pixel trig needed.
// arc_flags: {} = full ring, {.Arc_Narrow} = span ≤ π (intersect), {.Arc_Wide} = span > π (union)
arc_flags: Shape_Flags = {}
normal_start: [2]f32 = {}
normal_end: [2]f32 = {}
if arc_span < 2 * math.PI - 0.001 {
sin_start, cos_start := math.sincos(start_rad)
sin_end, cos_end := math.sincos(end_rad)
normal_start = {sin_start, -cos_start}
normal_end = {-sin_end, cos_end}
arc_flags = arc_span <= math.PI ? {.Arc_Narrow} : {.Arc_Wide}
}
prim := Core_2D_Primitive {
bounds = {
actual_center.x - outer_radius - padding,
actual_center.y - outer_radius - padding,
actual_center.x + outer_radius + padding,
actual_center.y + outer_radius + padding,
},
}
prim.params.ring_arc = Ring_Arc_Params {
inner_radius = inner_radius * dpi_scale,
outer_radius = outer_radius * dpi_scale,
normal_start = normal_start,
normal_end = normal_end,
half_feather = half_feather,
}
return prim, arc_flags
}
//----- Brush and outline ----------------------------------
// Apply brush fill and outline to a primitive, then submit it.
// Dispatches to the correct sub-batch based on the Brush variant.
// All parameters (outline_width) are in logical pixels, matching the rest of the public API.
// The helper converts to physical pixels for GPU packing internally.
//INTERNAL
apply_brush_and_outline :: proc(
layer: ^Layer,
prim: ^Core_2D_Primitive,
kind: Shape_Kind,
brush: Brush,
outline_color: Color,
outline_width: f32,
extra_flags: Shape_Flags = {},
) {
flags: Shape_Flags = extra_flags
// Fill — determined by the Brush variant.
texture_id := INVALID_TEXTURE
sampler := DFT_SAMPLER
switch b in brush {
case Color: prim.color = b
case Linear_Gradient:
flags += {.Gradient}
prim.color = b.start_color
prim.effects.gradient_color = b.end_color
rad := math.to_radians(b.angle)
sin_a, cos_a := math.sincos(rad)
prim.effects.gradient_dir_sc = pack_f16_pair(f16(cos_a), f16(sin_a))
case Radial_Gradient:
flags += {.Gradient, .Gradient_Radial}
prim.color = b.inner_color
prim.effects.gradient_color = b.outer_color
case Texture_Fill:
flags += {.Textured}
tint, uv, sam := resolve_texture_defaults(b)
prim.color = tint
prim.uv_rect = {uv.x, uv.y, uv.width, uv.height}
texture_id = b.id
sampler = sam
}
// Outline — orthogonal to all Brush variants.
if outline_width > 0 {
flags += {.Outline}
prim.effects.outline_color = outline_color
prim.effects.outline_packed = pack_f16_pair(f16(outline_width * GLOB.dpi_scaling), 0)
// Expand bounds to contain the outline (bounds are in logical pixels)
prim.bounds[0] -= outline_width
prim.bounds[1] -= outline_width
prim.bounds[2] += outline_width
prim.bounds[3] += outline_width
}
// Set .Rotated flag if rotation_sc was populated by the build proc
if prim.rotation_sc != 0 {
flags += {.Rotated}
}
prim.flags = pack_kind_flags(kind, flags)
prepare_sdf_primitive_ex(layer, prim^, texture_id, sampler)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Public draw procs ------------
// ---------------------------------------------------------------------------------------------------------------------
// Draw a filled rectangle via SDF with optional per-corner rounding radii.
// Use `uniform_radii(rect, roundness)` to compute uniform radii from a 01 fraction.
//
// Origin semantics:
// `origin` is a local offset from the rect's top-left corner that selects both the positioning
// anchor and the rotation pivot. `rect.x, rect.y` specifies where that anchor point lands in
// world space. When `origin = {0, 0}` (default), `rect.x, rect.y` is the top-left corner.
// Rotation always occurs around the anchor point.
rectangle :: proc(
layer: ^Layer,
rect: Rectangle,
brush: Brush,
outline_color: Color = {},
outline_width: f32 = 0,
radii: Rectangle_Radii = {},
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
) {
prim := build_rrect_primitive(rect, radii, origin, rotation, feather_px)
apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}
// Draw a filled circle via SDF (emitted as a fully-rounded RRect).
//
// Origin semantics (Convention B):
// `origin` is a local offset from the shape's center that selects both the positioning anchor
// and the rotation pivot. The `center` parameter specifies where that anchor point lands in
// world space. When `origin = {0, 0}` (default), `center` is the visual center.
// When `origin = {r, 0}`, the point `r` pixels to the right of the shape center lands at
// `center`, shifting the shape left by `r`.
circle :: proc(
layer: ^Layer,
center: Vec2,
radius: f32,
brush: Brush,
outline_color: Color = {},
outline_width: f32 = 0,
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
) {
prim := build_circle_primitive(center, radius, origin, rotation, feather_px)
apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}
// Draw a filled ellipse via SDF.
// Origin semantics: see `circle`.
ellipse :: proc(
layer: ^Layer,
center: Vec2,
radius_horizontal, radius_vertical: f32,
brush: Brush,
outline_color: Color = {},
outline_width: f32 = 0,
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
) {
prim := build_ellipse_primitive(center, radius_horizontal, radius_vertical, origin, rotation, feather_px)
apply_brush_and_outline(layer, &prim, .Ellipse, brush, outline_color, outline_width)
}
// Draw a filled regular polygon via SDF.
// `sides` must be >= 3. The polygon is inscribed in a circle of the given `radius`.
// Origin semantics: see `circle`.
polygon :: proc(
layer: ^Layer,
center: Vec2,
sides: int,
radius: f32,
brush: Brush,
outline_color: Color = {},
outline_width: f32 = 0,
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
) {
if sides < 3 do return
prim := build_polygon_primitive(center, sides, radius, origin, rotation, feather_px)
apply_brush_and_outline(layer, &prim, .NGon, brush, outline_color, outline_width)
}
// Draw a ring, arc, or pie slice via SDF.
// Full ring by default. Pass start_angle/end_angle (degrees) for partial arcs.
// Use inner_radius = 0 for pie slices (sectors).
// Origin semantics: see `circle`.
ring :: proc(
layer: ^Layer,
center: Vec2,
inner_radius, outer_radius: f32,
brush: Brush,
outline_color: Color = {},
outline_width: f32 = 0,
start_angle: f32 = 0,
end_angle: f32 = DFT_CIRC_END_ANGLE,
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
) {
prim, arc_flags := build_ring_arc_primitive(
center,
inner_radius,
outer_radius,
start_angle,
end_angle,
origin,
rotation,
feather_px,
)
apply_brush_and_outline(layer, &prim, .Ring_Arc, brush, outline_color, outline_width, arc_flags)
}
// Draw a line segment via SDF (emitted as a rotated capsule-shaped RRect).
// Round caps are produced by setting corner radii equal to half the thickness.
line :: proc(
layer: ^Layer,
start_position, end_position: Vec2,
brush: Brush,
thickness: f32 = DFT_STROKE_THICKNESS,
outline_color: Color = {},
outline_width: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
) {
delta_x := end_position.x - start_position.x
delta_y := end_position.y - start_position.y
seg_length := math.sqrt(delta_x * delta_x + delta_y * delta_y)
if seg_length < 0.0001 do return
rotation_radians := math.atan2(delta_y, delta_x)
sin_angle, cos_angle := math.sincos(rotation_radians)
center_x := (start_position.x + end_position.x) * 0.5
center_y := (start_position.y + end_position.y) * 0.5
half_length := seg_length * 0.5
half_thickness := thickness * 0.5
cap_radius := half_thickness
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
// Expand bounds for rotation
bounds_half := rotated_aabb_half_extents(half_length + cap_radius, half_thickness, cos_angle, sin_angle)
prim := Core_2D_Primitive {
bounds = {
center_x - bounds_half.x - padding,
center_y - bounds_half.y - padding,
center_x + bounds_half.x + padding,
center_y + bounds_half.y + padding,
},
rotation_sc = pack_rotation_sc(sin_angle, cos_angle),
}
prim.params.rrect = RRect_Params {
half_size = {(half_length + cap_radius) * dpi_scale, half_thickness * dpi_scale},
radii = {
cap_radius * dpi_scale,
cap_radius * dpi_scale,
cap_radius * dpi_scale,
cap_radius * dpi_scale,
},
half_feather = half_feather,
}
apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}
// Draw a line strip via decomposed SDF line segments.
line_strip :: proc(
layer: ^Layer,
points: []Vec2,
brush: Brush,
thickness: f32 = DFT_STROKE_THICKNESS,
outline_color: Color = {},
outline_width: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
) {
if len(points) < 2 do return
for i in 0 ..< len(points) - 1 {
line(layer, points[i], points[i + 1], brush, thickness, outline_color, outline_width, feather_px)
}
}