1602 lines
54 KiB
Odin
1602 lines
54 KiB
Odin
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 0–1 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)
|
||
}
|
||
}
|