Harbor

branch main
showing the latest snapshot on main
gpu.odin 44.9 KB · Plain text
gpu/gpu.odin 0644 Raw
package gpu

import "core:log"
import "core:math"
import "core:mem"
import "core:os"
import glsl "core:math/linalg/glsl"

import bk "backend"
import vk_backend "backend/vulkan"
import d3d11_backend "backend/d3d11"
import d3d12_backend "backend/d3d12"
import opengl_backend "backend/opengl"
import "compiler"
import "renderer"
import ir "render_ir"
import prof "profile"
import "resource"

// Internal engine state -- global singleton like Raylib
@(private)
g_state: ^Engine_State

@(private)
Engine_State :: struct {
	// Backend
	backend:        bk.Backend,

	// Resources
	resource_state: resource.Resource_State,

	// Rendering
	render_state:   renderer.Renderer_State,
	offscreen_targets: compiler.Render_Target_Cache,
	offscreen_2d_pipelines: [dynamic]Offscreen_2D_Pipeline,
	offscreen_sampled_views: [dynamic]Offscreen_Sampled_View,
	pipeline_2d_vertex_shader:   bk.Shader_Handle,
	pipeline_2d_fragment_shader: bk.Shader_Handle,

	// Memory
	arena:          mem.Arena,
	arena_buffer:   []byte,

	// Camera shake
	shake_2d:       Camera_Shake_State,
	shake_3d:       Camera_Shake_State,

	// State
	initialized:    bool,
	headless:       bool,
}

@(private)
Offscreen_2D_Pipeline :: struct {
	render_pass: bk.Render_Pass_Handle,
	pipeline:    bk.Pipeline_Handle,
}

@(private)
Offscreen_Sampled_View :: struct {
	texture:    bk.Texture_Handle,
	texture_id: u32,
	width:      u32,
	height:     u32,
}

@(private)
Camera_Shake_State :: struct {
	trauma:     f32, // 0..1 current intensity, decays over time
	decay:      f32, // trauma units per second
	max_offset: f32, // max pixel/unit offset
	max_angle:  f32, // max rotation degrees (2D only)
	seed:       f32, // time accumulator for noise
}

// --- Lifecycle ---

// Initialize GPU resources for an externally-owned presentation surface.
init_surface :: proc(surface: Surface_Desc, width, height: u32, title: cstring = "GPU") -> bool {
	if g_state != nil && g_state.initialized {
		log.error("gpu: already initialized")
		return false
	}

	state := new(Engine_State)
	if state == nil {
		log.error("gpu: failed to allocate engine state")
		return false
	}
	g_state = state
	if !init_gpu_and_renderer(state, surface, width, height, title) {
		return false
	}

	state.initialized = true
	return true
}

// Initialize only the selected backend. This is for proof/offscreen runtimes
// that drive compiler.Execution_State directly and do not need app resources.
init_headless :: proc(width, height: u32, title: cstring = "GPU headless") -> bool {
	if g_state != nil && g_state.initialized {
		log.error("gpu: already initialized")
		return false
	}

	state := new(Engine_State)
	if state == nil {
		log.error("gpu: failed to allocate engine state")
		return false
	}
	g_state = state
	if !init_backend(state, {}, width, height, title) {
		return false
	}

	state.initialized = true
	state.headless = true
	return true
}

// Shared backend initialization.
@(private)
init_backend :: proc(state: ^Engine_State, surface: Surface_Desc, width, height: u32, title: cstring) -> bool {
	when bk.GPU_BACKEND == "vulkan" {
		backend, backend_ok := vk_backend.init_vulkan_backend(surface, width, height, title)
		if !backend_ok {
			log.error("gpu: failed to initialize Vulkan backend")
			free(state)
			g_state = nil
			return false
		}
		state.backend = backend
	} else when bk.GPU_BACKEND == "d3d11" {
		backend, backend_ok := d3d11_backend.init_d3d11_backend(surface, width, height, title)
		if !backend_ok {
			log.error("gpu: failed to initialize D3D11 backend")
			free(state)
			g_state = nil
			return false
		}
		state.backend = backend
	} else when bk.GPU_BACKEND == "d3d12" {
		backend, backend_ok := d3d12_backend.init_d3d12_backend(surface, width, height, title)
		if !backend_ok {
			log.error("gpu: failed to initialize D3D12 backend")
			free(state)
			g_state = nil
			return false
		}
		state.backend = backend
	} else when bk.GPU_BACKEND == "opengl" {
		backend, backend_ok := opengl_backend.init_opengl_backend(surface, width, height, title)
		if !backend_ok {
			log.error("gpu: failed to initialize OpenGL backend")
			free(state)
			g_state = nil
			return false
		}
		state.backend = backend
	} else {
		#assert(false, "unsupported GPU_BACKEND")
	}
	if !bk.backend_vtable_is_complete(&state.backend) {
		log.error("gpu: backend vtable is incomplete")
		if state.backend.shutdown != nil {
			state.backend.shutdown()
		}
		free(state)
		g_state = nil
		return false
	}

	return true
}

// Shared GPU + renderer initialization.
@(private)
init_gpu_and_renderer :: proc(state: ^Engine_State, surface: Surface_Desc, width, height: u32, title: cstring) -> bool {
	if !init_backend(state, surface, width, height, title) {
		return false
	}

	// Init resources
	res, res_ok := resource.init_resources(&state.backend)
	if !res_ok {
		log.error("gpu: failed to initialize resources")
		shutdown()
		return false
	}
	state.resource_state = res

	// Init renderer
	rs, rs_ok := renderer.init_renderer(&state.backend, &state.resource_state)
	if !rs_ok {
		log.error("gpu: failed to initialize renderer")
		shutdown()
		return false
	}
	state.render_state = rs
	state.render_state.batch_2d.parent_state = &state.render_state
	state.offscreen_targets = compiler.init_render_target_cache(&state.backend)
	state.offscreen_2d_pipelines = make([dynamic]Offscreen_2D_Pipeline, 0, ir.DEFAULT_PASS_CAP)
	state.offscreen_sampled_views = make([dynamic]Offscreen_Sampled_View, 0, ir.DEFAULT_PASS_CAP)

	// Camera shake defaults
	state.shake_2d = Camera_Shake_State{decay = 1.0, max_offset = 10.0, max_angle = 5.0}
	state.shake_3d = Camera_Shake_State{decay = 1.0, max_offset = 0.3}

	return true
}

// Shut down GPU resources.
shutdown :: proc() {
	if g_state == nil {
		return
	}

	g_state.backend.wait_idle()

	if !g_state.headless {
		destroy_offscreen_2d_pipelines()
		destroy_offscreen_sampled_views()
		compiler.destroy_render_target_cache(&g_state.offscreen_targets)
		renderer.shutdown_renderer(&g_state.render_state, &g_state.backend)
		resource.shutdown_resources(&g_state.resource_state, &g_state.backend)
	}

	g_state.backend.shutdown()

	free(g_state)
	g_state = nil
}

// Get current surface width.
get_screen_width :: proc() -> i32 {
	if g_state == nil || !g_state.initialized {
		return 0
	}
	return i32(g_state.backend.get_extent().width)
}

// Get current surface height.
get_screen_height :: proc() -> i32 {
	if g_state == nil || !g_state.initialized {
		return 0
	}
	return i32(g_state.backend.get_extent().height)
}

resize_surface :: proc(width, height: u32) {
	if g_state == nil || !g_state.initialized {
		return
	}
	g_state.backend.on_resize(width, height)
	renderer.on_swapchain_recreated(&g_state.render_state)
}

// --- Drawing ---

// Begin frame: acquire swap image and begin command buffer.
begin_drawing :: proc(clear_color: Color = BLACK, dt: f32 = 0) {
	if g_state == nil || !g_state.initialized {
		return
	}

	// Decay camera shake
	if g_state.shake_2d.trauma > 0 {
		g_state.shake_2d.seed += dt * 60
		g_state.shake_2d.trauma = max(0, g_state.shake_2d.trauma - g_state.shake_2d.decay * dt)
	}
	if g_state.shake_3d.trauma > 0 {
		g_state.shake_3d.seed += dt * 60
		g_state.shake_3d.trauma = max(0, g_state.shake_3d.trauma - g_state.shake_3d.decay * dt)
	}

	renderer.begin_frame(&g_state.render_state, clear_color)
}

// End frame: end command buffer, submit, present.
end_drawing :: proc() -> bool {
	if g_state == nil || !g_state.initialized {
		return false
	}

	resized := renderer.end_frame(&g_state.render_state)
	return resized
}

write_profile :: proc(path: string, app_wall_seconds: f64 = -1, app_frame_count: u64 = 0) -> bool {
	if g_state == nil || len(path) == 0 {
		return false
	}
	if app_wall_seconds >= 0 {
		g_state.render_state.profile.app_wall_seconds = app_wall_seconds
		g_state.render_state.profile.app_frame_count = app_frame_count
		if g_state.headless && g_state.render_state.profile.frame_count == 0 {
			g_state.render_state.profile.frame_count = app_frame_count
		}
	}
	data := prof.to_json(g_state.render_state.profile, context.temp_allocator)
	err := os.write_entire_file(path, data)
	return err == nil
}

// --- 2D Drawing ---

begin_mode_2d :: proc(camera: Camera2D = {}) {
	if g_state == nil || !g_state.render_state.frame_active {
		return
	}

	// Flush current batch before changing projection
	renderer.flush_batch_2d(&g_state.render_state.batch_2d)
	renderer.reset_batch_2d(&g_state.render_state.batch_2d)

	extent := g_state.backend.get_extent()
	w := f32(extent.width)
	h := f32(extent.height)
	proj := renderer.ortho_2d(0, w, 0, h)

	cam := camera
	if cam.zoom == 0 {
		cam.zoom = 1
	}

	// Build view: translate(offset) * scale(zoom) * rotate(-rotation) * translate(-target)
	rot_rad := -cam.rotation * math.PI / 180.0
	cos_r := math.cos(rot_rad)
	sin_r := math.sin(rot_rad)

	// Odin matrix: m[row, col]
	view: glsl.mat4x4
	view[0, 0] = cam.zoom * cos_r
	view[0, 1] = cam.zoom * sin_r
	view[1, 0] = -cam.zoom * sin_r
	view[1, 1] = cam.zoom * cos_r
	view[2, 2] = 1
	view[3, 3] = 1

	// Apply translate(-target) by adjusting the translation column (col 3)
	tx := -cam.target.x * view[0, 0] + -cam.target.y * view[1, 0] + cam.offset.x
	ty := -cam.target.x * view[0, 1] + -cam.target.y * view[1, 1] + cam.offset.y
	view[0, 3] = tx
	view[1, 3] = ty

	// Apply camera shake
	if g_state.shake_2d.trauma > 0 {
		shake := g_state.shake_2d.trauma * g_state.shake_2d.trauma
		s := &g_state.shake_2d
		ox := shake * s.max_offset * shake_noise(s.seed)
		oy := shake * s.max_offset * shake_noise(s.seed + 100)
		ar := shake * s.max_angle * shake_noise(s.seed + 200) * math.PI / 180.0
		// Apply shake as additional rotation and offset
		sc := math.cos(ar)
		ss := math.sin(ar)
		view[0, 3] += ox
		view[1, 3] += oy
		// Compose small rotation
		r00 := view[0, 0]; r01 := view[0, 1]
		r10 := view[1, 0]; r11 := view[1, 1]
		view[0, 0] = r00 * sc + r10 * ss
		view[0, 1] = r01 * sc + r11 * ss
		view[1, 0] = r10 * sc - r00 * ss
		view[1, 1] = r11 * sc - r01 * ss
	}

	renderer.set_projection_2d(&g_state.render_state.batch_2d, proj * view)

	// Set up 2D lit rendering if enabled
	if g_state.render_state.lighting_enabled_2d {
		g_state.render_state.batch_2d.use_lit = true
		g_state.render_state.batch_2d.lit_pipeline = g_state.render_state.pipeline_2d_lit
		g_state.render_state.batch_2d.light_desc_set = g_state.render_state.light_state.ubo_descriptor_sets[g_state.render_state.frame_ctx.frame_index]
		// Update light UBO for 2D (camera_pos not relevant for 2D)
		renderer.update_light_ubo(
			&g_state.render_state.light_state,
			&g_state.backend,
			g_state.render_state.frame_ctx.frame_index,
			proj * view,
			{0, 0, 0},
		)
	} else {
		g_state.render_state.batch_2d.use_lit = false
	}
}

end_mode_2d :: proc() {
	if g_state == nil || !g_state.render_state.frame_active {
		return
	}

	// Flush current batch before restoring projection
	renderer.flush_batch_2d(&g_state.render_state.batch_2d)
	renderer.reset_batch_2d(&g_state.render_state.batch_2d)
	renderer.reset_projection_2d(&g_state.render_state.batch_2d)
}

measure_text :: proc(text: string, scale: f32 = 1.0) -> (width, height: f32) {
	return renderer.text_width(text, scale), renderer.text_height(text, scale)
}

// --- 3D Drawing ---

GRID_Y_OFFSET_3D :: f32(0.01)

// --- Resources ---

load_texture :: proc(path: cstring) -> Texture {
	if g_state == nil || !g_state.initialized {
		return {}
	}
	id, w, h, ok := resource.load_texture_from_file(
		&g_state.resource_state,
		&g_state.backend,
		string(path),
	)
	if !ok {
		return {}
	}
	return Texture{id = id, width = w, height = h}
}

load_texture_from_rgba :: proc(pixels: []u8, width, height: i32) -> Texture {
	if g_state == nil || !g_state.initialized {
		return {}
	}
	id, ok := resource.load_texture_from_pixels(
		&g_state.resource_state,
		&g_state.backend,
		raw_data(pixels),
		u32(width), u32(height),
	)
	if !ok {
		return {}
	}
	return Texture{id = id, width = width, height = height}
}

unload_texture :: proc(tex: Texture) {
	if g_state == nil {
		return
	}
	g_state.backend.wait_idle()
	resource.unload_texture(&g_state.resource_state, &g_state.backend, tex.id)
}

gen_mesh_cube :: proc(w, h, l: f32) -> Mesh {
	if g_state == nil || !g_state.initialized {
		return {}
	}
	verts, idxs := resource.gen_cube_geometry(w, h, l)
	defer delete(verts)
	defer delete(idxs)
	id, ic, ok := resource.upload_mesh_raw(
		&g_state.resource_state, &g_state.backend,
		raw_data(verts), len(verts) * size_of(resource.Mesh_Vertex_PNU),
		i32(len(verts)), idxs, bk.LAYOUT_POS_NORM_UV,
	)
	if !ok {
		return {}
	}
	return Mesh{id = id, vertex_count = i32(len(verts)), index_count = ic}
}

gen_mesh_sphere :: proc(radius: f32, rings, slices: i32) -> Mesh {
	if g_state == nil || !g_state.initialized {
		return {}
	}
	verts, idxs := resource.gen_sphere_geometry(radius, rings, slices)
	defer delete(verts)
	defer delete(idxs)
	id, ic, ok := resource.upload_mesh_raw(
		&g_state.resource_state, &g_state.backend,
		raw_data(verts), len(verts) * size_of(resource.Mesh_Vertex_PNU),
		i32(len(verts)), idxs, bk.LAYOUT_POS_NORM_UV,
	)
	if !ok {
		return {}
	}
	return Mesh{id = id, vertex_count = i32(len(verts)), index_count = ic}
}

gen_mesh_plane :: proc(w, l: f32, res_x, res_z: i32) -> Mesh {
	if g_state == nil || !g_state.initialized {
		return {}
	}
	verts, idxs := resource.gen_plane_geometry(w, l, res_x, res_z)
	defer delete(verts)
	defer delete(idxs)
	id, ic, ok := resource.upload_mesh_raw(
		&g_state.resource_state, &g_state.backend,
		raw_data(verts), len(verts) * size_of(resource.Mesh_Vertex_PNU),
		i32(len(verts)), idxs, bk.LAYOUT_POS_NORM_UV,
	)
	if !ok {
		return {}
	}
	return Mesh{id = id, vertex_count = i32(len(verts)), index_count = ic}
}

gen_mesh_custom :: proc(vertices: []Vertex, indices: []u32) -> Mesh {
	if g_state == nil || !g_state.initialized {
		return {}
	}
	id, ic, ok := resource.upload_mesh_raw(
		&g_state.resource_state, &g_state.backend,
		raw_data(vertices), len(vertices) * size_of(Vertex),
		i32(len(vertices)), indices,
		bk.LAYOUT_POS_NORM_UV_COLOR,
	)
	if !ok {
		return {}
	}
	return Mesh{id = id, vertex_count = i32(len(vertices)), index_count = ic}
}

gen_mesh_raw :: proc(vertex_data: rawptr, vertex_data_size: int, vertex_count: i32, indices: []u32, layout: Vertex_Layout) -> Mesh {
	if g_state == nil || !g_state.initialized {
		return {}
	}
	id, ic, ok := resource.upload_mesh_raw(
		&g_state.resource_state, &g_state.backend,
		vertex_data, vertex_data_size,
		vertex_count, indices, layout,
	)
	if !ok {
		return {}
	}
	return Mesh{id = id, vertex_count = vertex_count, index_count = ic}
}

get_mesh_layout :: proc(mesh_id: u32) -> (Vertex_Layout, bool) {
	if g_state == nil {
		return {}, false
	}
	return resource.get_mesh_layout(&g_state.resource_state, mesh_id)
}

unload_mesh :: proc(mesh: Mesh) {
	if g_state == nil {
		return
	}
	g_state.backend.wait_idle()
	resource.unload_mesh(&g_state.resource_state, &g_state.backend, mesh.id)
}

load_model :: proc(path: cstring) -> Model {
	if g_state == nil || !g_state.initialized {
		return {}
	}
	id, mc, ok := resource.load_model_from_gltf(
		&g_state.resource_state,
		&g_state.backend,
		string(path),
	)
	if !ok {
		return {}
	}

	internal := &g_state.resource_state.models[id]
	meshes := make([]Mesh, mc)
	materials := make([]Material, mc)
	for i in 0..<mc {
		mid := internal.mesh_ids[i]
		m := &g_state.resource_state.meshes[mid]
		meshes[i] = Mesh{id = mid, vertex_count = m.vertex_count, index_count = m.index_count}

		tid := internal.texture_ids[i]
		t := &g_state.resource_state.textures[tid]
		nid := internal.normal_map_ids[i]
		materials[i] = Material{
			diffuse      = Texture{id = tid, width = t.width, height = t.height},
			color        = internal.material_colors[i],
			double_sided = internal.material_double_sided[i],
			metallic     = internal.material_metallic[i],
			roughness    = internal.material_roughness[i],
			emissive     = internal.material_emissive[i],
		}
		if nid != 0 {
			nt := &g_state.resource_state.textures[nid]
			materials[i].normal_map = Texture{id = nid, width = nt.width, height = nt.height}
		}
	}
	return Model{id = id, meshes = meshes, materials = materials, transform = internal.transform}
}

unload_model :: proc(model: Model) {
	if g_state == nil {
		return
	}
	g_state.backend.wait_idle()
	resource.unload_model_resource(&g_state.resource_state, &g_state.backend, model.id)
	delete(model.meshes)
	delete(model.materials)
}

load_shader :: proc(vert_path, frag_path: cstring) -> Shader {
	// TODO: File-based shader loading
	return {}
}

unload_shader :: proc(shader: Shader) {
	// TODO: File-based shader unloading
}

// --- Pipeline API ---
create_shader_module :: proc(desc: Shader_Module_Desc) -> (Shader_Handle, bool) {
	if g_state == nil { return {}, false }
	return g_state.backend.create_shader_module(desc)
}

destroy_shader :: proc(handle: Shader_Handle) {
	if g_state == nil { return }
	g_state.backend.destroy_shader(handle)
}

// Create a graphics pipeline from a descriptor.
create_pipeline :: proc(desc: Pipeline_Desc) -> (Pipeline_Handle, bool) {
	if g_state == nil { return {}, false }
	if !bk.supports_push_constant_size(g_state.backend.capabilities, desc.push_constant_size) {
		log.errorf(
			"gpu: graphics pipeline push constant size %d exceeds backend implemented limit %d",
			desc.push_constant_size,
			g_state.backend.capabilities.max_push_constant_size,
		)
		return {}, false
	}
	return g_state.backend.create_graphics_pipeline(desc)
}

destroy_pipeline :: proc(handle: Pipeline_Handle) {
	if g_state == nil { return }
	g_state.backend.destroy_graphics_pipeline(handle)
}

// Query the default render pass for pipeline creation.
get_default_render_pass :: proc() -> Render_Pass_Handle {
	if g_state == nil { return {} }
	return g_state.backend.get_default_render_pass()
}

get_backend :: proc() -> ^Backend {
	if g_state == nil || !g_state.initialized { return nil }
	return &g_state.backend
}

get_capabilities :: proc() -> Capabilities {
	if g_state == nil || !g_state.initialized {
		return bk.no_capabilities()
	}
	return g_state.backend.capabilities
}

supports :: proc(capability: Capability) -> bool {
	return bk.supports(get_capabilities(), capability)
}

get_frame_context :: proc() -> Frame_Context {
	if g_state == nil || !g_state.render_state.frame_active { return {} }
	return g_state.render_state.frame_ctx
}

ensure_default_render_pass :: proc() -> bool {
	if g_state == nil || !g_state.render_state.frame_active { return false }
	renderer.ensure_render_pass(&g_state.render_state)
	return g_state.render_state.render_pass_begun
}

// Query the texture sampler descriptor set layout (set 0).
get_texture_descriptor_layout :: proc() -> Descriptor_Handle {
	if g_state == nil { return {} }
	return resource.get_descriptor_set_layout(&g_state.resource_state)
}

// Query the light UBO descriptor set layout (set 1).
get_light_descriptor_layout :: proc() -> Descriptor_Handle {
	if g_state == nil { return {} }
	return g_state.render_state.light_state.ubo_set_layout
}

// --- Pipeline Registration ---
// Register externally-created pipelines for use by GPU's draw routines.

set_pipeline_2d :: proc(pipeline: Pipeline_Handle) {
	if g_state == nil { return }
	g_state.render_state.batch_2d.gfx_pipeline = pipeline
}

set_pipeline_2d_shaders :: proc(vertex_shader, fragment_shader: Shader_Handle) {
	if g_state == nil { return }
	g_state.pipeline_2d_vertex_shader = vertex_shader
	g_state.pipeline_2d_fragment_shader = fragment_shader
}

destroy_offscreen_2d_pipelines :: proc() {
	if g_state == nil {
		return
	}
	for entry in g_state.offscreen_2d_pipelines {
		if entry.pipeline != bk.NULL_PIPELINE {
			g_state.backend.destroy_graphics_pipeline(entry.pipeline)
		}
	}
	clear(&g_state.offscreen_2d_pipelines)
}

destroy_offscreen_sampled_views :: proc() {
	if g_state == nil {
		return
	}
	for entry in g_state.offscreen_sampled_views {
		if entry.texture_id != 0 {
			resource.unload_texture(&g_state.resource_state, &g_state.backend, entry.texture_id)
		}
	}
	clear(&g_state.offscreen_sampled_views)
}

@(private)
get_or_import_offscreen_sampled_texture :: proc(runtime: ^Engine_State, texture: bk.Texture_Handle, width, height: u32) -> (u32, bool) {
	if runtime == nil || texture == bk.NULL_TEXTURE || width == 0 || height == 0 {
		return 0, false
	}
	for entry in runtime.offscreen_sampled_views {
		if entry.texture == texture && entry.width == width && entry.height == height {
			return entry.texture_id, true
		}
	}
	texture_id, ok := resource.import_texture_view(&runtime.resource_state, &runtime.backend, texture, width, height)
	if !ok {
		return 0, false
	}
	append(&runtime.offscreen_sampled_views, Offscreen_Sampled_View{
		texture = texture,
		texture_id = texture_id,
		width = width,
		height = height,
	})
	return texture_id, true
}

@(private)
get_or_create_offscreen_2d_pipeline :: proc(render_pass: bk.Render_Pass_Handle) -> (bk.Pipeline_Handle, bool) {
	if g_state == nil || render_pass == bk.NULL_RENDER_PASS {
		return bk.NULL_PIPELINE, false
	}
	for entry in g_state.offscreen_2d_pipelines {
		if entry.render_pass == render_pass {
			return entry.pipeline, entry.pipeline != bk.NULL_PIPELINE
		}
	}
	if g_state.pipeline_2d_vertex_shader == bk.NULL_SHADER || g_state.pipeline_2d_fragment_shader == bk.NULL_SHADER {
		return bk.NULL_PIPELINE, false
	}

	layout := LAYOUT_POS_NORM_UV_COLOR
	binding, attrs, attr_count := vertex_layout_to_pipeline_attrs(&layout)
	vertex_bindings := [1]Vertex_Binding{binding}
	descriptor_layouts := [1]Descriptor_Handle{resource.get_descriptor_set_layout(&g_state.resource_state)}
	pipeline, ok := g_state.backend.create_graphics_pipeline({
		vert_shader = g_state.pipeline_2d_vertex_shader,
		frag_shader = g_state.pipeline_2d_fragment_shader,
		render_pass = render_pass,
		topology = .Triangle_List,
		cull_mode = .None,
		front_face = .Counter_Clockwise,
		enable_blending = true,
		blend_mode = .Alpha,
		enable_depth_test = false,
		push_constant_size = 64,
		push_constant_stages = {.Vertex},
		descriptor_layouts = descriptor_layouts[:],
		vertex_bindings = vertex_bindings[:],
		vertex_attributes = attrs[:attr_count],
	})
	if !ok {
		return bk.NULL_PIPELINE, false
	}
	append(&g_state.offscreen_2d_pipelines, Offscreen_2D_Pipeline{render_pass = render_pass, pipeline = pipeline})
	return pipeline, true
}

set_pipeline_2d_lit :: proc(pipeline: Pipeline_Handle) {
	if g_state == nil { return }
	g_state.render_state.pipeline_2d_lit = pipeline
}

set_pipeline_3d :: proc(variant: Layout_Variant, culled, double_sided: Pipeline_Handle) {
	if g_state == nil { return }
	g_state.render_state.pipeline_3d[variant] = culled
	g_state.render_state.pipeline_3d_double_sided[variant] = double_sided
}

set_pipeline_3d_lit :: proc(variant: Layout_Variant, culled, double_sided: Pipeline_Handle) {
	if g_state == nil { return }
	g_state.render_state.pipeline_3d_lit[variant] = culled
	g_state.render_state.pipeline_3d_lit_double_sided[variant] = double_sided
}

set_pipeline_3d_lit_shadowed :: proc(variant: Layout_Variant, culled, double_sided: Pipeline_Handle) {
	if g_state == nil { return }
	g_state.render_state.pipeline_3d_lit_shadowed[variant] = culled
	g_state.render_state.pipeline_3d_lit_shadowed_double_sided[variant] = double_sided
}

// --- Compute ---

load_compute_shader :: proc(path: cstring, num_buffers: u32 = 4, push_constant_size: u32 = 0) -> Compute_Shader {
	if g_state == nil {
		return {}
	}
	id, ok := resource.load_compute_shader_resource(
		&g_state.resource_state,
		&g_state.backend,
		path,
		num_buffers,
		push_constant_size,
	)
	if !ok {
		return {}
	}
	return Compute_Shader{id = id}
}

load_compute_shader_from_bytes :: proc(name: string, data: []u8, format: Shader_Format = REQUIRED_SHADER_FORMAT, num_buffers: u32 = 4, push_constant_size: u32 = 0) -> Compute_Shader {
	if g_state == nil {
		return {}
	}
	id, ok := resource.create_compute_shader_resource(
		&g_state.resource_state,
		&g_state.backend,
		name,
		data,
		format,
		num_buffers,
		push_constant_size,
	)
	if !ok {
		return {}
	}
	return Compute_Shader{id = id}
}

unload_compute_shader :: proc(shader: Compute_Shader) {
	if g_state == nil {
		return
	}
	g_state.backend.wait_idle()
	resource.unload_compute_shader_resource(&g_state.resource_state, &g_state.backend, shader.id)
}

create_storage_buffer :: proc(size: int, data: rawptr = nil) -> Storage_Buffer {
	if g_state == nil || size <= 0 {
		return {}
	}
	id, ok := resource.create_storage_buffer_resource(
		&g_state.resource_state,
		&g_state.backend,
		size,
		data,
	)
	if !ok {
		return {}
	}
	return Storage_Buffer{id = id, size = size}
}

update_storage_buffer :: proc(buf: Storage_Buffer, data: rawptr, size: int, offset: int = 0) {
	if g_state == nil || data == nil || size <= 0 {
		return
	}
	sb, ok := resource.get_storage_buffer(&g_state.resource_state, buf.id)
	if !ok {
		return
	}
	mapped := g_state.backend.get_buffer_mapped(sb.buffer)
	if mapped == nil {
		return
	}
	if offset < 0 || offset + size > sb.size {
		log.error("gpu: storage buffer write out of bounds")
		return
	}
	dst := rawptr(uintptr(mapped) + uintptr(offset))
	mem.copy(dst, data, size)
}

read_storage_buffer :: proc(buf: Storage_Buffer, dest: rawptr, size: int, offset: int = 0) {
	if g_state == nil || dest == nil || size <= 0 {
		return
	}
	sb, ok := resource.get_storage_buffer(&g_state.resource_state, buf.id)
	if !ok {
		return
	}
	mapped := g_state.backend.get_buffer_mapped(sb.buffer)
	if mapped == nil {
		return
	}
	if offset < 0 || offset + size > sb.size {
		log.error("gpu: storage buffer read out of bounds")
		return
	}
	src := rawptr(uintptr(mapped) + uintptr(offset))
	mem.copy(dest, src, size)
}

unload_storage_buffer :: proc(buf: Storage_Buffer) {
	if g_state == nil {
		return
	}
	g_state.backend.wait_idle()
	resource.unload_storage_buffer_resource(&g_state.resource_state, &g_state.backend, buf.id)
}

// --- Camera System ---

// Easing function: maps t in [0,1] to eased value in [0,1]
ease :: proc(t: f32, type: Ease_Type = .LINEAR) -> f32 {
	s := clamp(t, 0, 1)
	switch type {
	case .LINEAR:      return s
	case .SINE_IN:     return 1 - math.cos(s * math.PI * 0.5)
	case .SINE_OUT:    return math.sin(s * math.PI * 0.5)
	case .SINE_IN_OUT: return -(math.cos(s * math.PI) - 1) * 0.5
	case .QUAD_IN:     return s * s
	case .QUAD_OUT:    return 1 - (1 - s) * (1 - s)
	case .QUAD_IN_OUT:
		if s < 0.5 { return 2 * s * s }
		return 1 - (-2 * s + 2) * (-2 * s + 2) * 0.5
	case .CUBIC_IN:    return s * s * s
	case .CUBIC_OUT:
		inv := 1 - s
		return 1 - inv * inv * inv
	case .CUBIC_IN_OUT:
		if s < 0.5 { return 4 * s * s * s }
		inv := -2 * s + 2
		return 1 - inv * inv * inv * 0.5
	case .EXPO_IN:
		return s == 0 ? 0 : math.pow(2, 10 * (s - 1))
	case .EXPO_OUT:
		return s == 1 ? 1 : 1 - math.pow(2, -10 * s)
	case .EXPO_IN_OUT:
		if s == 0 { return 0 }
		if s == 1 { return 1 }
		if s < 0.5 { return math.pow(2, 20 * s - 10) * 0.5 }
		return (2 - math.pow(2, -20 * s + 10)) * 0.5
	}
	return s
}

// Interpolate between two 2D cameras
camera_lerp_2d :: proc(from, to: Camera2D, t: f32) -> Camera2D {
	s := clamp(t, 0, 1)
	inv := 1 - s
	return Camera2D{
		offset   = from.offset * inv + to.offset * s,
		target   = from.target * inv + to.target * s,
		rotation = from.rotation * inv + to.rotation * s,
		zoom     = from.zoom * inv + to.zoom * s,
	}
}

// Interpolate between two 3D cameras
camera_lerp_3d :: proc(from, to: Camera3D, t: f32) -> Camera3D {
	s := clamp(t, 0, 1)
	inv := 1 - s
	up := from.up * inv + to.up * s
	up_len := math.sqrt(up.x * up.x + up.y * up.y + up.z * up.z)
	if up_len < 1e-8 {
		up = {0, 1, 0}
	} else {
		up = up / up_len
	}
	return Camera3D{
		position = from.position * inv + to.position * s,
		target   = from.target * inv + to.target * s,
		up       = up,
		fovy     = from.fovy * inv + to.fovy * s,
		near     = from.near * inv + to.near * s,
		far      = from.far * inv + to.far * s,
	}
}

// Smoothly follow a 2D target (exponential decay, frame-rate independent)
camera_smooth_follow_2d :: proc(camera: ^Camera2D, target: Vec2, speed, dt: f32) {
	if camera == nil { return }
	if dt <= 0 { return }
	if speed <= 0 {
		camera.target = target
		return
	}
	factor := 1 - math.exp(-speed * dt)
	camera.target += (target - camera.target) * factor
}

// Smoothly follow a 3D target (maintains position-target offset)
camera_smooth_follow_3d :: proc(camera: ^Camera3D, target_pos: Vec3, speed, dt: f32) {
	if camera == nil { return }
	if dt <= 0 { return }
	if speed <= 0 {
		offset := camera.position - camera.target
		camera.target = target_pos
		camera.position = target_pos + offset
		return
	}
	offset := camera.position - camera.target
	factor := 1 - math.exp(-speed * dt)
	camera.target += (target_pos - camera.target) * factor
	camera.position = camera.target + offset
}

// Convert screen position to world position (2D)
get_screen_to_world_2d :: proc(screen_pos: Vec2, camera: Camera2D) -> Vec2 {
	zoom := camera.zoom if camera.zoom != 0 else f32(1)
	rot_rad := camera.rotation * math.PI / 180.0
	cos_r := math.cos(rot_rad)
	sin_r := math.sin(rot_rad)
	// Undo offset, undo zoom
	p := (screen_pos - camera.offset) / zoom
	// Undo rotation (rotate by +rotation to invert -rotation)
	return Vec2{
		p.x * cos_r - p.y * sin_r + camera.target.x,
		p.x * sin_r + p.y * cos_r + camera.target.y,
	}
}

// Convert world position to screen position (2D)
get_world_to_screen_2d :: proc(world_pos: Vec2, camera: Camera2D) -> Vec2 {
	zoom := camera.zoom if camera.zoom != 0 else f32(1)
	rot_rad := -camera.rotation * math.PI / 180.0
	cos_r := math.cos(rot_rad)
	sin_r := math.sin(rot_rad)
	// Translate by -target
	p := world_pos - camera.target
	// Rotate by -rotation
	rotated := Vec2{
		p.x * cos_r - p.y * sin_r,
		p.x * sin_r + p.y * cos_r,
	}
	// Scale by zoom, add offset
	return rotated * zoom + camera.offset
}

// Project a 3D world position to 2D screen coordinates
get_world_to_screen_3d :: proc(world_pos: Vec3, camera: Camera3D) -> Vec2 {
	view := camera_view_matrix(camera)
	proj := camera_proj_matrix(camera)
	pv := proj * view
	// Multiply proj_view * vec4(world_pos, 1)
	clip := mat4_mul_vec4(pv, {world_pos.x, world_pos.y, world_pos.z, 1})
	if clip.w <= 0 { return {-1, -1} }
	// Perspective divide
	ndc_x := clip.x / clip.w
	ndc_y := clip.y / clip.w
	// NDC to screen (our perspective already flips Y for Vulkan)
	w := f32(get_screen_width())
	h := f32(get_screen_height())
	return Vec2{
		(ndc_x + 1) * 0.5 * w,
		(ndc_y + 1) * 0.5 * h,
	}
}

// Unproject a screen position into a world-space ray
get_screen_to_world_ray_3d :: proc(screen_pos: Vec2, camera: Camera3D) -> (origin: Vec3, direction: Vec3) {
	w := f32(get_screen_width())
	h := f32(get_screen_height())
	if w <= 0 || h <= 0 { return camera.position, {0, 0, -1} }

	// Screen to NDC
	ndc_x := screen_pos.x / w * 2 - 1
	ndc_y := screen_pos.y / h * 2 - 1

	view := camera_view_matrix(camera)
	proj := camera_proj_matrix(camera)
	inv := mat4_inverse(proj * view)

	// Unproject near and far points
	near_clip := mat4_mul_vec4(inv, {ndc_x, ndc_y, 0, 1})
	far_clip  := mat4_mul_vec4(inv, {ndc_x, ndc_y, 1, 1})

	if math.abs(near_clip.w) < 1e-8 || math.abs(far_clip.w) < 1e-8 {
		return camera.position, {0, 0, -1}
	}

	near_world := Vec3{near_clip.x, near_clip.y, near_clip.z} / near_clip.w
	far_world  := Vec3{far_clip.x, far_clip.y, far_clip.z} / far_clip.w

	dir := far_world - near_world
	dir_len := math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z)
	if dir_len < 1e-8 { return camera.position, {0, 0, -1} }

	return near_world, dir / dir_len
}

// Get the view matrix for a 3D camera
camera_view_matrix :: proc(camera: Camera3D) -> Mat4 {
	f := vec3_normalize(camera.target - camera.position)
	r := vec3_normalize(vec3_cross(f, camera.up))
	u := vec3_cross(r, f)
	m: Mat4
	m[0, 0] = r.x; m[0, 1] = r.y; m[0, 2] = r.z
	m[1, 0] = u.x; m[1, 1] = u.y; m[1, 2] = u.z
	m[2, 0] = -f.x; m[2, 1] = -f.y; m[2, 2] = -f.z
	m[0, 3] = -(r.x * camera.position.x + r.y * camera.position.y + r.z * camera.position.z)
	m[1, 3] = -(u.x * camera.position.x + u.y * camera.position.y + u.z * camera.position.z)
	m[2, 3] = f.x * camera.position.x + f.y * camera.position.y + f.z * camera.position.z
	m[3, 3] = 1
	return m
}

// Get the projection matrix for a 3D camera
camera_proj_matrix :: proc(camera: Camera3D) -> Mat4 {
	aspect: f32 = 16.0 / 9.0
	if g_state != nil {
		extent := g_state.backend.get_extent()
		aspect = f32(extent.width) / f32(extent.height)
	}
	near := camera.near if camera.near > 0 else f32(0.1)
	far := camera.far if camera.far > 0 else f32(100)
	fovy := camera.fovy if camera.fovy > 0 else f32(60)
	fovy_rad := fovy * math.PI / 180.0
	f := 1.0 / math.tan(fovy_rad * 0.5)
	m: Mat4
	m[0, 0] = f / aspect
	m[1, 1] = -f // Y-flip for Vulkan NDC
	m[2, 2] = far / (near - far)
	m[2, 3] = (near * far) / (near - far)
	m[3, 2] = -1
	return m
}

// Add trauma to both 2D and 3D camera shake
camera_shake :: proc(intensity: f32 = 0.5) {
	if g_state == nil { return }
	g_state.shake_2d.trauma = clamp(g_state.shake_2d.trauma + intensity, 0, 1)
	g_state.shake_3d.trauma = clamp(g_state.shake_3d.trauma + intensity, 0, 1)
}

// Add trauma to 2D camera shake only
camera_shake_2d :: proc(intensity: f32 = 0.5) {
	if g_state == nil { return }
	g_state.shake_2d.trauma = clamp(g_state.shake_2d.trauma + intensity, 0, 1)
}

// Add trauma to 3D camera shake only
camera_shake_3d :: proc(intensity: f32 = 0.5) {
	if g_state == nil { return }
	g_state.shake_3d.trauma = clamp(g_state.shake_3d.trauma + intensity, 0, 1)
}

// Configure camera shake parameters
set_camera_shake_params :: proc(decay: f32 = 1.0, max_offset: f32 = -1, max_angle: f32 = -1) {
	if g_state == nil { return }
	if decay > 0 {
		g_state.shake_2d.decay = decay
		g_state.shake_3d.decay = decay
	}
	if max_offset >= 0 {
		g_state.shake_2d.max_offset = max_offset
		g_state.shake_3d.max_offset = max_offset
	}
	if max_angle >= 0 {
		g_state.shake_2d.max_angle = max_angle
	}
}

// Create a 2D camera path from keyframes
make_camera_path_2d :: proc(keyframes: []Camera_Keyframe_2D) -> Camera_Path_2D {
	dur: f32 = 0
	if len(keyframes) > 0 {
		dur = keyframes[len(keyframes) - 1].time
	}
	return Camera_Path_2D{keyframes = keyframes, duration = dur}
}

// Create a 3D camera path from keyframes
make_camera_path_3d :: proc(keyframes: []Camera_Keyframe_3D) -> Camera_Path_3D {
	dur: f32 = 0
	if len(keyframes) > 0 {
		dur = keyframes[len(keyframes) - 1].time
	}
	return Camera_Path_3D{keyframes = keyframes, duration = dur}
}

// Evaluate a 2D camera path at a given time
camera_path_eval_2d :: proc(path: Camera_Path_2D, time: f32) -> Camera2D {
	if len(path.keyframes) == 0 { return default_camera_2d() }
	if len(path.keyframes) == 1 { return path.keyframes[0].camera }

	t := clamp(time, 0, path.duration)
	if t <= path.keyframes[0].time { return path.keyframes[0].camera }
	if t >= path.keyframes[len(path.keyframes) - 1].time {
		return path.keyframes[len(path.keyframes) - 1].camera
	}

	// Find surrounding keyframes
	for i := 1; i < len(path.keyframes); i += 1 {
		if t <= path.keyframes[i].time {
			prev := path.keyframes[i - 1]
			next := path.keyframes[i]
			dt := next.time - prev.time
			if dt < 1e-8 { return next.camera }
			local_t := (t - prev.time) / dt
			return camera_lerp_2d(prev.camera, next.camera, ease(local_t, next.easing))
		}
	}
	return path.keyframes[len(path.keyframes) - 1].camera
}

// Evaluate a 3D camera path at a given time
camera_path_eval_3d :: proc(path: Camera_Path_3D, time: f32) -> Camera3D {
	if len(path.keyframes) == 0 { return default_camera_3d() }
	if len(path.keyframes) == 1 { return path.keyframes[0].camera }

	t := clamp(time, 0, path.duration)
	if t <= path.keyframes[0].time { return path.keyframes[0].camera }
	if t >= path.keyframes[len(path.keyframes) - 1].time {
		return path.keyframes[len(path.keyframes) - 1].camera
	}

	for i := 1; i < len(path.keyframes); i += 1 {
		if t <= path.keyframes[i].time {
			prev := path.keyframes[i - 1]
			next := path.keyframes[i]
			dt := next.time - prev.time
			if dt < 1e-8 { return next.camera }
			local_t := (t - prev.time) / dt
			return camera_lerp_3d(prev.camera, next.camera, ease(local_t, next.easing))
		}
	}
	return path.keyframes[len(path.keyframes) - 1].camera
}

// --- Math Helpers ---

matrix_identity :: proc() -> Mat4 {
	m: Mat4
	m[0, 0] = 1
	m[1, 1] = 1
	m[2, 2] = 1
	m[3, 3] = 1
	return m
}

mat4_translate :: proc(v: Vec3) -> Mat4 {
	// m[row, col] convention
	m: Mat4
	m[0, 0] = 1
	m[1, 1] = 1
	m[2, 2] = 1
	m[3, 3] = 1
	m[0, 3] = v.x
	m[1, 3] = v.y
	m[2, 3] = v.z
	return m
}

mat4_scale :: proc(v: Vec3) -> Mat4 {
	m: Mat4
	m[0, 0] = v.x
	m[1, 1] = v.y
	m[2, 2] = v.z
	m[3, 3] = 1
	return m
}

mat4_rotate_y :: proc(angle_rad: f32) -> Mat4 {
	c := math.cos(angle_rad)
	s := math.sin(angle_rad)
	m: Mat4
	m[0, 0] = c
	m[0, 2] = s
	m[1, 1] = 1
	m[2, 0] = -s
	m[2, 2] = c
	m[3, 3] = 1
	return m
}

mat4_rotate_x :: proc(angle_rad: f32) -> Mat4 {
	c := math.cos(angle_rad)
	s := math.sin(angle_rad)
	m: Mat4
	m[0, 0] = 1
	m[1, 1] = c
	m[1, 2] = -s
	m[2, 1] = s
	m[2, 2] = c
	m[3, 3] = 1
	return m
}

mat4_rotate_z :: proc(angle_rad: f32) -> Mat4 {
	c := math.cos(angle_rad)
	s := math.sin(angle_rad)
	m: Mat4
	m[0, 0] = c
	m[0, 1] = -s
	m[1, 0] = s
	m[1, 1] = c
	m[2, 2] = 1
	m[3, 3] = 1
	return m
}

@(private)
vec3_normalize :: proc(v: Vec3) -> Vec3 {
	l := math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
	if l < 1e-8 {
		return {}
	}
	return v / l
}

@(private)
vec3_cross :: proc(a, b: Vec3) -> Vec3 {
	return {
		a.y * b.z - a.z * b.y,
		a.z * b.x - a.x * b.z,
		a.x * b.y - a.y * b.x,
	}
}

@(private)
vec3_length :: proc(v: Vec3) -> f32 {
	return math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
}

@(private)
shake_noise :: proc(t: f32) -> f32 {
	// Pseudo-random [-1, 1] from time seed
	return math.sin(t * 123.456) * math.cos(t * 78.233)
}

@(private)
mat4_mul_vec4 :: proc(m: Mat4, v: Vec4) -> Vec4 {
	return Vec4{
		m[0, 0] * v.x + m[0, 1] * v.y + m[0, 2] * v.z + m[0, 3] * v.w,
		m[1, 0] * v.x + m[1, 1] * v.y + m[1, 2] * v.z + m[1, 3] * v.w,
		m[2, 0] * v.x + m[2, 1] * v.y + m[2, 2] * v.z + m[2, 3] * v.w,
		m[3, 0] * v.x + m[3, 1] * v.y + m[3, 2] * v.z + m[3, 3] * v.w,
	}
}

// --- Lighting ---

// Enable/disable 3D PBR lighting. When disabled, uses the original unlit shader.
set_lighting_enabled :: proc(enabled: bool) {
	if g_state == nil { return }
	g_state.render_state.lighting_enabled_3d = enabled
}

// Enable/disable 2D screen-space lighting. When disabled, uses the original unlit shader.
set_lighting_2d_enabled :: proc(enabled: bool) {
	if g_state == nil { return }
	g_state.render_state.lighting_enabled_2d = enabled
}

// Set the ambient light color. Alpha component controls intensity.
set_ambient_light :: proc(color: Color = {0.1, 0.1, 0.1, 1.0}) {
	if g_state == nil { return }
	g_state.render_state.light_state.ambient_color = color
}

// Add a light source. Returns light index (0-15), or -1 if full.
add_light :: proc(light: Light) -> i32 {
	if g_state == nil { return -1 }
	return renderer.add_light(&g_state.render_state.light_state, renderer.Light_Data{
		type         = u32(light.type),
		enabled      = light.enabled,
		position     = {light.position.x, light.position.y, light.position.z},
		color        = light.color,
		intensity    = light.intensity,
		radius       = light.radius,
		casts_shadow = light.casts_shadow,
	})
}

// Update an existing light by index.
set_light :: proc(index: i32, light: Light) {
	if g_state == nil { return }
	renderer.set_light(&g_state.render_state.light_state, index, renderer.Light_Data{
		type         = u32(light.type),
		enabled      = light.enabled,
		position     = {light.position.x, light.position.y, light.position.z},
		color        = light.color,
		intensity    = light.intensity,
		radius       = light.radius,
		casts_shadow = light.casts_shadow,
	})
}

// Remove a light by index (shifts remaining lights down).
remove_light :: proc(index: i32) {
	if g_state == nil { return }
	renderer.remove_light(&g_state.render_state.light_state, index)
}

// Remove all lights.
clear_lights :: proc() {
	if g_state == nil { return }
	renderer.clear_lights(&g_state.render_state.light_state)
}

// Get the number of active lights.
get_light_count :: proc() -> i32 {
	if g_state == nil { return 0 }
	return i32(g_state.render_state.light_state.light_count)
}

// Get a light by index.
get_light :: proc(index: i32) -> Light {
	if g_state == nil || index < 0 || u32(index) >= g_state.render_state.light_state.light_count {
		return {}
	}
	ld := &g_state.render_state.light_state.lights[index]
	return Light{
		type         = Light_Type(ld.type),
		enabled      = ld.enabled,
		position     = {ld.position.x, ld.position.y, ld.position.z},
		color        = ld.color,
		intensity    = ld.intensity,
		radius       = ld.radius,
		casts_shadow = ld.casts_shadow,
	}
}

// Create a directional light. Direction is the way the light shines (e.g. {0,-1,0} for sun from above).
create_directional_light :: proc(direction: Vec3, color: Color = WHITE, intensity: f32 = 1.0) -> Light {
	return Light{
		type      = .DIRECTIONAL,
		enabled   = true,
		position  = direction,
		color     = color,
		intensity = intensity,
	}
}

// Create a point light at a world position.
create_point_light :: proc(position: Vec3, color: Color = WHITE, intensity: f32 = 1.0, radius: f32 = 10.0) -> Light {
	return Light{
		type      = .POINT,
		enabled   = true,
		position  = position,
		color     = color,
		intensity = intensity,
		radius    = radius,
	}
}

// --- Shadows ---

// Initialize shadow atlas and depth prepass resources. Call after init.
// Returns true on success. Shadow pipelines can be created after this.
init_shadows :: proc() -> bool {
	if g_state == nil { return false }
	extent := g_state.backend.get_extent()
	ok := renderer.init_shadow_state(
		&g_state.render_state.shadow_state,
		&g_state.backend,
		extent.width, extent.height,
	)
	return ok
}

// Query the shadow depth-only render pass for pipeline creation.
get_shadow_render_pass :: proc() -> Render_Pass_Handle {
	if g_state == nil { return {} }
	return g_state.render_state.shadow_state.render_pass
}

// Query the shadow descriptor set layout (set 2) for pipeline creation.
get_shadow_descriptor_layout :: proc() -> Descriptor_Handle {
	if g_state == nil { return {} }
	return g_state.render_state.shadow_state.descriptor_set_layout
}

// Register an externally-created depth-only pipeline for shadow rendering.
set_shadow_depth_pipeline :: proc(variant: Layout_Variant, pipeline: Pipeline_Handle) {
	if g_state == nil { return }
	g_state.render_state.shadow_state.depth_pipelines[variant] = pipeline
}

// Enable/disable shadow mapping for directional lights.
set_shadows_enabled :: proc(enabled: bool) {
	if g_state == nil { return }
	g_state.render_state.shadows_enabled = enabled
	g_state.render_state.shadow_state.enabled = enabled
}

// Set shadow atlas size (default 4096). Call before first frame for best results.
set_shadow_resolution :: proc(resolution: u32) {
	if g_state == nil { return }
	g_state.render_state.shadow_state.atlas_size = resolution
}

// Set shadow depth bias to prevent shadow acne.
set_shadow_bias :: proc(constant_bias: f32 = 1.25, slope_bias: f32 = 1.75) {
	if g_state == nil { return }
	g_state.render_state.shadow_state.bias_constant = constant_bias
	g_state.render_state.shadow_state.bias_slope = slope_bias
}

// Set the shadow projection area (half-extent and depth around camera target).
set_shadow_area :: proc(half_extent: f32 = 15.0, depth: f32 = 40.0) {
	if g_state == nil { return }
	g_state.render_state.shadow_state.shadow_half_extent = half_extent
	g_state.render_state.shadow_state.shadow_depth = depth
}

// --- Deferred Lighting (Future) ---

// Enable deferred lighting mode (not yet implemented).
set_deferred_lighting :: proc(enabled: bool) {
	log.warn("gpu: deferred lighting is not yet implemented")
}

// Check if deferred lighting is available.
is_deferred_lighting_supported :: proc() -> bool {
	return false
}

// Set the G-buffer resolution scale (not yet implemented).
set_deferred_resolution_scale :: proc(scale: f32) {
	log.warn("gpu: deferred lighting is not yet implemented")
}

@(private)
mat4_inverse :: proc(m: Mat4) -> Mat4 {
	// Cofactor expansion for 4x4 matrix inverse
	a00 := m[0, 0]; a01 := m[0, 1]; a02 := m[0, 2]; a03 := m[0, 3]
	a10 := m[1, 0]; a11 := m[1, 1]; a12 := m[1, 2]; a13 := m[1, 3]
	a20 := m[2, 0]; a21 := m[2, 1]; a22 := m[2, 2]; a23 := m[2, 3]
	a30 := m[3, 0]; a31 := m[3, 1]; a32 := m[3, 2]; a33 := m[3, 3]

	b00 := a00 * a11 - a01 * a10
	b01 := a00 * a12 - a02 * a10
	b02 := a00 * a13 - a03 * a10
	b03 := a01 * a12 - a02 * a11
	b04 := a01 * a13 - a03 * a11
	b05 := a02 * a13 - a03 * a12
	b06 := a20 * a31 - a21 * a30
	b07 := a20 * a32 - a22 * a30
	b08 := a20 * a33 - a23 * a30
	b09 := a21 * a32 - a22 * a31
	b10 := a21 * a33 - a23 * a31
	b11 := a22 * a33 - a23 * a32

	det := b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06
	if math.abs(det) < 1e-12 {
		return matrix_identity()
	}
	inv_det := 1.0 / det

	r: Mat4
	r[0, 0] = ( a11 * b11 - a12 * b10 + a13 * b09) * inv_det
	r[0, 1] = (-a01 * b11 + a02 * b10 - a03 * b09) * inv_det
	r[0, 2] = ( a31 * b05 - a32 * b04 + a33 * b03) * inv_det
	r[0, 3] = (-a21 * b05 + a22 * b04 - a23 * b03) * inv_det
	r[1, 0] = (-a10 * b11 + a12 * b08 - a13 * b07) * inv_det
	r[1, 1] = ( a00 * b11 - a02 * b08 + a03 * b07) * inv_det
	r[1, 2] = (-a30 * b05 + a32 * b02 - a33 * b01) * inv_det
	r[1, 3] = ( a20 * b05 - a22 * b02 + a23 * b01) * inv_det
	r[2, 0] = ( a10 * b10 - a11 * b08 + a13 * b06) * inv_det
	r[2, 1] = (-a00 * b10 + a01 * b08 - a03 * b06) * inv_det
	r[2, 2] = ( a30 * b04 - a31 * b02 + a33 * b00) * inv_det
	r[2, 3] = (-a20 * b04 + a21 * b02 - a23 * b00) * inv_det
	r[3, 0] = (-a10 * b09 + a11 * b07 - a12 * b06) * inv_det
	r[3, 1] = ( a00 * b09 - a01 * b07 + a02 * b06) * inv_det
	r[3, 2] = (-a30 * b03 + a31 * b01 - a32 * b00) * inv_det
	r[3, 3] = ( a20 * b03 - a21 * b01 + a22 * b00) * inv_det
	return r
}