Render multiple objects

This commit is contained in:
Riyyi
2026-10-31 18:23:23 +01:00
parent af063209ff
commit 148b511009
5 changed files with 514 additions and 91 deletions
+55
View File
@@ -0,0 +1,55 @@
# Simple textured cube
# 8 vertices, 6 faces (12 triangles), per-face UVs
o Cube
# Positions
v -1.0 -1.0 1.0
v 1.0 -1.0 1.0
v 1.0 1.0 1.0
v -1.0 1.0 1.0
v -1.0 -1.0 -1.0
v 1.0 -1.0 -1.0
v 1.0 1.0 -1.0
v -1.0 1.0 -1.0
# Texture coordinates
vt 0.0 0.0
vt 1.0 0.0
vt 1.0 1.0
vt 0.0 1.0
# Normals
vn 0.0 0.0 1.0
vn 0.0 0.0 -1.0
vn 1.0 0.0 0.0
vn -1.0 0.0 0.0
vn 0.0 1.0 0.0
vn 0.0 -1.0 0.0
# Faces (front, back, right, left, top, bottom)
# Each face uses two triangles: (0,1,2) and (0,2,3)
# Front (+Z)
f 1/1/1 2/2/1 3/3/1
f 1/1/1 3/3/1 4/4/1
# Back (-Z)
f 6/1/2 5/2/2 8/3/2
f 6/1/2 8/3/2 7/4/2
# Right (+X)
f 2/1/3 6/2/3 7/3/3
f 2/1/3 7/3/3 3/4/3
# Left (-X)
f 5/1/4 1/2/4 4/3/4
f 5/1/4 4/3/4 8/4/4
# Top (+Y)
f 4/1/5 3/2/5 7/3/5
f 4/1/5 7/3/5 8/4/5
# Bottom (-Y)
f 5/1/6 6/2/6 2/3/6
f 5/1/6 2/3/6 1/4/6
+80
View File
@@ -0,0 +1,80 @@
package core
import "base:runtime"
import "core:fmt"
import "sindri:base"
// -----------------------------------------
// Types
Model :: struct {
vertices: []base.Vertex,
indices: []u16,
// Some file formats embed their texture
// texture: ^Texture2D,
}
Texture :: struct {
width: u32,
height: u32,
id: u32,
internal_format: u32,
data_format: u32,
data_type: u32,
}
Assets :: struct {
allocator: runtime.Allocator,
model_list: map[string]Model,
texture_list: map[string]Texture,
}
// -----------------------------------------
// Constructor/destructor
assets_init :: proc(allocator := context.allocator) -> Assets {
return {
allocator = allocator,
model_list = make(map[string]Model, allocator),
texture_list = make(map[string]Texture, allocator),
}
}
assets_destroy :: proc(a: ^Assets) {
for _, &model in a.model_list {
delete(model.vertices, a.allocator)
delete(model.indices, a.allocator)
}
for _, &texture in a.texture_list {
}
delete(a.model_list)
delete(a.texture_list)
}
// -----------------------------------------
// Public functions
load_model :: proc(a: ^Assets, path: string) -> ^Model {
if path in a.model_list do return &a.model_list[path]
vertices, indices, ok := load_obj_model(path, a.allocator)
fmt.assertf(ok, "[assets] failed to read model: %s", path)
a.model_list[path] = {
vertices = vertices,
indices = indices,
}
return &a.model_list[path]
}
// Batching:
// max_vertices 60k
// max_elements 60k
// max_texture_slots 32, query gpu or can wgpu do a larger buffer?
// Scene load: loop through all models, add all model sizes together,
// then create a vertex_buffer using the max size, storing the index somewhere.
+5 -5
View File
@@ -7,21 +7,21 @@ import "core:strconv"
import "core:strings"
import "sindri:base"
import "sindri:platform"
// Minimal OBJ loader: reads v, vt, vn and triangulates convex polygon faces.
// Produces one GPU vertex per face corner, then centers and normalizes the
// model so it fits roughly inside a [-1, 1] cube at the origin
load_obj_model :: proc(
path: string,
allocator := context.allocator,
) -> (
vertices: []base.Vertex,
indices: []u16,
ok: bool,
) {
data, err := os.read_entire_file_from_path(path, context.allocator)
data, err := os.read_entire_file_from_path(path, allocator)
if err != os.ERROR_NONE {
fmt.eprintfln("[core] failed to read model: %s", path)
fmt.eprintfln("[obj] failed to read model: %s", path)
return nil, nil, false
}
defer delete(data)
@@ -141,8 +141,8 @@ load_obj_model :: proc(
v.pos = (v.pos - center) * scale
}
vertices = make([]base.Vertex, len(temp_vertices))
indices = make([]u16, len(temp_indices))
vertices = make([]base.Vertex, len(temp_vertices), allocator)
indices = make([]u16, len(temp_indices), allocator)
copy(vertices, temp_vertices[:])
copy(indices, temp_indices[:])
return vertices, indices, true
+26 -5
View File
@@ -89,10 +89,31 @@ test :: proc(e: ^base.Key_Press_Event, _: rawptr) -> bool {
// Load model from disk and upload it
load_test_model :: proc() {
vertices, indices, model_ok := core.load_obj_model("assets/suzanne.obj")
if !model_ok do fmt.panicf("[main] could not load model")
defer delete(vertices)
defer delete(indices)
assets := core.assets_init()
defer core.assets_destroy(&assets)
platform.fill_buffers_with_model(vertices, indices)
cube := core.load_model(&assets, "assets/cube.obj")
suzanne := core.load_model(&assets, "assets/suzanne.obj")
platform.create_material("dummy")
platform.create_renderable(
"assets/cube.obj",
cube.vertices,
cube.indices,
true,
)
platform.create_renderable(
"assets/suzanne.obj",
suzanne.vertices,
suzanne.indices,
false,
)
}
// per-object:
// - vertex buffer
// - index buffer
// - uniform buffer (model matrix)
// - texture
// - material
// - 1 drawcall
+348 -81
View File
@@ -14,8 +14,33 @@ import "sindri:base"
// -----------------------------------------
// Types
// Updated once per frame
Camera_Uniform :: struct {
mvp: linalg.Matrix4f32,
mvp: base.Mat4,
}
// Updated once per material
Material_Uniform :: struct {
dummy: f32,
}
// Updated once per mesh
Renderable_Uniform :: struct {
transform: base.Mat4,
}
Material :: struct {
bind_group: wgpu.BindGroup,
uniform_buffer: wgpu.Buffer,
}
// TODO: Split Renderable into mesh (vert, idx, #idx) and object (bg, ub)
Renderable :: struct {
bind_group: wgpu.BindGroup,
uniform_buffer: wgpu.Buffer,
vertex_buffer: wgpu.Buffer,
index_buffer: wgpu.Buffer,
index_count: u32,
}
// -----------------------------------------
@@ -23,44 +48,52 @@ Camera_Uniform :: struct {
// Instance -> Surface -> Adapter -> Device -> Queue
state: struct {
ctx: runtime.Context,
os: OS,
ctx: runtime.Context,
os: OS,
// ----------------------------------------
instance: wgpu.Instance, // entry point: creates Adapter, Device and Surface
surface: wgpu.Surface, // handle to a presentable surface (e.g. a window)
adapter: wgpu.Adapter, // handle to physical GPU
device: wgpu.Device, // open connection to a GPU
queue: wgpu.Queue, // handle to command queue on a Device
config: wgpu.SurfaceConfiguration,
module: wgpu.ShaderModule,
pipeline_layout: wgpu.PipelineLayout,
pipeline: wgpu.RenderPipeline,
instance: wgpu.Instance, // entry point: creates Adapter, Device and Surface
surface: wgpu.Surface, // handle to a presentable surface (e.g. a window)
adapter: wgpu.Adapter, // handle to physical GPU
device: wgpu.Device, // open connection to a GPU
queue: wgpu.Queue, // handle to command queue on a Device
config: wgpu.SurfaceConfiguration,
module: wgpu.ShaderModule,
pipeline_layout: wgpu.PipelineLayout,
pipeline: wgpu.RenderPipeline,
// ----------------------------------------
capabilities: wgpu.SurfaceCapabilities,
vsync: bool,
capabilities: wgpu.SurfaceCapabilities,
vsync: bool,
// ----------------------------------------
depth_texture: wgpu.Texture,
depth_texture_view: wgpu.TextureView,
depth_texture: wgpu.Texture,
depth_texture_view: wgpu.TextureView,
// ----------------------------------------
uniform_buffer: wgpu.Buffer,
bind_group_layout: wgpu.BindGroupLayout,
bind_group: wgpu.BindGroup,
camera_uniform_buffer: wgpu.Buffer,
camera_bind_group_layout: wgpu.BindGroupLayout,
camera_bind_group: wgpu.BindGroup,
// ----------------------------------------
vertex_buffer: wgpu.Buffer,
index_buffer: wgpu.Buffer,
index_count: u32,
material_bind_group_layout: wgpu.BindGroupLayout,
materials: map[string]Material,
// ----------------------------------------
diffuse_texture: wgpu.Texture,
diffuse_texture_view: wgpu.TextureView,
diffuse_sampler: wgpu.Sampler,
renderable_bind_group_layout: wgpu.BindGroupLayout,
renderables: map[string]Renderable,
// ----------------------------------------
diffuse_texture: wgpu.Texture,
diffuse_texture_view: wgpu.TextureView,
diffuse_sampler: wgpu.Sampler,
}
// -----------------------------------------
// Constructor/destructor
instance_init :: proc(settings: base.Settings) {
instance_init :: proc(
settings: base.Settings,
allocator := context.allocator,
) {
state.ctx = context
state.materials = make(map[string]Material, allocator)
state.renderables = make(map[string]Renderable, allocator)
wgpu.SetLogCallback(log_callback, nil)
wgpu.SetLogLevel(.Warn)
@@ -96,12 +129,29 @@ instance_destroy :: proc() {
wgpu.TextureViewRelease(state.diffuse_texture_view)
wgpu.TextureRelease(state.diffuse_texture)
wgpu.BufferRelease(state.uniform_buffer)
wgpu.BufferRelease(state.vertex_buffer)
wgpu.BufferRelease(state.index_buffer)
wgpu.BufferRelease(state.camera_uniform_buffer)
wgpu.BindGroupRelease(state.camera_bind_group)
wgpu.BindGroupLayoutRelease(state.camera_bind_group_layout)
wgpu.BindGroupRelease(state.bind_group)
wgpu.BindGroupLayoutRelease(state.bind_group_layout)
if state.materials != nil {
for _, m in state.materials {
wgpu.BufferRelease(m.uniform_buffer)
wgpu.BindGroupRelease(m.bind_group)
}
delete(state.materials)
}
wgpu.BindGroupLayoutRelease(state.material_bind_group_layout)
if state.renderables != nil {
for _, r in state.renderables {
wgpu.BufferRelease(r.uniform_buffer)
wgpu.BindGroupRelease(r.bind_group)
wgpu.BufferRelease(r.vertex_buffer)
wgpu.BufferRelease(r.index_buffer)
}
delete(state.renderables)
}
wgpu.BindGroupLayoutRelease(state.renderable_bind_group_layout)
wgpu.RenderPipelineRelease(state.pipeline)
wgpu.PipelineLayoutRelease(state.pipeline_layout)
@@ -137,7 +187,11 @@ resize :: proc "c" () {
create_depth_texture()
// The projection depends on aspect, so rebuild and re-upload the matrix
upload_camera(state.queue, state.uniform_buffer, framebuffer_aspect())
upload_camera(
state.queue,
state.camera_uniform_buffer,
framebuffer_aspect(),
)
}
frame :: proc "c" () {
@@ -194,38 +248,58 @@ frame :: proc "c" () {
wgpu.RenderPassEncoderSetPipeline(render_pass_encoder, state.pipeline)
// The camera's MVP matrix: group 0
// Uniform group 0, camera MVP matrix
wgpu.RenderPassEncoderSetBindGroup(
render_pass_encoder,
0,
state.bind_group,
state.camera_bind_group,
dynamicOffsets = nil, // reuse bind group af different byte offsets, per binding
)
wgpu.RenderPassEncoderSetVertexBuffer(
m := state.materials["dummy"]
wgpu.RenderPassEncoderSetBindGroup(
render_pass_encoder,
slot = 0,
buffer = state.vertex_buffer,
offset = 0, // offset into buffer to start from
size = wgpu.WHOLE_SIZE, // x bytes of the buffer to use
1,
m.bind_group,
dynamicOffsets = nil, // reuse bind group af different byte offsets, per binding
)
wgpu.RenderPassEncoderSetIndexBuffer(
render_pass_encoder,
buffer = state.index_buffer,
format = .Uint16, // type of each element in the buffer
offset = 0, // offset into buffer to start from
size = wgpu.WHOLE_SIZE, // x bytes of the buffer to use
)
// Render all renderables
for _, r in state.renderables {
// Uniform group 2
wgpu.RenderPassEncoderSetBindGroup(
render_pass_encoder,
2,
r.bind_group,
dynamicOffsets = nil, // reuse bind group af different byte offsets, per binding
)
wgpu.RenderPassEncoderDrawIndexed(
render_pass_encoder,
state.index_count,
instanceCount = 1, // draw x instance(s) of the mesh
firstIndex = 0, // offset into buffer to start from, useful for drawing sub-mesh
baseVertex = 0, // value added to index before vertex fetch, useful for multi-mesh vertex buffers
firstInstance = 0, // number to start instance index from
)
wgpu.RenderPassEncoderSetVertexBuffer(
render_pass_encoder,
slot = 0,
buffer = r.vertex_buffer,
offset = 0, // offset into buffer to start from
size = wgpu.WHOLE_SIZE, // x bytes of the buffer to use
)
wgpu.RenderPassEncoderSetIndexBuffer(
render_pass_encoder,
buffer = r.index_buffer,
format = .Uint16, // type of each element in the buffer
offset = 0, // offset into buffer to start from
size = wgpu.WHOLE_SIZE, // x bytes of the buffer to use
)
wgpu.RenderPassEncoderDrawIndexed(
render_pass_encoder,
r.index_count,
instanceCount = 1, // draw x instance(s) of the mesh
firstIndex = 0, // offset into buffer to start from, useful for drawing sub-mesh
baseVertex = 0, // value added to index before vertex fetch, useful for multi-mesh vertex buffers
firstInstance = 0, // number to start instance index from
)
}
wgpu.RenderPassEncoderEnd(render_pass_encoder)
wgpu.RenderPassEncoderRelease(render_pass_encoder)
@@ -237,19 +311,148 @@ frame :: proc "c" () {
wgpu.SurfacePresent(state.surface)
}
// Upload arbitrary vertex/index data to GPU buffers
fill_buffers_with_model :: proc(vertices: []base.Vertex, indices: []u16) {
state.vertex_buffer = wgpu.DeviceCreateBufferWithData(
create_material :: proc(name: string) {
if name in state.materials do return
uniform_buffer := wgpu.DeviceCreateBuffer(
state.device,
&{label = "model vertices", usage = {.Vertex}},
vertices,
&{
label = "material uniform",
usage = {.Uniform, .CopyDst}, // shader reads, queue writes
size = cast(u64)size_of(Material_Uniform),
},
)
state.index_buffer = wgpu.DeviceCreateBufferWithData(
uniform := Material_Uniform{}
// bufferOffset = offset into buffer to start from
wgpu.QueueWriteBuffer(
state.queue,
uniform_buffer,
0,
&uniform,
size_of(Material_Uniform),
)
entries := [?]wgpu.BindGroupEntry {
{
binding = 0, // slot 0
buffer = uniform_buffer,
offset = 0, // offset into buffer to start from
size = cast(u64)size_of(Material_Uniform),
},
}
bind_group := wgpu.DeviceCreateBindGroup(
state.device,
&{label = "model indices", usage = {.Index}},
indices,
&{
label = "material bind group",
layout = state.material_bind_group_layout,
entryCount = len(entries),
entries = raw_data(entries[:]),
},
)
state.index_count = cast(u32)len(indices)
state.materials[name] = {
bind_group = bind_group,
uniform_buffer = uniform_buffer,
}
}
create_renderable :: proc(
name: string,
vertices: []base.Vertex,
indices: []u16,
first: bool,
) {
if name in state.renderables do return
uniform_buffer := wgpu.DeviceCreateBuffer(
state.device,
&{
label = "renderable uniform",
usage = {.Uniform, .CopyDst}, // shader reads, queue writes
size = cast(u64)size_of(Renderable_Uniform),
},
)
transform: base.Mat4
if first {
transform = linalg.identity(base.Mat4)
} else {
transform = linalg.matrix4_translate(base.Vec3{3, 0, 0})
}
uniform := Renderable_Uniform {
transform = transform,
}
// bufferOffset = offset into buffer to start from
wgpu.QueueWriteBuffer(
state.queue,
uniform_buffer,
0,
&uniform,
size_of(Renderable_Uniform),
)
entries := [?]wgpu.BindGroupEntry {
{
binding = 0, // slot 0
buffer = uniform_buffer,
offset = 0, // offset into buffer to start from
size = cast(u64)size_of(Renderable_Uniform),
},
}
bind_group := wgpu.DeviceCreateBindGroup(
state.device,
&{
label = "renderable bind group",
layout = state.renderable_bind_group_layout,
entryCount = len(entries),
entries = raw_data(entries[:]),
},
)
vertex_buffer := wgpu.DeviceCreateBuffer(
state.device,
&{
label = "vertex buffer",
usage = {.Vertex, .CopyDst}, // shader reads, queue writes
size = cast(u64)(len(vertices) * size_of(base.Vertex)),
},
)
wgpu.QueueWriteBuffer(
state.queue,
vertex_buffer,
0,
raw_data(vertices),
cast(uint)(len(vertices) * size_of(base.Vertex)),
)
index_buffer := wgpu.DeviceCreateBuffer(
state.device,
&{
label = "index buffer",
usage = {.Index, .CopyDst}, // shader reads, queue writes
size = cast(u64)(len(indices) * size_of(u16)),
},
)
wgpu.QueueWriteBuffer(
state.queue,
index_buffer,
0,
raw_data(indices),
cast(uint)(len(indices) * size_of(u16)),
)
state.renderables[name] = {
bind_group = bind_group,
uniform_buffer = uniform_buffer,
vertex_buffer = vertex_buffer,
index_buffer = index_buffer,
index_count = cast(u32)len(indices),
}
}
// -----------------------------------------
@@ -307,7 +510,7 @@ on_device :: proc "c" (
// ----------------------------------------
// Compile shader
uniforms_struct := to_wgsl_struct(Camera_Uniform, "Uniforms")
uniforms_struct := to_wgsl_struct(Camera_Uniform, "Camera")
defer delete(uniforms_struct)
vertex_input_struct := to_wgsl_struct(
base.Vertex,
@@ -316,14 +519,26 @@ on_device :: proc "c" (
)
defer delete(vertex_input_struct)
material_struct := to_wgsl_struct(Material_Uniform, "Material")
defer delete(material_struct)
renderable_struct := to_wgsl_struct(Renderable_Uniform, "Renderable")
defer delete(renderable_struct)
shader := fmt.tprintf(
`
%s
%s
%s
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(0) var<uniform> c: Camera;
@group(0) @binding(1) var t_diffuse: texture_2d<f32>;
@group(0) @binding(2) var s_diffuse: sampler;
@group(1) @binding(0) var<uniform> m: Material;
@group(2) @binding(0) var<uniform> r: Renderable;
%s
struct VertexOutput {{
@@ -335,7 +550,7 @@ on_device :: proc "c" (
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {{
var out: VertexOutput;
out.position = u.mvp * vec4<f32>(in.pos, 1.0);
out.position = c.mvp * r.transform * vec4<f32>(in.pos, 1.0);
out.col = in.col;
out.uv = in.uv;
return out;
@@ -347,6 +562,8 @@ on_device :: proc "c" (
}}
`,
uniforms_struct,
material_struct,
renderable_struct,
vertex_input_struct,
)
@@ -363,7 +580,7 @@ on_device :: proc "c" (
// ----------------------------------------
// Camera uniform: buffer, then the baked layout it will be read through
state.uniform_buffer = wgpu.DeviceCreateBuffer(
state.camera_uniform_buffer = wgpu.DeviceCreateBuffer(
state.device,
&{
label = "camera uniform",
@@ -396,7 +613,7 @@ on_device :: proc "c" (
sampler = {type = .Filtering},
},
}
state.bind_group_layout = wgpu.DeviceCreateBindGroupLayout(
state.camera_bind_group_layout = wgpu.DeviceCreateBindGroupLayout(
state.device,
&{
label = "camera layout",
@@ -405,6 +622,50 @@ on_device :: proc "c" (
},
)
// ----------------------------------------
// Material baked layout
material_layout_entries := [?]wgpu.BindGroupLayoutEntry {
{
binding = 0, // slot 0
visibility = {.Fragment},
buffer = {
type = .Uniform,
minBindingSize = cast(u64)size_of(Material_Uniform),
},
},
}
state.material_bind_group_layout = wgpu.DeviceCreateBindGroupLayout(
state.device,
&{
label = "material layout",
entryCount = len(material_layout_entries),
entries = raw_data(material_layout_entries[:]),
},
)
// ----------------------------------------
// Renderable baked layout
renderable_layout_entries := [?]wgpu.BindGroupLayoutEntry {
{
binding = 0, // slot 0
visibility = {.Vertex},
buffer = {
type = .Uniform,
minBindingSize = cast(u64)size_of(Renderable_Uniform),
},
},
}
state.renderable_bind_group_layout = wgpu.DeviceCreateBindGroupLayout(
state.device,
&{
label = "renderable layout",
entryCount = len(renderable_layout_entries),
entries = raw_data(renderable_layout_entries[:]),
},
)
// ----------------------------------------
// Diffuse texture + sampler
@@ -426,12 +687,16 @@ on_device :: proc "c" (
// ----------------------------------------
// Render pipeline
camera_group_layouts := [1]wgpu.BindGroupLayout{state.bind_group_layout}
group_layouts := [?]wgpu.BindGroupLayout {
state.camera_bind_group_layout,
state.material_bind_group_layout,
state.renderable_bind_group_layout,
}
state.pipeline_layout = wgpu.DeviceCreatePipelineLayout(
state.device,
&{
bindGroupLayoutCount = len(camera_group_layouts),
bindGroupLayouts = raw_data(camera_group_layouts[:]),
bindGroupLayoutCount = len(group_layouts),
bindGroupLayouts = raw_data(group_layouts[:]),
},
)
@@ -471,25 +736,29 @@ on_device :: proc "c" (
bind_group_entries := [?]wgpu.BindGroupEntry {
{
binding = 0, // slot 0
buffer = state.uniform_buffer,
buffer = state.camera_uniform_buffer,
offset = 0, // offset into buffer to start from
size = cast(u64)size_of(Camera_Uniform),
},
{binding = 1, textureView = state.diffuse_texture_view},
{binding = 2, sampler = state.diffuse_sampler},
}
state.bind_group = wgpu.DeviceCreateBindGroup(
state.camera_bind_group = wgpu.DeviceCreateBindGroup(
state.device,
&{
label = "camera bind group",
layout = state.bind_group_layout,
layout = state.camera_bind_group_layout,
entryCount = len(bind_group_entries),
entries = raw_data(bind_group_entries[:]),
},
)
// Initial upload of the matrix
upload_camera(state.queue, state.uniform_buffer, framebuffer_aspect())
upload_camera(
state.queue,
state.camera_uniform_buffer,
framebuffer_aspect(),
)
}
@(private = "file")
@@ -569,9 +838,7 @@ framebuffer_aspect :: proc() -> f32 {
// Perspective projection, WebGPU flavour
@(private = "file")
camera_perspective :: proc(
fovy_degrees, aspect, near, far: f32,
) -> linalg.Matrix4f32 {
camera_perspective :: proc(fovy_degrees, aspect, near, far: f32) -> base.Mat4 {
gl := linalg.matrix4_perspective(
fovy_degrees * math.PI / 180.0,
aspect,
@@ -580,16 +847,16 @@ camera_perspective :: proc(
)
// GL's clip space is [-1, 1] and WebGPU's clip space is [0, 1],
// so fold z with a remap: z' = 1/2 z + 1/2 w
remap := linalg.identity(linalg.Matrix4f32)
remap := linalg.identity(base.Mat4)
remap[2, 2] = 0.5
remap[2, 3] = 0.5
return remap * gl
}
@(private = "file")
camera_mvp :: proc(aspect: f32) -> linalg.Matrix4f32 {
camera_mvp :: proc(aspect: f32) -> base.Mat4 {
projection := camera_perspective(60, aspect, 0.1, 100)
view := linalg.matrix4_look_at_f32({0, 0.75, 2}, {0, 0, 0}, {0, 1, 0})
view := linalg.matrix4_look_at_f32({0, 2, 5}, {0, 0, 0}, {0, 1, 0})
// model = identity: the loader already centered the model at the origin
return projection * view
}