Harbor

branch main
showing the latest snapshot on main
draw2d.odin 22.2 KB · Plain text
renderer/draw2d.odin 0644 Raw
package renderer

import "core:math"
import "core:mem"
import glsl "core:math/linalg/glsl"
import "../resource"
import bk "../backend"

MAX_QUAD_COUNT   :: 10000
MAX_VERTEX_COUNT :: MAX_QUAD_COUNT * 4
MAX_INDEX_COUNT  :: MAX_QUAD_COUNT * 6
CIRCLE_SEGMENTS  :: 64
VERTEX_SIZE      :: 48  // matches gpu.Vertex layout

// Mirrors gpu.Vertex layout to avoid circular import
Draw2D_Vertex :: struct {
	position:  [3]f32,
	normal:    [3]f32,
	tex_coord: [2]f32,
	color:     [4]f32,
}

Batch_2D :: struct {
	vertex_buffers: [bk.MAX_FRAMES_IN_FLIGHT]bk.Buffer_Handle,
	index_buffers:  [bk.MAX_FRAMES_IN_FLIGHT]bk.Buffer_Handle,
	vertices:       [dynamic]Draw2D_Vertex,
	indices:        [dynamic]u32,
	gfx_pipeline:   bk.Pipeline_Handle,
	proj_view:      glsl.mat4x4,
	screen_proj:    glsl.mat4x4,
	current_texture_id: u32,
	resource_state:     ^resource.Resource_State,
	// Per-frame state
	backend:     ^bk.Backend,
	frame_ctx:   bk.Frame_Context,
	frame_index: u32,
	// GPU buffer write offsets
	vertex_buf_offset: int,
	index_buf_offset:  int,
	// Lit rendering support
	lit_pipeline:   bk.Pipeline_Handle,
	light_desc_set: bk.Descriptor_Handle,
	use_lit:        bool,
	// Back-pointer for deferred render pass
	parent_state: ^Renderer_State,
}

init_batch_2d :: proc(b: ^bk.Backend, res_state: ^resource.Resource_State) -> (batch: Batch_2D, ok: bool) {
	batch.resource_state = res_state

	// Create per-frame vertex and index buffers
	for i in 0..<bk.MAX_FRAMES_IN_FLIGHT {
		vb, vb_ok := b.create_buffer(bk.Buffer_Desc{
			size   = u64(MAX_VERTEX_COUNT * VERTEX_SIZE),
			usage  = {.Vertex},
			memory = {.Host_Visible, .Host_Coherent},
		})
		if !vb_ok {
			shutdown_batch_2d(&batch, b)
			return {}, false
		}
		// Persistently map
		b.map_buffer(vb)
		batch.vertex_buffers[i] = vb

		ib, ib_ok := b.create_buffer(bk.Buffer_Desc{
			size   = u64(MAX_INDEX_COUNT * size_of(u32)),
			usage  = {.Index},
			memory = {.Host_Visible, .Host_Coherent},
		})
		if !ib_ok {
			shutdown_batch_2d(&batch, b)
			return {}, false
		}
		b.map_buffer(ib)
		batch.index_buffers[i] = ib
	}

	// Initialize dynamic arrays with reserved capacity
	batch.vertices = make([dynamic]Draw2D_Vertex, 0, MAX_VERTEX_COUNT)
	batch.indices = make([dynamic]u32, 0, MAX_INDEX_COUNT)

	// Default orthographic projection (top-left origin, Y-down)
	extent := b.get_extent()
	batch.screen_proj = ortho_2d(0, f32(extent.width), 0, f32(extent.height))
	batch.proj_view = batch.screen_proj

	// Pipeline starts as NULL — must be set externally via set_pipeline_2d.
	return batch, true
}

shutdown_batch_2d :: proc(batch: ^Batch_2D, b: ^bk.Backend) {
	// Pipeline is owned externally (Vortex) — do not destroy here.
	for i in 0..<bk.MAX_FRAMES_IN_FLIGHT {
		b.unmap_buffer(batch.vertex_buffers[i])
		b.destroy_buffer(batch.vertex_buffers[i])

		b.unmap_buffer(batch.index_buffers[i])
		b.destroy_buffer(batch.index_buffers[i])
	}

	delete(batch.vertices)
	delete(batch.indices)
}

// Set per-frame references at the start of each frame
begin_batch_frame :: proc(batch: ^Batch_2D, b: ^bk.Backend, ctx: bk.Frame_Context) {
	batch.backend = b
	batch.frame_ctx = ctx
	batch.frame_index = ctx.frame_index
}

reset_batch_2d :: proc(batch: ^Batch_2D) {
	clear(&batch.vertices)
	clear(&batch.indices)
	batch.current_texture_id = 0
	batch.vertex_buf_offset = 0
	batch.index_buf_offset = 0
}

flush_batch_2d :: proc(batch: ^Batch_2D) {
	if batch.gfx_pipeline == bk.NULL_PIPELINE { return }

	vert_count := len(batch.vertices)
	idx_count := len(batch.indices)
	if vert_count == 0 || idx_count == 0 {
		return
	}

	b := batch.backend
	ctx := batch.frame_ctx
	frame := batch.frame_index

	// Ensure render pass is active before recording draw commands
	if batch.parent_state != nil {
		ensure_render_pass(batch.parent_state)
	}

	// Copy vertex data to mapped GPU buffer at current offset
	vb_mapped := b.get_buffer_mapped(batch.vertex_buffers[frame])
	vb_dst := rawptr(uintptr(vb_mapped) + uintptr(batch.vertex_buf_offset * VERTEX_SIZE))
	mem.copy(vb_dst, raw_data(batch.vertices), vert_count * VERTEX_SIZE)

	// Copy index data to mapped GPU buffer at current offset
	ib_mapped := b.get_buffer_mapped(batch.index_buffers[frame])
	ib_dst := rawptr(uintptr(ib_mapped) + uintptr(batch.index_buf_offset * size_of(u32)))
	mem.copy(ib_dst, raw_data(batch.indices), idx_count * size_of(u32))

	// Choose pipeline (lit or unlit)
	active_pipeline: bk.Pipeline_Handle
	if batch.use_lit && batch.lit_pipeline != bk.NULL_PIPELINE {
		active_pipeline = batch.lit_pipeline
	} else {
		active_pipeline = batch.gfx_pipeline
	}

	// Bind pipeline
	b.bind_graphics_pipeline(ctx, active_pipeline)
	if batch.parent_state != nil {
		batch.parent_state.profile.batch_flushes += 1
		batch.parent_state.profile.pipeline_binds += 1
		batch.parent_state.profile.vertices_submitted += u64(vert_count)
		batch.parent_state.profile.indices_submitted += u64(idx_count)
	}

	// Push projection matrix
	b.push_constants(ctx, active_pipeline, {.Vertex}, 0, 64, &batch.proj_view)
	if batch.parent_state != nil {
		batch.parent_state.profile.push_constants += 1
	}

	// Bind light UBO at set=1 if using lit pipeline
	if batch.use_lit && batch.lit_pipeline != bk.NULL_PIPELINE {
		b.bind_descriptor_set(ctx, active_pipeline, batch.light_desc_set, 1)
		if batch.parent_state != nil {
			batch.parent_state.profile.descriptor_binds += 1
		}
	}

	// Bind texture descriptor set at set=0
	if batch.resource_state != nil {
		desc_set := resource.get_texture_descriptor_set(batch.resource_state, batch.current_texture_id)
		b.bind_descriptor_set(ctx, active_pipeline, desc_set, 0)
		if batch.parent_state != nil {
			batch.parent_state.profile.descriptor_binds += 1
		}
	}

	// Bind vertex buffer
	b.bind_vertex_buffer(ctx, batch.vertex_buffers[frame])

	// Bind index buffer
	b.bind_index_buffer(ctx, batch.index_buffers[frame])

	// Draw with offsets into the buffer
	b.draw_indexed(ctx, u32(idx_count), 1, u32(batch.index_buf_offset), i32(batch.vertex_buf_offset), 0)
	if batch.parent_state != nil {
		batch.parent_state.profile.draw_indexed_calls += 1
	}

	// Advance buffer offsets for next flush
	batch.vertex_buf_offset += vert_count
	batch.index_buf_offset += idx_count

	// Clear CPU-side arrays for next batch
	clear(&batch.vertices)
	clear(&batch.indices)
}

// Switch the current texture. Flushes the batch if texture changes.
batch_set_texture :: proc(batch: ^Batch_2D, texture_id: u32) {
	if texture_id == batch.current_texture_id {
		return
	}
	// Flush current geometry with the old texture
	if len(batch.vertices) > 0 && batch.backend != nil {
		flush_batch_2d(batch)
	}
	batch.current_texture_id = texture_id
}

// --- Shape generation ---

batch_add_rectangle :: proc(batch: ^Batch_2D, x, y, w, h: f32, color: [4]f32) {
	if len(batch.vertices) + 4 > MAX_VERTEX_COUNT || len(batch.indices) + 6 > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))

	append(&batch.vertices,
		Draw2D_Vertex{ position = {x, y, 0},         normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = {x + w, y, 0},     normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = {x + w, y + h, 0}, normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = {x, y + h, 0},     normal = {0, 0, 1}, color = color },
	)

	append(&batch.indices, base, base + 1, base + 2, base + 2, base + 3, base)
}

batch_add_textured_quad :: proc(batch: ^Batch_2D, x, y, w, h: f32, u0, v0, u1, v1: f32, color: [4]f32) {
	if len(batch.vertices) + 4 > MAX_VERTEX_COUNT || len(batch.indices) + 6 > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))

	append(&batch.vertices,
		Draw2D_Vertex{ position = {x, y, 0},         normal = {0, 0, 1}, tex_coord = {u0, v0}, color = color },
		Draw2D_Vertex{ position = {x + w, y, 0},     normal = {0, 0, 1}, tex_coord = {u1, v0}, color = color },
		Draw2D_Vertex{ position = {x + w, y + h, 0}, normal = {0, 0, 1}, tex_coord = {u1, v1}, color = color },
		Draw2D_Vertex{ position = {x, y + h, 0},     normal = {0, 0, 1}, tex_coord = {u0, v1}, color = color },
	)

	append(&batch.indices, base, base + 1, base + 2, base + 2, base + 3, base)
}

batch_add_textured_quad_rotated :: proc(batch: ^Batch_2D, x, y, w, h: f32, u0, v0, u1, v1: f32, rotation: f32, color: [4]f32) {
	if len(batch.vertices) + 4 > MAX_VERTEX_COUNT || len(batch.indices) + 6 > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))

	// Rotate around the top-left corner (x, y)
	cx := x + w * 0.5
	cy := y + h * 0.5
	rad := rotation * math.PI / 180.0
	cos_r := math.cos(rad)
	sin_r := math.sin(rad)

	rotate :: proc(px, py, cx, cy, cos_r, sin_r: f32) -> [3]f32 {
		dx := px - cx
		dy := py - cy
		return {cx + dx * cos_r - dy * sin_r, cy + dx * sin_r + dy * cos_r, 0}
	}

	append(&batch.vertices,
		Draw2D_Vertex{ position = rotate(x, y, cx, cy, cos_r, sin_r),         normal = {0, 0, 1}, tex_coord = {u0, v0}, color = color },
		Draw2D_Vertex{ position = rotate(x + w, y, cx, cy, cos_r, sin_r),     normal = {0, 0, 1}, tex_coord = {u1, v0}, color = color },
		Draw2D_Vertex{ position = rotate(x + w, y + h, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, tex_coord = {u1, v1}, color = color },
		Draw2D_Vertex{ position = rotate(x, y + h, cx, cy, cos_r, sin_r),     normal = {0, 0, 1}, tex_coord = {u0, v1}, color = color },
	)

	append(&batch.indices, base, base + 1, base + 2, base + 2, base + 3, base)
}

batch_add_circle :: proc(batch: ^Batch_2D, cx, cy, radius: f32, color: [4]f32) {
	vert_needed := 1 + CIRCLE_SEGMENTS
	idx_needed := CIRCLE_SEGMENTS * 3

	if len(batch.vertices) + vert_needed > MAX_VERTEX_COUNT || len(batch.indices) + idx_needed > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))

	// Center vertex
	append(&batch.vertices, Draw2D_Vertex{ position = {cx, cy, 0}, normal = {0, 0, 1}, color = color })

	// Perimeter vertices
	for i in 0..<CIRCLE_SEGMENTS {
		angle := f32(i) * 2.0 * math.PI / f32(CIRCLE_SEGMENTS)
		px := cx + radius * math.cos(angle)
		py := cy + radius * math.sin(angle)
		append(&batch.vertices, Draw2D_Vertex{ position = {px, py, 0}, normal = {0, 0, 1}, color = color })
	}

	// Triangle fan indices
	for i in 0..<u32(CIRCLE_SEGMENTS) {
		next := (i + 1) % u32(CIRCLE_SEGMENTS)
		append(&batch.indices, base, base + 1 + i, base + 1 + next)
	}
}

// Rotate point (px, py) around center (cx, cy).
rotate_point :: proc(px, py, cx, cy, cos_r, sin_r: f32) -> [3]f32 {
	dx := px - cx
	dy := py - cy
	return {cx + dx * cos_r - dy * sin_r, cy + dx * sin_r + dy * cos_r, 0}
}

batch_add_triangle :: proc(batch: ^Batch_2D, x1, y1, x2, y2, x3, y3: f32, color: [4]f32) {
	if len(batch.vertices) + 3 > MAX_VERTEX_COUNT || len(batch.indices) + 3 > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))

	append(&batch.vertices,
		Draw2D_Vertex{ position = {x1, y1, 0}, normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = {x2, y2, 0}, normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = {x3, y3, 0}, normal = {0, 0, 1}, color = color },
	)

	append(&batch.indices, base, base + 1, base + 2)
}

batch_add_rectangle_rotated :: proc(batch: ^Batch_2D, cx, cy, half_w, half_h: f32, color: [4]f32, rotation: f32 = 0) {
	if len(batch.vertices) + 4 > MAX_VERTEX_COUNT || len(batch.indices) + 6 > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))
	cos_r := math.cos(rotation)
	sin_r := math.sin(rotation)

	append(&batch.vertices,
		Draw2D_Vertex{ position = rotate_point(cx - half_w, cy - half_h, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx + half_w, cy - half_h, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx + half_w, cy + half_h, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx - half_w, cy + half_h, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
	)

	append(&batch.indices, base, base + 1, base + 2, base + 2, base + 3, base)
}

batch_add_ellipse :: proc(batch: ^Batch_2D, cx, cy, rx, ry: f32, color: [4]f32, rotation: f32 = 0) {
	vert_needed := 1 + CIRCLE_SEGMENTS
	idx_needed := CIRCLE_SEGMENTS * 3

	if len(batch.vertices) + vert_needed > MAX_VERTEX_COUNT || len(batch.indices) + idx_needed > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))
	cos_r := math.cos(rotation)
	sin_r := math.sin(rotation)

	append(&batch.vertices, Draw2D_Vertex{ position = {cx, cy, 0}, normal = {0, 0, 1}, color = color })

	for i in 0..<CIRCLE_SEGMENTS {
		angle := f32(i) * 2.0 * math.PI / f32(CIRCLE_SEGMENTS)
		px := cx + rx * math.cos(angle)
		py := cy + ry * math.sin(angle)
		append(&batch.vertices, Draw2D_Vertex{ position = rotate_point(px, py, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color })
	}

	for i in 0..<u32(CIRCLE_SEGMENTS) {
		next := (i + 1) % u32(CIRCLE_SEGMENTS)
		append(&batch.indices, base, base + 1 + i, base + 1 + next)
	}
}

batch_add_ring :: proc(batch: ^Batch_2D, cx, cy, inner_r, outer_r: f32, color: [4]f32, rotation: f32 = 0) {
	vert_needed := CIRCLE_SEGMENTS * 2
	idx_needed := CIRCLE_SEGMENTS * 6

	if len(batch.vertices) + vert_needed > MAX_VERTEX_COUNT || len(batch.indices) + idx_needed > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))
	cos_r := math.cos(rotation)
	sin_r := math.sin(rotation)

	for i in 0..<CIRCLE_SEGMENTS {
		angle := f32(i) * 2.0 * math.PI / f32(CIRCLE_SEGMENTS)
		cos_a := math.cos(angle)
		sin_a := math.sin(angle)
		op := rotate_point(cx + outer_r * cos_a, cy + outer_r * sin_a, cx, cy, cos_r, sin_r)
		ip := rotate_point(cx + inner_r * cos_a, cy + inner_r * sin_a, cx, cy, cos_r, sin_r)
		append(&batch.vertices,
			Draw2D_Vertex{ position = op, normal = {0, 0, 1}, color = color },
			Draw2D_Vertex{ position = ip, normal = {0, 0, 1}, color = color },
		)
	}

	for i in 0..<u32(CIRCLE_SEGMENTS) {
		next := (i + 1) % u32(CIRCLE_SEGMENTS)
		o0 := base + i * 2
		i0 := base + i * 2 + 1
		o1 := base + next * 2
		i1 := base + next * 2 + 1
		append(&batch.indices, o0, o1, i0, i0, o1, i1)
	}
}

batch_add_capsule :: proc(batch: ^Batch_2D, cx, cy, radius, half_length: f32, color: [4]f32, rotation: f32 = 0) {
	half_segs := CIRCLE_SEGMENTS / 2
	vert_needed := 4 + (1 + half_segs + 1) * 2
	idx_needed := 6 + half_segs * 3 * 2

	if len(batch.vertices) + vert_needed > MAX_VERTEX_COUNT || len(batch.indices) + idx_needed > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))
	cos_r := math.cos(rotation)
	sin_r := math.sin(rotation)

	// Rectangle body (4 verts, 2 tris)
	append(&batch.vertices,
		Draw2D_Vertex{ position = rotate_point(cx - radius, cy - half_length, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx + radius, cy - half_length, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx + radius, cy + half_length, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx - radius, cy + half_length, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
	)
	append(&batch.indices, base, base + 1, base + 2, base + 2, base + 3, base)

	// Top semicircle (center at cx, cy - half_length)
	top_base := u32(len(batch.vertices))
	top_cy := cy - half_length
	append(&batch.vertices, Draw2D_Vertex{ position = rotate_point(cx, top_cy, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color })
	for i in 0..=half_segs {
		angle := math.PI + f32(i) * math.PI / f32(half_segs)
		px := cx + radius * math.cos(angle)
		py := top_cy + radius * math.sin(angle)
		append(&batch.vertices, Draw2D_Vertex{ position = rotate_point(px, py, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color })
	}
	for i in 0..<u32(half_segs) {
		append(&batch.indices, top_base, top_base + 1 + i, top_base + 2 + i)
	}

	// Bottom semicircle (center at cx, cy + half_length)
	bot_base := u32(len(batch.vertices))
	bot_cy := cy + half_length
	append(&batch.vertices, Draw2D_Vertex{ position = rotate_point(cx, bot_cy, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color })
	for i in 0..=half_segs {
		angle := f32(i) * math.PI / f32(half_segs)
		px := cx + radius * math.cos(angle)
		py := bot_cy + radius * math.sin(angle)
		append(&batch.vertices, Draw2D_Vertex{ position = rotate_point(px, py, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color })
	}
	for i in 0..<u32(half_segs) {
		append(&batch.indices, bot_base, bot_base + 1 + i, bot_base + 2 + i)
	}
}

batch_add_rhombus :: proc(batch: ^Batch_2D, cx, cy, half_w, half_h: f32, color: [4]f32, rotation: f32 = 0) {
	if len(batch.vertices) + 4 > MAX_VERTEX_COUNT || len(batch.indices) + 6 > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))
	cos_r := math.cos(rotation)
	sin_r := math.sin(rotation)

	append(&batch.vertices,
		Draw2D_Vertex{ position = rotate_point(cx, cy - half_h, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx + half_w, cy, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx, cy + half_h, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = rotate_point(cx - half_w, cy, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color },
	)

	append(&batch.indices, base, base + 1, base + 2, base + 2, base + 3, base)
}

batch_add_regular_polygon :: proc(batch: ^Batch_2D, cx, cy, radius: f32, sides: int, color: [4]f32, rotation: f32 = 0) {
	n := max(sides, 3)
	vert_needed := 1 + n
	idx_needed := n * 3

	if len(batch.vertices) + vert_needed > MAX_VERTEX_COUNT || len(batch.indices) + idx_needed > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))
	cos_r := math.cos(rotation)
	sin_r := math.sin(rotation)

	append(&batch.vertices, Draw2D_Vertex{ position = {cx, cy, 0}, normal = {0, 0, 1}, color = color })

	for i in 0..<n {
		angle := f32(i) * 2.0 * math.PI / f32(n) - math.PI * 0.5
		px := cx + radius * math.cos(angle)
		py := cy + radius * math.sin(angle)
		append(&batch.vertices, Draw2D_Vertex{ position = rotate_point(px, py, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color })
	}

	for i in 0..<u32(n) {
		next := (i + 1) % u32(n)
		append(&batch.indices, base, base + 1 + i, base + 1 + next)
	}
}

batch_add_sector :: proc(batch: ^Batch_2D, cx, cy, radius, start_angle, sweep_angle: f32, color: [4]f32, rotation: f32 = 0) {
	segs := max(3, int(f32(CIRCLE_SEGMENTS) * abs(sweep_angle) / (2.0 * math.PI)))
	vert_needed := 1 + segs + 1
	idx_needed := segs * 3

	if len(batch.vertices) + vert_needed > MAX_VERTEX_COUNT || len(batch.indices) + idx_needed > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))
	cos_r := math.cos(rotation)
	sin_r := math.sin(rotation)

	append(&batch.vertices, Draw2D_Vertex{ position = {cx, cy, 0}, normal = {0, 0, 1}, color = color })

	for i in 0..=segs {
		angle := start_angle + f32(i) * sweep_angle / f32(segs)
		px := cx + radius * math.cos(angle)
		py := cy + radius * math.sin(angle)
		append(&batch.vertices, Draw2D_Vertex{ position = rotate_point(px, py, cx, cy, cos_r, sin_r), normal = {0, 0, 1}, color = color })
	}

	for i in 0..<u32(segs) {
		append(&batch.indices, base, base + 1 + i, base + 2 + i)
	}
}

batch_add_arc :: proc(batch: ^Batch_2D, cx, cy, inner_r, outer_r, start_angle, sweep_angle: f32, color: [4]f32, rotation: f32 = 0) {
	segs := max(3, int(f32(CIRCLE_SEGMENTS) * abs(sweep_angle) / (2.0 * math.PI)))
	vert_needed := (segs + 1) * 2
	idx_needed := segs * 6

	if len(batch.vertices) + vert_needed > MAX_VERTEX_COUNT || len(batch.indices) + idx_needed > MAX_INDEX_COUNT {
		return
	}

	base := u32(len(batch.vertices))
	cos_r := math.cos(rotation)
	sin_r := math.sin(rotation)

	for i in 0..=segs {
		angle := start_angle + f32(i) * sweep_angle / f32(segs)
		cos_a := math.cos(angle)
		sin_a := math.sin(angle)
		op := rotate_point(cx + outer_r * cos_a, cy + outer_r * sin_a, cx, cy, cos_r, sin_r)
		ip := rotate_point(cx + inner_r * cos_a, cy + inner_r * sin_a, cx, cy, cos_r, sin_r)
		append(&batch.vertices,
			Draw2D_Vertex{ position = op, normal = {0, 0, 1}, color = color },
			Draw2D_Vertex{ position = ip, normal = {0, 0, 1}, color = color },
		)
	}

	for i in 0..<u32(segs) {
		o0 := base + i * 2
		i0 := base + i * 2 + 1
		o1 := base + (i + 1) * 2
		i1 := base + (i + 1) * 2 + 1
		append(&batch.indices, o0, o1, i0, i0, o1, i1)
	}
}

batch_add_line :: proc(batch: ^Batch_2D, x1, y1, x2, y2, thickness: f32, color: [4]f32) {
	if len(batch.vertices) + 4 > MAX_VERTEX_COUNT || len(batch.indices) + 6 > MAX_INDEX_COUNT {
		return
	}

	dx := x2 - x1
	dy := y2 - y1
	length := math.sqrt(dx * dx + dy * dy)
	if length < 0.0001 {
		return
	}

	// Perpendicular, scaled by half thickness
	ht := thickness * 0.5 / length
	px := -dy * ht
	py := dx * ht

	base := u32(len(batch.vertices))

	append(&batch.vertices,
		Draw2D_Vertex{ position = {x1 - px, y1 - py, 0}, normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = {x1 + px, y1 + py, 0}, normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = {x2 + px, y2 + py, 0}, normal = {0, 0, 1}, color = color },
		Draw2D_Vertex{ position = {x2 - px, y2 - py, 0}, normal = {0, 0, 1}, color = color },
	)

	append(&batch.indices, base, base + 1, base + 2, base + 2, base + 3, base)
}

batch_add_text :: proc(batch: ^Batch_2D, x, y: f32, text: string, color: [4]f32, scale: f32 = 1.0) {
	base_font_add_text(batch, x, y, text, color, scale)
}

text_width :: proc(text: string, scale: f32 = 1.0) -> f32 {
	return base_font_text_width(text, scale)
}

text_height :: proc(text: string, scale: f32 = 1.0) -> f32 {
	return base_font_text_height(text, scale)
}

// --- Projection ---

update_screen_projection :: proc(batch: ^Batch_2D, width, height: f32) {
	batch.screen_proj = ortho_2d(0, width, 0, height)
	batch.proj_view = batch.screen_proj
}

set_projection_2d :: proc(batch: ^Batch_2D, proj: glsl.mat4x4) {
	batch.proj_view = proj
}

reset_projection_2d :: proc(batch: ^Batch_2D) {
	batch.proj_view = batch.screen_proj
}

// Orthographic projection for screen-space 2D (Y-down, top-left origin).
// Vulkan NDC has Y+ down so bottom=0,top=height gives Y-down directly.
// D3D NDC has Y+ up so we swap bottom/top to achieve the same Y-down result.
ortho_2d :: proc(left, right, bottom, top: f32) -> glsl.mat4x4 {
	b, t: f32
	when bk.GPU_BACKEND == "vulkan" || bk.GPU_BACKEND == "opengl" {
		b = bottom
		t = top
	} else {
		// D3D: flip Y so Y=0 is top of screen (Y-down like Vulkan)
		b = top
		t = bottom
	}
	rl := right - left
	tb := t - b
	// Clip: X[-1,1], Y[-1,1], Z[0,1]
	// Odin matrix: m[row, col]
	m: glsl.mat4x4
	m[0, 0] = 2.0 / rl
	m[1, 1] = 2.0 / tb
	m[2, 2] = 0.5
	m[0, 3] = -(right + left) / rl
	m[1, 3] = -(t + b) / tb
	m[2, 3] = 0.5
	m[3, 3] = 1.0
	return m
}