PVG
20. Walkable Surfaces From Assets — Program Video Games

20. Walkable Surfaces From Assets

Loading Assets

For now, we're going to load two assets:
assets/scenes/town.glb and assets/scenes/town_surface.glb

The town.glb is the visual representation - what the scene looks like.

The town_surface.glb contains only planes that we can convert to Quads for our walkable_surfaces array.

Init Test Code

To view the town, we'll use a simple shader that we won't go too much into, as it's a temporary drop-in.

This is in main, just after initialising raylib.

// Load town scene for testing
{
    // Create the walkable surfaces

    town_model := rl.LoadModel("assets/scenes/town.glb")
    town_surface := rl.LoadModel("assets/scenes/town_surface.glb")

    surface_quads := quads_from_model(town_surface, context.temp_allocator)
    append(&gs.physics.walkable_surfaces, ..surface_quads)

    rl.UnloadModel(town_surface)

    // A simple shader with directional light

    gs.shader = rl.LoadShader("assets/shaders/dirlight.vert.glsl", "assets/shaders/dirlight.frag.glsl")

    // The shader must be set on the materials in the town model

    for i := 0; i < int(town_model.materialCount); i += 1 {
        town_model.materials[i].shader = gs.shader
    }

    // Update the shader's uniform values with the "sunlight" values

    loc_light_dir := rl.GetShaderLocation(gs.shader, "lightDir")
    loc_light_color := rl.GetShaderLocation(gs.shader, "lightColor")
    loc_ambient := rl.GetShaderLocation(gs.shader, "ambient")

    sun_dir := Vec3{-0.3, -1.0, -0.2}
    sun_color := Vec3{1, 1, 1}
    ambient := Vec3{0.15, 0.15, 0.15}

    rl.SetShaderValue(gs.shader, loc_light_dir, &sun_dir, .VEC3)
    rl.SetShaderValue(gs.shader, loc_light_color, &sun_color, .VEC3)
    rl.SetShaderValue(gs.shader, loc_ambient, &ambient, .VEC3)

    // Update Model storage (will be moved to assets.odin later)

    gs.assets.models[.Town] = town_model
}

Our ground following system worked with the old Euler angle-based quads, but we're now switching to using basis vectors for better mathematical stability. Time to update our ground sampling to work with the new Quad structure.

The Change: Euler Angles to Basis Vectors

Previously, our Quad stored rotation as Euler angles:

// Old structure
Quad :: struct {
    center: Vec3,
    rotation: Vec3,
    size: Vec2,
}

Now it uses basis vectors:

// New structure
Quad :: struct {
    center: Vec3,
    x_basis: Vec3, // Direction along width
    y_basis: Vec3, // Normal direction (up)
    size: Vec2,
}

Updating the Ground Sampling Algorithm

The core ray-plane intersection logic stays the same, but we need to update how we construct the transformation matrices.

Old Matrix Construction (Euler Angles)

// Old way - using Euler rotation
rot_mat := rl.MatrixRotateXYZ(quad.rotation)

New Matrix Construction (Basis Vectors)

// New way - construct matrix from basis vectors
z_basis := rl.Vector3Normalize(rl.Vector3CrossProduct(quad.x_basis, quad.y_basis))
mat := rl.Matrix{
    quad.x_basis.x, quad.y_basis.x, z_basis.x, quad.center.x,
    quad.x_basis.y, quad.y_basis.y, z_basis.y, quad.center.y,
    quad.x_basis.z, quad.y_basis.z, z_basis.z, quad.center.z,
    0, 0, 0, 1,
}

Instead of rotating around axes, we directly build the transformation matrix using the three basis vectors that define the quad's orientation in 3D space.

Updated Coordinate Transformations

The old helper functions world_from_local and local_from_world are replaced with direct matrix operations (in ground sampling proc):

// Transform vertices to world space
a := rl.Vector3Transform(local_a, mat)
b := rl.Vector3Transform(local_b, mat)
c := rl.Vector3Transform(local_c, mat)

// Transform intersection point back to local space for bounds checking
local_q := q - quad.center
inv_mat := rl.MatrixTranspose(mat)
local_q = rl.Vector3Transform(local_q, inv_mat)

Loading Quads from 3D Models

The new system includes geometry extraction from Blender models:

quads_from_model :: proc(model: rl.Model, allocator := context.allocator) -> []Quad {
    quads := make([]Quad, model.meshCount, allocator)

    for i := i32(0); i < model.meshCount; i += 1 {
        vertices := [4]Vec3{
            Vec3{model.meshes[i].vertices[0], model.meshes[i].vertices[1], model.meshes[i].vertices[2]},
            Vec3{model.meshes[i].vertices[3], model.meshes[i].vertices[4], model.meshes[i].vertices[5]},
            Vec3{model.meshes[i].vertices[6], model.meshes[i].vertices[7], model.meshes[i].vertices[8]},
            Vec3{model.meshes[i].vertices[9], model.meshes[i].vertices[10], model.meshes[i].vertices[11]},
        }

        normal := Vec3{model.meshes[i].normals[0], model.meshes[i].normals[1], model.meshes[i].normals[2]}
        center := (vertices[0] + vertices[1] + vertices[2] + vertices[3]) / 4
        x_basis, y_basis := quad_rotation_compute(normal, vertices)
        size := quad_size_compute(vertices)

        quads[i] = Quad{
            center = center,
            size = size,
            x_basis = x_basis,
            y_basis = y_basis,
        }
    }

    return quads
}

This replaces manually defining quads in code with loading them from actual 3D scenes created in modeling software.

The Big Idea

The ground following algorithm remains fundamentally the same - we're still casting rays and finding plane intersections. The only change is using basis vectors instead of Euler angles for more robust mathematical operations.

This update eliminates potential gimbal lock issues and provides more direct control over surface orientation, making the terrain system more reliable for complex geometry.