33. Scene and Physics Revamp
After using the asset pipeline to build a new Town Scene, a couple of things became apparent:
- Embedding data into custom properties is a miserable workflow
- Sometimes custom properties are on the object, sometimes they are on the mesh, and for reasons I couldn't debug after a half a day, sometimes they didn't show up at all
- The quads for ground colliders is not sufficient (no 3-axis rotation)
At this stage, we have to make a choice about how to proceed. We can either try to fix the rotation problems, or use a different method.
I opted to use a different method entirely: Sphere vs Triangle Mesh collisions.
Let's get the grunt work out of the way first. As Odin has been updated to make os2 the new os package, we need to fix some breaking changes.
This does not apply if you are from the future. You have undoubtedly already incorporated the new
ospackage into your code.
Update core:os Calls
1: read_dir now takes an explicit allocator. In our case we can use context.temp_allocator as we clone any necessary data manually.
2: read_entire_file now returns an error instead of a boolean. Update any calls to check if err != nil. Optionally, add err as argument to logged error.
Update main.odin
Since os2 has been folded into os, we no longer need the os2 import. We do need core:time now, as os.File_Time has been replaced:
1: Replace import "core:os/os2" with import "core:time".
2: In Game_API, change last_write_time: os.File_Time to last_write_time: time.Time.
3: Replace os2.copy_file with os.copy_file in game_api_reload.
While we're here, set context.user_ptr before calling game_post_window_create. We'll need this shortly:
context.user_ptr = game_state_memory
game_api.game_post_window_create(game_state_memory)
Streamline Entity Definition Procedures
The entity_def_by_* procedures make the user pass the data structure, making them useless wrappers. Instead, we'll use the context.user_ptr to get the Game_State and look at the data structure internally:
entity_def_by_name :: proc(name: string) -> (def: Entity_Definition, ok: bool) {
gs := cast(^Game_State)context.user_ptr
for ed in gs.entity_definitions {
if ed.name == name {
return gs.entity_definitions[ed.id], true
}
}
return {}, false
}
must_entity_def_ptr_by_name :: proc(name: string) -> ^Entity_Definition {
gs := cast(^Game_State)context.user_ptr
for ed in gs.entity_definitions {
if ed.name == name {
return &gs.entity_definitions[ed.id]
}
}
log.panicf("Failed to find entity with name: '%s'", name)
}
entity_def_by_id :: proc(id: int) -> ^Entity_Definition {
gs := cast(^Game_State)context.user_ptr
if len(gs.entity_definitions) <= id do return nil
return &gs.entity_definitions[id]
}
Now update every call site. The signatures no longer take a defs argument, so remove it from calls in battle.odin, ui.odin, and game.odin. There are about half a dozen of these. The compiler will find them for you.
Also delete get_nearby_point from entities.odin. It's unused.
Update Entity and Entity_Definition
We need to parse _behavior_flags from the entity JSON files. Add the following block after the existing entity definition parsing in game_post_window_create:
if _behavior_flags, exists := root.(json.Object)["_behavior_flags"].(json.Array);
exists {
for v in _behavior_flags {
name := v.(json.String)
if flag, ok := reflect.enum_from_name(Entity_Behavior, name); ok {
entity_def.behavior_flags += {flag}
} else {
log.errorf(
"Could not find behavior flag '%s' on entity '%s'",
name,
file_info.name,
)
}
}
}
While you're in there, remove the debug print loop that dumps all loaded entities. We don't need it anymore.
Update Scene_State and Assets_State
The old scene system stored colliders explicitly. We're moving to triangle mesh collisions against the model directly, so we can strip those fields out.
1: In Scene_State, remove static_geometry and walkable_surfaces.
2: In Assets_State, change the models key from Model_Key to string. We'll reference models by scene name/key now instead of an enum.
Assets_State :: struct {
textures: map[Texture_Key]raylib.Texture,
models: map[string]raylib.Model,
}
Fix Shaders for Texture Support
Our directional light shaders don't pass through texture coordinates. Without this, every mesh renders as a solid colour.
In dirlight.vert.glsl, add the texture coordinate input and output:
in vec2 vertexTexCoord;
// ...
out vec2 vTexCoord;
void main() {
vTexCoord = vertexTexCoord;
// ... rest unchanged
}
In dirlight.frag.glsl, sample the texture and multiply by lighting:
in vec2 vTexCoord;
uniform sampler2D texture0;
void main() {
// ... lighting calculation unchanged ...
vec3 light = ambient + lightColor * NdotL;
vec4 texelColor = texture(texture0, vTexCoord);
vec3 finalRGB = texelColor.rgb * light;
fragColor = vec4(clamp(finalRGB, 0.0, 1.0), texelColor.a);
}
Note that we pass through
texelColor.a. We'll use alpha to mark collision-only meshes that shouldn't be rendered.
Lazy Scene Loading
The old scene system loaded all scenes up front from hardcoded paths and stored them in a map.
Instead, we'll load scenes at the time of transition.
Create a new file scene2.odin. We define two small structs and one procedure:
Scene_Door :: struct {
name: string,
pair: string,
}
Scene_Data :: struct {
doors: []Scene_Door,
spawners: []Entity_Spawner,
}
scene_switch is the core of the new system. It unloads the old model, resets the arena, loads the .glb and .json by name, and hooks everything up:
scene_switch :: proc(arena: ^mem.Dynamic_Arena, scene_name: string) {
gs := cast(^Game_State)context.user_ptr
if gs.scene != nil {
if model, exists := gs.assets.models[gs.scene.scene_name]; exists {
rl.UnloadModel(model)
}
}
if arena.current_block == nil {
mem.dynamic_arena_init(arena, alignment = runtime.MAP_CACHE_LINE_SIZE)
}
mem.dynamic_arena_reset(arena)
allocator := mem.dynamic_arena_allocator(arena)
model := rl.LoadModel(fmt.ctprintf("assets/scenes/%s.glb", scene_name))
for i in 0 ..< model.materialCount {
model.materials[i].shader = gs.rendering.shader
}
file_data, file_data_err := os.read_entire_file(
fmt.tprintf("data/scenes/%s.json", scene_name),
context.temp_allocator,
)
if file_data_err != nil {
log.errorf("Tried to load scene, but JSON file could not be loaded: '%s'", file_data_err)
}
scene_data: Scene_Data
json.unmarshal(file_data, &scene_data, .SJSON, allocator)
ss := new(Scene_State, allocator)
ss.scene_name = strings.clone(scene_name, allocator)
ss.active_entities = make(type_of(ss.active_entities))
ss.entities = make(type_of(ss.entities))
ss.generations = make(type_of(ss.generations))
ss.unused_entity_handles = make(type_of(ss.unused_entity_handles))
ss.player_handle = player_init(ss)
gs.assets.models[scene_name] = model
gs.scene = ss
}
The convention is simple: a scene called "flat" expects assets/scenes/flat.glb and data/scenes/flat.json.
Now gut the old system. In scenes.odin, delete scene_parse, scene_init, and scene_load.
In game.odin:
- Move the shader loading out of the old town-loading block so it's loaded once globally.
- Delete the entire old scene-loading block (the hardcoded paths, the loop over
gs.scenes). - Replace the scene change event handler to call
scene_switch:
scene_switch(&gs.scene_arena, payload.scene_key)
- At the end of
game_post_window_create, load the initial scene:
scene_switch(&gs.scene_arena, "flat")
- In
battle_start, remove thescene_initcall - the battle scene setup handles this differently.
Delete geometry.odin
The quad-based geometry helpers (quad_size_compute, quad_rotation_compute, quads_from_model) are no longer needed. Delete the file.
Update Rendering
The old rendering code drew static geometry as debug OBBs and walkable surfaces as debug quads, then drew scene objects with the shader. We now render the model meshes directly.
Replace the old rendering loops in game_render with:
model := &gs.assets.models[gs.scene.scene_name]
for i in 0 ..< model.meshCount {
material := model.materials[model.meshMaterial[i]]
material.shader = gs.rendering.shader
// Skip transparent meshes (collision-only meshes)
if material.maps[0].color.a == 0 do continue
rl.DrawMesh(model.meshes[i], material, model.transform)
}
Meshes with zero alpha are collision-only. This is why we passed texelColor.a through in the shader earlier.
This is used to make staircases into something more like ramps for smooth movement.
Delete the old BeginShaderMode/EndShaderMode block that drew scene objects, and remove the static geometry and walkable surface debug drawing loops.
New Physics
This is the big one. We're replacing the old OBB-based collision system with sphere-vs-triangle-mesh collisions against the loaded model.
Remove the following from physics.odin:
Planestructcircle_vs_rect_responsedo_obb_collisions_xy,do_obb_collisions_xz,do_obb_collisions_zyphysics_calculate_next_positionsample_ground_heightplane_computecheck_segment_plane
We keep
check_sphere_vs_obb3,check_sphere_vs_aabb, andcheck_point_vs_aabbas they are still used elsewhere.
Lower MAX_STEP_HEIGHT from 1.0 to 0.3.
The new physics_update is straightforward. It just calls the new position computation:
physics_update :: proc(ws: ^Scene_State) {
for &entity in ws.entities {
if .Noclip in entity.flags {
entity.position += entity.velocity
} else {
entity.position = physics_compute_next_position(ws, &entity)
}
}
}
physics_compute_next_position works in two passes against every triangle in the model:
Pass 1 - Wall collisions: For each triangle, skip ground-like surfaces (normal dot up > 0.7). For remaining triangles, find the closest point on the triangle to the entity. If it's within the collider radius, push the entity out.
Pass 2 - Ground snapping: Raycast straight down from the entity position. Find the closest ground-like triangle hit within MAX_STEP_HEIGHT * 2 + collider_radius. Snap the entity's Y to that hit point. If no ground is found, reject the movement entirely.
physics_compute_next_position :: proc(ss: ^Scene_State, entity: ^Entity) -> Vec3 {
gs := cast(^Game_State)context.user_ptr
new_position := entity.position + entity.velocity
new_position.y += MAX_STEP_HEIGHT
model := &gs.assets.models[gs.scene.scene_name]
for _ in 0 ..< MAX_COLLISION_PASSES {
for i in 0 ..< model.meshCount {
mesh := &model.meshes[i]
vertices := cast([^]Vec3)mesh.vertices
for j in 0 ..< mesh.triangleCount {
index_a := mesh.indices[j * 3]
index_b := mesh.indices[j * 3 + 1]
index_c := mesh.indices[j * 3 + 2]
a := vertices[index_a]
b := vertices[index_b]
c := vertices[index_c]
normal := rl.Vector3Normalize(rl.Vector3CrossProduct(b - a, c - a))
if rl.Vector3DotProduct(normal, Vec3{0, 1, 0}) > 0.7 {
continue
}
closest := physics_closest_point_point_triangle(new_position, a, b, c)
diff := new_position - closest
dist := rl.Vector3Length(diff)
if dist < entity.collider_radius {
normal := rl.Vector3Normalize(diff)
corrected := closest + normal * entity.collider_radius
if corrected.y >= new_position.y {
new_position = corrected
} else {
new_position.x = corrected.x
new_position.z = corrected.z
}
}
}
}
}
ray_origin := new_position
ray_direction := Vec3{0, -1, 0}
ray_length := MAX_STEP_HEIGHT * 2 + entity.collider_radius
best_hit_distance := max(f32)
best_hit_point: Vec3
did_hit_ground := false
for i in 0 ..< model.meshCount {
mesh := &model.meshes[i]
vertices := cast([^]Vec3)mesh.vertices
for j in 0 ..< mesh.triangleCount {
index_a := mesh.indices[j * 3]
index_b := mesh.indices[j * 3 + 1]
index_c := mesh.indices[j * 3 + 2]
a := vertices[index_a]
b := vertices[index_b]
c := vertices[index_c]
hit := rl.GetRayCollisionTriangle(
{position = ray_origin, direction = ray_direction},
a, b, c,
)
if hit.hit && hit.distance < ray_length && hit.distance < best_hit_distance {
angle := rl.Vector3DotProduct(hit.normal, Vec3{0, 1, 0})
if angle > 0.7 {
best_hit_distance = hit.distance
best_hit_point = hit.point
did_hit_ground = true
}
}
}
}
if did_hit_ground {
new_position.y = best_hit_point.y + entity.collider_radius
} else {
new_position = entity.position
}
return new_position
}
The closest-point-on-triangle helper is straight from Real-Time Collision Detection:
physics_closest_point_point_triangle :: proc(p, a, b, c: Vec3) -> Vec3 {
dot := rl.Vector3DotProduct
cross := rl.Vector3CrossProduct
ab := b - a
ac := c - a
bc := c - b
snom := dot(p - a, ab)
sdenom := dot(p - b, a - b)
tnom := dot(p - a, ac)
tdenom := dot(p - c, a - c)
if snom <= 0 && tnom <= 0 do return a
unom := dot(p - b, bc)
udenom := dot(p - c, b - c)
if sdenom <= 0 && unom <= 0 do return b
if tdenom <= 0 && udenom <= 0 do return c
n := cross(b - a, c - a)
vc := dot(n, cross(a - p, b - p))
if vc <= 0 && snom >= 0 && sdenom >= 0 {
return a + snom / (snom + sdenom) * ab
}
va := dot(n, cross(b - p, c - p))
if va <= 0 && unom >= 0 && udenom >= 0 {
return b + unom / (unom + udenom) * bc
}
vb := dot(n, cross(c - p, a - p))
if vb <= 0 && tnom >= 0 && tdenom >= 0 {
return a + tnom / (tnom + tdenom) * ac
}
u := va / (va + vb + vc)
v := vb / (va + vb + vc)
w := 1 - u - v
return u * a + v * b + w * c
}