draw-improvements (#17)

Major rework to draw rendering system. We are making a SDF first rendering system with tesselated stuff only as a fallback strategy for specific situations where SDF is particularly poorly suited

Co-authored-by: Zachary Levy <zachary@sunforge.is>
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-04-24 07:57:44 +00:00
parent 37da2ea068
commit bca19277b3
15 changed files with 1773 additions and 1736 deletions

View File

@@ -4,6 +4,7 @@ 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"
@@ -27,72 +28,22 @@ BUFFER_INIT_SIZE :: 256
INITIAL_LAYER_SIZE :: 5
INITIAL_SCISSOR_SIZE :: 10
// ---------------------------------------------------------------------------------------------------------------------
// ----- Color -------------------------
// ---------------------------------------------------------------------------------------------------------------------
// 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)
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 or user texture, mode 0
Text, // indexed, atlas texture, mode 0
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
texture_id: Texture_Id,
sampler: Sampler_Preset,
}
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 ------------------
// ---------------------------------------------------------------------------------------------------------------------
// ----- Default parameter values -----
// Named constants for non-zero default procedure parameters. Centralizes magic numbers
// so they can be tuned in one place and referenced by name in proc signatures.
DFT_FEATHER_PX :: 1 // Total AA feather width in physical pixels (half on each side of boundary).
DFT_STROKE_THICKNESS :: 1 // Default line/stroke thickness in logical pixels.
DFT_FONT_SIZE :: 44 // Default font size in points for text rendering.
DFT_CIRC_END_ANGLE :: 360 // Full-circle end angle in degrees (ring/arc).
DFT_UV_RECT :: Rectangle{0, 0, 1, 1} // Full-texture UV rect (rectangle_texture).
DFT_TINT :: WHITE // Default texture tint (rectangle_texture, clay_image).
DFT_TEXT_COLOR :: BLACK // Default text color.
DFT_CLEAR_COLOR :: BLACK // Default clear color for end().
DFT_SAMPLER :: Sampler_Preset.Linear_Clamp // Default texture sampler preset.
GLOB: Global
@@ -153,6 +104,136 @@ Global :: struct {
odin_context: runtime.Context, // Odin context captured at init for use in callbacks.
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Core types --------------------
// ---------------------------------------------------------------------------------------------------------------------
// A 2D position in world space. Non-distinct alias for [2]f32 — bare literals like {100, 200}
// work at non-ambiguous call sites.
//
// Coordinate system: origin is the top-left corner of the window/layer. X increases rightward,
// Y increases downward. This matches SDL, HTML Canvas, and most 2D UI coordinate conventions.
// All position parameters in the draw API (center, origin, start_position, end_position, etc.)
// use this coordinate system.
//
// Units are logical pixels (pre-DPI-scaling). The renderer multiplies by dpi_scaling internally
// before uploading to the GPU. A Vec2{100, 50} refers to the same visual location regardless of
// display DPI.
Vec2 :: [2]f32
// An RGBA color with 8 bits per channel. Distinct type over [4]u8 so that proc-group
// overloads can disambiguate Color from other 4-byte structs.
//
// Channel order: R, G, B, A (indices 0, 1, 2, 3). Alpha 255 is fully opaque, 0 is fully
// transparent. This matches the GPU-side layout: the shader unpacks via unpackUnorm4x8 which
// reads the bytes in memory order as R, G, B, A and normalizes each to [0, 1].
//
// When used in the Primitive struct (Primitive.color), the 4 bytes are stored as a u32 in
// native byte order and unpacked by the shader.
Color :: [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}
// Per-corner rounding radii for rectangles, specified clockwise from top-left.
// All values are in logical pixels (pre-DPI-scaling).
Rectangle_Radii :: struct {
top_left: f32,
top_right: f32,
bottom_right: f32,
bottom_left: f32,
}
// A linear gradient between two colors along an arbitrary angle.
// The `end_color` is the color at the end of the gradient direction; the shape's fill `color`
// parameter acts as the start color. `angle` is in degrees: 0 = left-to-right, 90 = top-to-bottom.
Linear_Gradient :: struct {
end_color: Color,
angle: f32,
}
// A radial gradient between two colors from center to edge.
// The `outer_color` is the color at the shape's edge; the shape's fill `color` parameter
// acts as the inner (center) color.
Radial_Gradient :: struct {
outer_color: Color,
}
// Tagged union for specifying a gradient on any shape. Defaults to `nil` (no gradient).
// When a gradient is active, the shape's `color` parameter becomes the start/inner color,
// and the gradient struct carries the end/outer color plus any type-specific parameters.
//
// Gradient and Textured are mutually exclusive on the same primitive. If a shape uses
// `rectangle_texture`, gradients are not applicable — use the tint color instead.
Gradient :: union {
Linear_Gradient,
Radial_Gradient,
}
// Convert clay.Color ([4]c.float in 0255 range) to Color.
color_from_clay :: #force_inline 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}
}
// Pre-multiply RGB channels by alpha. The tessellated vertex path and text path require
// premultiplied colors because the blend state is ONE, ONE_MINUS_SRC_ALPHA and the
// tessellated fragment shader passes vertex color through without further modification.
// Users who construct Vertex structs manually for prepare_shape must premultiply their colors.
premultiply_color :: #force_inline proc(color: Color) -> Color {
a := u32(color[3])
return Color {
u8((u32(color[0]) * a + 127) / 255),
u8((u32(color[1]) * a + 127) / 255),
u8((u32(color[2]) * a + 127) / 255),
color[3],
}
}
Rectangle :: struct {
x: f32,
y: f32,
width: f32,
height: f32,
}
Sub_Batch_Kind :: enum u8 {
Tessellated, // non-indexed, white texture or user texture, mode 0
Text, // indexed, atlas texture, mode 0
SDF, // instanced unit quad, white texture or user texture, mode 1
}
Sub_Batch :: struct {
kind: Sub_Batch_Kind,
offset: u32, // Tessellated: vertex offset; Text: text_batch index; SDF: primitive index
count: u32, // Tessellated: vertex count; Text: always 1; SDF: primitive count
texture_id: Texture_Id,
sampler: Sampler_Preset,
}
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,
}
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
@@ -162,10 +243,6 @@ Init_Options :: struct {
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(
@@ -378,12 +455,13 @@ new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer {
// ---------------------------------------------------------------------------------------------------------------------
// 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) {
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)))
append_or_extend_sub_batch(scissor, layer, .Tessellated, offset, u32(len(vertices)))
}
// Submit an SDF primitive to the given layer for rendering.
@@ -409,6 +487,9 @@ prepare_text :: proc(layer: ^Layer, text: Text) {
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))
@@ -419,7 +500,7 @@ prepare_text :: proc(layer: ^Layer, text: Text) {
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},
Vertex{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = pm_color},
)
}
@@ -457,6 +538,9 @@ prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform
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))
@@ -469,7 +553,7 @@ prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform
// 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},
Vertex{position = apply_transform(transform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = pm_color},
)
}
@@ -502,7 +586,7 @@ append_or_extend_sub_batch :: proc(
offset: u32,
count: u32,
texture_id: Texture_Id = INVALID_TEXTURE,
sampler: Sampler_Preset = .Linear_Clamp,
sampler: Sampler_Preset = DFT_SAMPLER,
) {
if scissor.sub_batch_len > 0 {
last := &GLOB.tmp_sub_batches[scissor.sub_batch_start + scissor.sub_batch_len - 1]
@@ -595,6 +679,9 @@ prepare_clay_batch :: proc(
switch (render_command.commandType) {
case clay.RenderCommandType.None:
log.errorf(
"Received render command with type None. This generally means we're in some kind of fucked up state.",
)
case clay.RenderCommandType.Text:
render_data := render_command.renderData.text
txt := string(render_data.stringContents.chars[:render_data.stringContents.length])
@@ -609,46 +696,29 @@ prepare_clay_batch :: proc(
)
prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)})
case clay.RenderCommandType.Image:
// Any texture
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}
radii := Rectangle_Radii {
top_left = cr.topLeft,
top_right = cr.topRight,
bottom_right = cr.bottomRight,
bottom_left = 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)
}
rectangle(layer, bounds, bg, radii = radii)
}
// 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,
)
}
// Draw the image
rectangle_texture(layer, inner, img_data.texture_id, img_data.tint, uv, sampler, radii)
case clay.RenderCommandType.ScissorStart:
if bounds.width == 0 || bounds.height == 0 do continue
@@ -680,34 +750,38 @@ prepare_clay_batch :: proc(
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)
radii := Rectangle_Radii {
top_left = cr.topLeft,
top_right = cr.topRight,
bottom_right = cr.bottomRight,
bottom_left = cr.bottomLeft,
}
rectangle(layer, bounds, color, radii = radii)
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)
radii := Rectangle_Radii {
top_left = cr.topLeft,
top_right = cr.topRight,
bottom_right = cr.bottomRight,
bottom_left = cr.bottomLeft,
}
rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii)
case clay.RenderCommandType.Custom: if custom_draw != nil {
custom_draw(layer, bounds, render_command.renderData.custom)
} else {
log.error("Received clay render command of type custom but no custom_draw proc provided.")
}
}
}
}
// 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) {
end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = DFT_CLEAR_COLOR) {
cmd_buffer := sdl.AcquireGPUCommandBuffer(device)
if cmd_buffer == nil {
log.panicf("Failed to acquire GPU command buffer: %s", sdl.GetError())
@@ -740,7 +814,16 @@ end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = BL
render_texture = GLOB.msaa_texture
}
clear_color_f32 := color_to_f32(clear_color)
// Premultiply clear color: the blend state is ONE, ONE_MINUS_SRC_ALPHA (premultiplied),
// so the clear color must also be premultiplied for correct background compositing.
clear_color_straight := color_to_f32(clear_color)
clear_alpha := clear_color_straight[3]
clear_color_f32 := [4]f32 {
clear_color_straight[0] * clear_alpha,
clear_color_straight[1] * clear_alpha,
clear_color_straight[2] * clear_alpha,
clear_alpha,
}
// Draw layers. One render pass per layer; sub-batches draw in submission order within each scissor.
for &layer, index in GLOB.layers {
@@ -948,10 +1031,20 @@ Transform_2D :: struct {
// 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 {
build_pivot_rotation :: proc(position: Vec2, origin: Vec2, rotation_deg: f32) -> Transform_2D {
radians := math.to_radians(rotation_deg)
cos_angle := math.cos(radians)
sin_angle := math.sin(radians)
return build_pivot_rotation_sc(position, origin, cos_angle, sin_angle)
}
// Variant of build_pivot_rotation that accepts pre-computed cos/sin values,
// avoiding redundant trigonometry when the caller has already computed them.
build_pivot_rotation_sc :: #force_inline proc(
position: Vec2,
origin: Vec2,
cos_angle, sin_angle: f32,
) -> Transform_2D {
return Transform_2D {
m00 = cos_angle,
m01 = -sin_angle,
@@ -963,7 +1056,7 @@ build_pivot_rotation :: proc(position: [2]f32, origin: [2]f32, rotation_deg: f32
}
// 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 {
apply_transform :: #force_inline proc(transform: Transform_2D, point: Vec2) -> Vec2 {
return {
transform.m00 * point.x + transform.m01 * point.y + transform.tx,
transform.m10 * point.x + transform.m11 * point.y + transform.ty,
@@ -973,7 +1066,7 @@ apply_transform :: #force_inline proc(transform: Transform_2D, point: [2]f32) ->
// 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 {
needs_transform :: #force_inline proc(origin: Vec2, rotation: f32) -> bool {
return origin != {0, 0} || rotation != 0
}