Harbor

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

import "core:encoding/json"
import "core:log"
import "core:mem"
import glsl "core:math/linalg/glsl"
import "core:os"
import "core:strings"
import bk "../backend"

// --- glTF Parsed Data (intermediate, freed after GPU upload) ---

Gltf_Mesh_Data :: struct {
	vertex_data:  []byte,
	vertex_count: i32,
	layout:       bk.Vertex_Layout,
	indices:      []u32,
	material_idx: int,
	transform:    glsl.mat4x4,
}

Gltf_Material_Data :: struct {
	texture_path:     string, // resolved absolute path (allocated)
	normal_map_path:  string, // resolved absolute path (allocated)
	color:            [4]f32,
	double_sided:     bool,
	metallic_factor:  f32,
	roughness_factor: f32,
	emissive_factor:  [3]f32,
}

Gltf_Model :: struct {
	meshes:         []Gltf_Mesh_Data,
	materials:      []Gltf_Material_Data,
	root_transform: glsl.mat4x4, // Scene root node transform (not baked into vertices)
}

// glTF internal parsing types
@(private)
Gltf_Buffer_View :: struct {
	byte_offset: int,
	byte_length: int,
	byte_stride: int,
}

@(private)
Gltf_Accessor :: struct {
	buffer_view:    int,
	byte_offset:    int,
	component_type: int,
	count:          int,
}

@(private)
Gltf_Node :: struct {
	children:   [dynamic]int,
	xform:     glsl.mat4x4,
	has_xform: bool,
	mesh_idx:   int,
	has_mesh:   bool,
}

// --- Public resource API ---

load_model_from_gltf :: proc(
	state: ^Resource_State,
	b: ^bk.Backend,
	path: string,
) -> (id: u32, mesh_count: int, ok: bool) {
	gltf, gltf_ok := parse_gltf(path)
	if !gltf_ok {
		return 0, 0, false
	}
	defer free_gltf_model(&gltf)

	slot, slot_ok := allocate_model_slot(state)
	if !slot_ok {
		return 0, 0, false
	}

	mc := len(gltf.meshes)
	model := &state.models[slot]
	model.mesh_ids = make([]u32, mc)
	model.texture_ids = make([]u32, mc)
	model.normal_map_ids = make([]u32, mc)
	model.material_colors = make([][4]f32, mc)
	model.material_double_sided = make([]bool, mc)
	model.material_metallic = make([]f32, mc)
	model.material_roughness = make([]f32, mc)
	model.material_emissive = make([][4]f32, mc)
	model.mesh_count = mc
	model.transform = gltf.root_transform

	// Deduplicate textures by path
	Loaded_Tex :: struct {
		path: string,
		id:   u32,
	}
	loaded: [dynamic]Loaded_Tex
	defer delete(loaded)

	for i in 0..<mc {
		mesh_data := &gltf.meshes[i]

		// Upload mesh geometry to GPU
		mid, _, mesh_ok := upload_mesh_raw(
			state, b,
			raw_data(mesh_data.vertex_data), len(mesh_data.vertex_data),
			mesh_data.vertex_count, mesh_data.indices,
			mesh_data.layout,
		)
		if !mesh_ok {
			log.errorf("gpu/gltf: failed to upload mesh %d", i)
			for j in 0..<i {
				unload_mesh(state, b, model.mesh_ids[j])
			}
			delete(model.mesh_ids)
			delete(model.texture_ids)
			delete(model.normal_map_ids)
			delete(model.material_colors)
			delete(model.material_double_sided)
			delete(model.material_metallic)
			delete(model.material_roughness)
			delete(model.material_emissive)
			model^ = {}
			return 0, 0, false
		}
		model.mesh_ids[i] = mid

		// Load material texture
		mat_idx := mesh_data.material_idx
		if mat_idx >= 0 && mat_idx < len(gltf.materials) {
			mat := &gltf.materials[mat_idx]
			model.material_colors[i] = mat.color
			model.material_double_sided[i] = mat.double_sided
			model.material_metallic[i] = mat.metallic_factor
			model.material_roughness[i] = mat.roughness_factor
			model.material_emissive[i] = {mat.emissive_factor.x, mat.emissive_factor.y, mat.emissive_factor.z, 0}

			if len(mat.texture_path) > 0 {
				// Check if already loaded
				found := false
				for &lt in loaded {
					if lt.path == mat.texture_path {
						model.texture_ids[i] = lt.id
						found = true
						break
					}
				}
				if !found {
					tid, _, _, tex_ok := load_texture_from_file(state, b, mat.texture_path)
					if tex_ok {
						model.texture_ids[i] = tid
						append(&loaded, Loaded_Tex{path = mat.texture_path, id = tid})
					} else {
						log.errorf("gpu/gltf: failed to load texture: %s", mat.texture_path)
					}
				}
			}

			if len(mat.normal_map_path) > 0 {
				found := false
				for &lt in loaded {
					if lt.path == mat.normal_map_path {
						model.normal_map_ids[i] = lt.id
						found = true
						break
					}
				}
				if !found {
					nid, _, _, nok := load_texture_from_file(state, b, mat.normal_map_path)
					if nok {
						model.normal_map_ids[i] = nid
						append(&loaded, Loaded_Tex{path = mat.normal_map_path, id = nid})
					} else {
						log.errorf("gpu/gltf: failed to load normal map: %s", mat.normal_map_path)
					}
				}
			}
		} else {
			model.material_colors[i] = {1, 1, 1, 1}
		}
	}

	model.active = true
	return slot, mc, true
}

unload_model_resource :: proc(state: ^Resource_State, b: ^bk.Backend, id: u32) {
	if id >= MAX_MODELS || !state.models[id].active {
		return
	}
	model := &state.models[id]

	for i in 0..<model.mesh_count {
		unload_mesh(state, b, model.mesh_ids[i])
	}

	// Deduplicate texture unloads (diffuse + normal maps)
	unloaded: [MAX_TEXTURES]bool
	for i in 0..<model.mesh_count {
		tid := model.texture_ids[i]
		if tid != 0 && !unloaded[tid] {
			unload_texture(state, b, tid)
			unloaded[tid] = true
		}
		nid := model.normal_map_ids[i]
		if nid != 0 && nid != NORMAL_TEXTURE_ID && !unloaded[nid] {
			unload_texture(state, b, nid)
			unloaded[nid] = true
		}
	}

	delete(model.mesh_ids)
	delete(model.texture_ids)
	delete(model.normal_map_ids)
	delete(model.material_colors)
	delete(model.material_double_sided)
	delete(model.material_metallic)
	delete(model.material_roughness)
	delete(model.material_emissive)
	model^ = {}
}

// --- glTF JSON Parser ---

@(private)
IDENTITY_MAT4 :: glsl.mat4x4{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}

@(private)
parse_gltf :: proc(path: string) -> (model: Gltf_Model, ok: bool) {
	// Read glTF JSON
	gltf_data, read_err := os.read_entire_file(path, context.allocator)
	if read_err != nil {
		log.errorf("gpu/gltf: failed to read file: %s", path)
		return {}, false
	}
	defer delete(gltf_data, context.allocator)

	parsed, json_err := json.parse(gltf_data)
	if json_err != nil {
		log.errorf("gpu/gltf: JSON parse error in %s: %v", path, json_err)
		return {}, false
	}
	defer json.destroy_value(parsed)

	root, root_ok := parsed.(json.Object)
	if !root_ok {
		log.error("gpu/gltf: root is not an object")
		return {}, false
	}

	// Resolve base directory
	base_dir := gltf_dir_from_path(path)

	// Read binary buffer
	buffers_arr := json_array(root, "buffers") or_return
	buf0, buf0_ok := buffers_arr[0].(json.Object)
	if !buf0_ok {
		return {}, false
	}
	bin_uri := json_string(buf0, "uri") or_return
	bin_path := strings.concatenate({base_dir, bin_uri}, context.temp_allocator)

	bin_data, bin_err := os.read_entire_file(bin_path, context.allocator)
	if bin_err != nil {
		log.errorf("gpu/gltf: failed to read binary buffer: %s", bin_path)
		return {}, false
	}
	defer delete(bin_data, context.allocator)

	// Parse buffer views
	views_arr := json_array(root, "bufferViews") or_return
	views := make([]Gltf_Buffer_View, len(views_arr))
	defer delete(views)
	for v, i in views_arr {
		vo, vo_ok := v.(json.Object)
		if !vo_ok { return {}, false }
		views[i].byte_length = json_int_val(vo, "byteLength")
		views[i].byte_offset = json_int_val(vo, "byteOffset")
		views[i].byte_stride = json_int_val(vo, "byteStride")
	}

	// Parse accessors
	acc_arr := json_array(root, "accessors") or_return
	accessors := make([]Gltf_Accessor, len(acc_arr))
	defer delete(accessors)
	for a, i in acc_arr {
		ao, ao_ok := a.(json.Object)
		if !ao_ok { return {}, false }
		accessors[i].buffer_view = json_int_val(ao, "bufferView")
		accessors[i].byte_offset = json_int_val(ao, "byteOffset")
		accessors[i].component_type = json_int_val(ao, "componentType")
		accessors[i].count = json_int_val(ao, "count")
	}

	// Parse images -> resolved paths
	images_arr, _ := json_array(root, "images")
	image_uris := make([]string, len(images_arr))
	defer {
		// Don't delete strings that were moved into materials
		// free_gltf_model handles those
	}
	for img, i in images_arr {
		io, io_ok := img.(json.Object)
		if !io_ok { continue }
		uri, uri_ok := json_string(io, "uri")
		if !uri_ok { continue }
		image_uris[i] = strings.concatenate({base_dir, uri})
	}
	defer {
		// Free image URIs not transferred to materials
		for &u in image_uris {
			// Only free if not empty and not transferred
			// Actually, materials copy the pointer, so we should NOT free here
			// free_gltf_model will handle it
		}
		delete(image_uris)
	}

	// Parse textures (texture index -> image index)
	textures_arr, _ := json_array(root, "textures")
	tex_to_image := make([]int, len(textures_arr))
	defer delete(tex_to_image)
	for t, i in textures_arr {
		to, to_ok := t.(json.Object)
		if !to_ok { continue }
		tex_to_image[i] = json_int_val(to, "source")
	}

	// Parse materials
	materials_arr, _ := json_array(root, "materials")
	model.materials = make([]Gltf_Material_Data, len(materials_arr))
	for m, i in materials_arr {
		mo, mo_ok := m.(json.Object)
		if !mo_ok { continue }
		model.materials[i].color = {1, 1, 1, 1}
		model.materials[i].roughness_factor = 0.5 // sensible default

		if ds, ds_ok := mo["doubleSided"].(json.Boolean); ds_ok {
			model.materials[i].double_sided = bool(ds)
		}

		pbr, has_pbr := mo["pbrMetallicRoughness"].(json.Object)
		if has_pbr {
			bct, has_bct := pbr["baseColorTexture"].(json.Object)
			if has_bct {
				tex_idx := json_int_val(bct, "index")
				if tex_idx >= 0 && tex_idx < len(tex_to_image) {
					img_idx := tex_to_image[tex_idx]
					if img_idx >= 0 && img_idx < len(image_uris) {
						// Transfer ownership of the string
						model.materials[i].texture_path = image_uris[img_idx]
						image_uris[img_idx] = ""
					}
				}
			}

			// Parse baseColorFactor
			if bcf, bcf_ok := pbr["baseColorFactor"].(json.Array); bcf_ok && len(bcf) >= 4 {
				model.materials[i].color = {
					f32(bcf[0].(json.Float) or_else 1),
					f32(bcf[1].(json.Float) or_else 1),
					f32(bcf[2].(json.Float) or_else 1),
					f32(bcf[3].(json.Float) or_else 1),
				}
			}

			// Parse metallicFactor (glTF default: 1.0)
			if mf, mf_ok := pbr["metallicFactor"].(json.Float); mf_ok {
				model.materials[i].metallic_factor = f32(mf)
			}

			// Parse roughnessFactor (glTF default: 1.0, we default to 0.5 for better visuals)
			if rf, rf_ok := pbr["roughnessFactor"].(json.Float); rf_ok {
				model.materials[i].roughness_factor = f32(rf)
			}
		}

		// Parse emissiveFactor
		if ef, ef_ok := mo["emissiveFactor"].(json.Array); ef_ok && len(ef) >= 3 {
			model.materials[i].emissive_factor = {
				f32(ef[0].(json.Float) or_else 0),
				f32(ef[1].(json.Float) or_else 0),
				f32(ef[2].(json.Float) or_else 0),
			}
		}

		// Parse normalTexture
		if nt, nt_ok := mo["normalTexture"].(json.Object); nt_ok {
			tex_idx := json_int_val(nt, "index")
			if tex_idx >= 0 && tex_idx < len(tex_to_image) {
				img_idx := tex_to_image[tex_idx]
				if img_idx >= 0 && img_idx < len(image_uris) && len(image_uris[img_idx]) > 0 {
					model.materials[i].normal_map_path = strings.clone(image_uris[img_idx])
				}
			}
		}
	}

	// Free any image URIs not transferred to materials
	for &u in image_uris {
		if len(u) > 0 {
			delete(u)
			u = ""
		}
	}

	// Parse meshes
	meshes_arr := json_array(root, "meshes") or_return

	// Parse nodes
	nodes_arr, _ := json_array(root, "nodes")
	nodes := make([]Gltf_Node, len(nodes_arr))
	defer {
		for &n in nodes {
			delete(n.children)
		}
		delete(nodes)
	}
	for n, i in nodes_arr {
		no, no_ok := n.(json.Object)
		if !no_ok { continue }

		if ch, ch_ok := no["children"].(json.Array); ch_ok {
			for c in ch {
				append(&nodes[i].children, int(c.(json.Float) or_else 0))
			}
		}

		if mat, mat_ok := no["matrix"].(json.Array); mat_ok && len(mat) == 16 {
			nodes[i].has_xform = true
			// glTF column-major -> Odin m[row, col]
			for col in 0..<4 {
				for row in 0..<4 {
					nodes[i].xform[row, col] = f32(mat[col * 4 + row].(json.Float) or_else 0)
				}
			}
		} else {
			// Parse TRS (translation / rotation / scale)
			t := glsl.vec3{0, 0, 0}
			r := glsl.vec4{0, 0, 0, 1} // quaternion (x, y, z, w)
			s := glsl.vec3{1, 1, 1}
			has_trs := false

			if ta, ta_ok := no["translation"].(json.Array); ta_ok && len(ta) >= 3 {
				t = {f32(ta[0].(json.Float) or_else 0), f32(ta[1].(json.Float) or_else 0), f32(ta[2].(json.Float) or_else 0)}
				has_trs = true
			}
			if ra, ra_ok := no["rotation"].(json.Array); ra_ok && len(ra) >= 4 {
				r = {f32(ra[0].(json.Float) or_else 0), f32(ra[1].(json.Float) or_else 0), f32(ra[2].(json.Float) or_else 0), f32(ra[3].(json.Float) or_else 0)}
				has_trs = true
			}
			if sa, sa_ok := no["scale"].(json.Array); sa_ok && len(sa) >= 3 {
				s = {f32(sa[0].(json.Float) or_else 0), f32(sa[1].(json.Float) or_else 0), f32(sa[2].(json.Float) or_else 0)}
				has_trs = true
			}

			if has_trs {
				nodes[i].has_xform = true
				nodes[i].xform = gltf_compose_trs(t, r, s)
			}
		}

		if m_val, m_ok := no["mesh"]; m_ok {
			nodes[i].mesh_idx = int(m_val.(json.Float) or_else 0)
			nodes[i].has_mesh = true
		}
	}

	// Compute per-mesh transforms by walking node tree
	mesh_transforms := make([]glsl.mat4x4, len(meshes_arr))
	defer delete(mesh_transforms)
	for i in 0..<len(mesh_transforms) {
		mesh_transforms[i] = IDENTITY_MAT4
	}

	// Walk node tree to accumulate per-mesh transforms.
	// The root scene node's own transform is stored separately as the model
	// transform (not baked into vertices) so the engine can apply it via the
	// ECS Transform hierarchy. This keeps vertex data in model space and lets
	// cameras, lights, and physics operate in consistent world-space units.
	root_xform := IDENTITY_MAT4
	scenes_arr, _ := json_array(root, "scenes")
	if len(scenes_arr) > 0 {
		scene0, s0_ok := scenes_arr[0].(json.Object)
		if s0_ok {
			if scene_nodes, sn_ok := scene0["nodes"].(json.Array); sn_ok {
				// If exactly one root node, extract its transform as the model transform
				if len(scene_nodes) == 1 {
					ri := int(scene_nodes[0].(json.Float) or_else 0)
					if ri >= 0 && ri < len(nodes) && nodes[ri].has_xform {
						root_xform = nodes[ri].xform
					}
				}
				for sn in scene_nodes {
					gltf_walk_nodes(nodes[:], int(sn.(json.Float) or_else 0), IDENTITY_MAT4, mesh_transforms)
				}
			}
		}
	}

	// Factor out the root transform from per-mesh transforms so vertices
	// are stored in model space (pre-root-transform).
	if root_xform != IDENTITY_MAT4 {
		root_inv := glsl.inverse(root_xform)
		for i in 0..<len(mesh_transforms) {
			mesh_transforms[i] = root_inv * mesh_transforms[i]
		}
	}

	// Build mesh data from primitives
	mesh_list: [dynamic]Gltf_Mesh_Data
	defer if !ok {
		for &m in mesh_list {
			delete(m.vertex_data)
			delete(m.indices)
		}
		delete(mesh_list)
	}

	for gm, mi in meshes_arr {
		gmo, gmo_ok := gm.(json.Object)
		if !gmo_ok { continue }
		prims_val, prims_ok := gmo["primitives"].(json.Array)
		if !prims_ok { continue }

		for p in prims_val {
			po, po_ok := p.(json.Object)
			if !po_ok { continue }
			attrs, attrs_ok := po["attributes"].(json.Object)
			if !attrs_ok { continue }

			pos_idx := json_int_or(attrs, "POSITION", -1)
			norm_idx := json_int_or(attrs, "NORMAL", -1)
			uv_idx := json_int_or(attrs, "TEXCOORD_0", -1)
			color_idx := json_int_or(attrs, "COLOR_0", -1)
			tangent_idx := json_int_or(attrs, "TANGENT", -1)
			indices_idx := json_int_or(po, "indices", -1)
			mat_idx := json_int_or(po, "material", -1)

			if pos_idx < 0 || indices_idx < 0 { continue }

			// Read vertex attributes from binary buffer
			positions := gltf_read_vec3(bin_data, views[:], &accessors[pos_idx])
			defer delete(positions)

			normals: []glsl.vec3
			defer delete(normals)
			if norm_idx >= 0 {
				normals = gltf_read_vec3(bin_data, views[:], &accessors[norm_idx])
			}

			uvs: []glsl.vec2
			defer delete(uvs)
			if uv_idx >= 0 {
				uvs = gltf_read_vec2(bin_data, views[:], &accessors[uv_idx])
			}

			colors: []glsl.vec4
			defer delete(colors)
			if color_idx >= 0 {
				colors = gltf_read_vec4(bin_data, views[:], &accessors[color_idx])
			}

			tangents: []glsl.vec4
			defer delete(tangents)
			if tangent_idx >= 0 {
				tangents = gltf_read_vec4(bin_data, views[:], &accessors[tangent_idx])
			}

			indices := gltf_read_indices(bin_data, views[:], &accessors[indices_idx])

			// Build vertex layout from present attributes.
			layout_attribs: [bk.MAX_VERTEX_ATTRIBS]bk.Vertex_Attrib
			layout_count := 0
			layout_attribs[layout_count] = .Position; layout_count += 1
			if normals != nil   { layout_attribs[layout_count] = .Normal;    layout_count += 1 }
			if uvs != nil       { layout_attribs[layout_count] = .Tex_Coord; layout_count += 1 }
			if colors != nil    { layout_attribs[layout_count] = .Color;     layout_count += 1 }
			if tangents != nil  { layout_attribs[layout_count] = .Tangent;   layout_count += 1 }
			layout := bk.vertex_layout_build(..layout_attribs[:layout_count])

			// Assemble interleaved vertex data
			vert_count := accessors[pos_idx].count
			vertex_data := make([]byte, vert_count * int(layout.stride))

			// Bake node transform into vertex data
			xform := mesh_transforms[mi]
			is_identity := xform == IDENTITY_MAT4

			for v in 0..<vert_count {
				base := v * int(layout.stride)

				// Position (always present)
				pos := positions[v]
				if !is_identity {
					p4 := glsl.vec4{pos.x, pos.y, pos.z, 1}
					tp := xform * p4
					pos = {tp.x, tp.y, tp.z}
				}
				write_vec3(vertex_data, base + 0, pos)

				// Normal
				if norm_off, has_norm := bk.vertex_layout_has(&layout, .Normal); has_norm {
					if normals != nil && v < len(normals) {
						n := normals[v]
						if !is_identity {
							nx := xform[0, 0] * n.x + xform[0, 1] * n.y + xform[0, 2] * n.z
							ny := xform[1, 0] * n.x + xform[1, 1] * n.y + xform[1, 2] * n.z
							nz := xform[2, 0] * n.x + xform[2, 1] * n.y + xform[2, 2] * n.z
							n = {nx, ny, nz}
						}
						write_vec3(vertex_data, base + int(norm_off), n)
					}
				}

				// Tex coord
				if uv_off, has_uv := bk.vertex_layout_has(&layout, .Tex_Coord); has_uv {
					if uvs != nil && v < len(uvs) {
						write_vec2(vertex_data, base + int(uv_off), uvs[v])
					}
				}

				// Vertex color
				if col_off, has_col := bk.vertex_layout_has(&layout, .Color); has_col {
					if colors != nil && v < len(colors) {
						write_vec4(vertex_data, base + int(col_off), colors[v])
					}
				}

				// Tangent
				if tan_off, has_tan := bk.vertex_layout_has(&layout, .Tangent); has_tan {
					if tangents != nil && v < len(tangents) {
						write_vec4(vertex_data, base + int(tan_off), tangents[v])
					}
				}
			}

			append(&mesh_list, Gltf_Mesh_Data{
				vertex_data  = vertex_data,
				vertex_count = i32(vert_count),
				layout       = layout,
				indices      = indices,
				material_idx = mat_idx,
				transform    = IDENTITY_MAT4, // already baked
			})
		}
	}

	model.meshes = mesh_list[:]
	model.root_transform = root_xform
	return model, true
}

@(private)
free_gltf_model :: proc(model: ^Gltf_Model) {
	for &m in model.meshes {
		delete(m.vertex_data)
		delete(m.indices)
	}
	delete(model.meshes)
	for &mat in model.materials {
		if len(mat.texture_path) > 0 {
			delete(mat.texture_path)
		}
		if len(mat.normal_map_path) > 0 {
			delete(mat.normal_map_path)
		}
	}
	delete(model.materials)
}

// --- Node tree traversal ---

@(private)
gltf_walk_nodes :: proc(
	nodes: []Gltf_Node,
	idx: int,
	parent_transform: glsl.mat4x4,
	mesh_transforms: []glsl.mat4x4,
) {
	if idx < 0 || idx >= len(nodes) { return }
	node := &nodes[idx]

	local := parent_transform
	if node.has_xform {
		local = parent_transform * node.xform
	}
	if node.has_mesh && node.mesh_idx >= 0 && node.mesh_idx < len(mesh_transforms) {
		mesh_transforms[node.mesh_idx] = local
	}
	for child in node.children {
		gltf_walk_nodes(nodes, child, local, mesh_transforms)
	}
}

// --- Binary buffer readers ---

@(private)
gltf_read_vec3 :: proc(bin: []byte, views: []Gltf_Buffer_View, acc: ^Gltf_Accessor) -> []glsl.vec3 {
	view := views[acc.buffer_view]
	base := view.byte_offset + acc.byte_offset
	stride := view.byte_stride if view.byte_stride > 0 else 12
	result := make([]glsl.vec3, acc.count)
	for i in 0..<acc.count {
		offset := base + i * stride
		if offset + 12 > len(bin) { break }
		ptr := cast(^[3]f32)&bin[offset]
		result[i] = {ptr[0], ptr[1], ptr[2]}
	}
	return result
}

@(private)
gltf_read_vec2 :: proc(bin: []byte, views: []Gltf_Buffer_View, acc: ^Gltf_Accessor) -> []glsl.vec2 {
	view := views[acc.buffer_view]
	base := view.byte_offset + acc.byte_offset
	stride := view.byte_stride if view.byte_stride > 0 else 8
	result := make([]glsl.vec2, acc.count)
	for i in 0..<acc.count {
		offset := base + i * stride
		if offset + 8 > len(bin) { break }
		ptr := cast(^[2]f32)&bin[offset]
		result[i] = {ptr[0], ptr[1]}
	}
	return result
}

@(private)
gltf_read_vec4 :: proc(bin: []byte, views: []Gltf_Buffer_View, acc: ^Gltf_Accessor) -> []glsl.vec4 {
	view := views[acc.buffer_view]
	base := view.byte_offset + acc.byte_offset
	stride := view.byte_stride if view.byte_stride > 0 else 16
	result := make([]glsl.vec4, acc.count)
	for i in 0..<acc.count {
		offset := base + i * stride
		if offset + 16 > len(bin) { break }
		ptr := cast(^[4]f32)&bin[offset]
		result[i] = {ptr[0], ptr[1], ptr[2], ptr[3]}
	}
	return result
}

@(private)
gltf_read_indices :: proc(bin: []byte, views: []Gltf_Buffer_View, acc: ^Gltf_Accessor) -> []u32 {
	view := views[acc.buffer_view]
	base := view.byte_offset + acc.byte_offset
	result := make([]u32, acc.count)

	switch acc.component_type {
	case 5125: // UNSIGNED_INT
		stride := view.byte_stride if view.byte_stride > 0 else 4
		for i in 0..<acc.count {
			offset := base + i * stride
			if offset + 4 > len(bin) { break }
			result[i] = (cast(^u32)&bin[offset])^
		}
	case 5123: // UNSIGNED_SHORT
		stride := view.byte_stride if view.byte_stride > 0 else 2
		for i in 0..<acc.count {
			offset := base + i * stride
			if offset + 2 > len(bin) { break }
			result[i] = u32((cast(^u16)&bin[offset])^)
		}
	case 5121: // UNSIGNED_BYTE
		for i in 0..<acc.count {
			offset := base + i
			if offset >= len(bin) { break }
			result[i] = u32(bin[offset])
		}
	}
	return result
}

// Compose a TRS (translation, rotation, scale) into a 4x4 matrix.
// Quaternion is (x, y, z, w) as in glTF spec.
@(private)
gltf_compose_trs :: proc(t: glsl.vec3, q: glsl.vec4, s: glsl.vec3) -> glsl.mat4x4 {
	// Rotation matrix from quaternion (x, y, z, w)
	x, y, z, w := q.x, q.y, q.z, q.w
	x2, y2, z2 := x + x, y + y, z + z
	xx := x * x2; xy := x * y2; xz := x * z2
	yy := y * y2; yz := y * z2; zz := z * z2
	wx := w * x2; wy := w * y2; wz := w * z2

	m: glsl.mat4x4
	m[0, 0] = (1 - (yy + zz)) * s.x
	m[0, 1] = (xy - wz) * s.y
	m[0, 2] = (xz + wy) * s.z
	m[0, 3] = t.x

	m[1, 0] = (xy + wz) * s.x
	m[1, 1] = (1 - (xx + zz)) * s.y
	m[1, 2] = (yz - wx) * s.z
	m[1, 3] = t.y

	m[2, 0] = (xz - wy) * s.x
	m[2, 1] = (yz + wx) * s.y
	m[2, 2] = (1 - (xx + yy)) * s.z
	m[2, 3] = t.z

	m[3, 0] = 0; m[3, 1] = 0; m[3, 2] = 0; m[3, 3] = 1
	return m
}

// --- JSON helpers ---

@(private)
gltf_dir_from_path :: proc(path: string) -> string {
	for i := len(path) - 1; i >= 0; i -= 1 {
		if path[i] == '/' || path[i] == '\\' {
			return path[:i + 1]
		}
	}
	return ""
}

@(private)
json_array :: proc(obj: json.Object, key: string) -> (json.Array, bool) {
	val, has := obj[key]
	if !has { return nil, false }
	arr, arr_ok := val.(json.Array)
	return arr, arr_ok
}

@(private)
json_string :: proc(obj: json.Object, key: string) -> (string, bool) {
	val, has := obj[key]
	if !has { return "", false }
	s, s_ok := val.(json.String)
	return s, s_ok
}

@(private)
json_int_val :: proc(obj: json.Object, key: string) -> int {
	val, has := obj[key]
	if !has { return 0 }
	return int(val.(json.Float) or_else 0)
}

@(private)
json_int_or :: proc(obj: json.Object, key: string, default_val: int) -> int {
	val, has := obj[key]
	if !has { return default_val }
	f, f_ok := val.(json.Float)
	if !f_ok { return default_val }
	return int(f)
}

// --- Vertex data writers (interleaved byte buffer) ---

@(private)
write_vec2 :: proc(buf: []byte, offset: int, v: glsl.vec2) {
	data := [2]f32{v.x, v.y}
	mem.copy(&buf[offset], &data, 8)
}

@(private)
write_vec3 :: proc(buf: []byte, offset: int, v: glsl.vec3) {
	data := [3]f32{v.x, v.y, v.z}
	mem.copy(&buf[offset], &data, 12)
}

@(private)
write_vec4 :: proc(buf: []byte, offset: int, v: glsl.vec4) {
	data := [4]f32{v.x, v.y, v.z, v.w}
	mem.copy(&buf[offset], &data, 16)
}