Week 3: Collision

Today's Lab: Jetpack Maze

Check-in: Playtesting

Get into groups of three and play each other's text-based games! Think about these things while you play:

  1. Form a group DM on Slack and send your game:
    • Delete the target/ folder
      • Cargo.toml
      • src
      • target XXXXXX
      • … other files
    • Zip up what's left
    • Send it on Slack
    • Unzip and cargo run
  2. Take turns where two people play through the third person's game for a few minutes.
  3. Players should talk through their thoughts while they play. The game maker should only answer direct questions and not volunteer anything.
  4. Since this is a game engines class and not a game design class, while you're playing focus especially on how things might be implemented. Do you see any surprising tricks or technical features?
  5. After playing, the player can reflect on their experiences and ask questions of the game maker, and the game maker can ask the players why they did certain things or any other things the game maker wants to know.

Rotate through so everyone gets a chance to have their game played.

Collision Detection

One of the foundational laws of video games (at least action games) is "when things touch, stuff can happen". It's helpful to distinguish between these two parts—"are things touching?" versus "make things happen". We also might want to know not only whether things are touching, but a vector indicating where and by how much the contact is occurring. Broadly, this falls under the umbrella of collision detection.

Collision Shapes

Game objects come in different shapes and sizes. The most important thing to remember is that the pixels making up a game character aren't all necessarily part of its collision boundary:

2020-11-19_16-24-59_464aaf.jpg
2020-11-19_16-25-24_16489704227_f5ac977318.jpg

Often, a complex character is broken down into several simple shapes. Common common candidates include:

  • Points (lots of games used this)
  • Line segments or rays
  • Circles
  • Axis-Aligned Bounding Boxes (a position and dimensions)
  • Unaligned Bounding Boxes (a position, a direction, and dimensions)

But not all collision shapes are equivalent! It may be that some enemy should collide with the player but not with walls, or that the player should sometimes be able to swim through water and sometimes be able to walk on top of it. So collision shapes also tend to have some metadata: whether the object behind it is fixed in place or can move when bumped, whether it is solid or is just used to detect trigger conditions like the player entering a certain area. It's also worth noting that collision edges can have directions—sometimes you can jump up through something but not fall down through it.

Computational Geometry

Let's do a quick refresher on computational geometry.

Vectors

  • Points and vectors are technically different
    • But we'll treat them the same
  • A vector can be defined with components, e.g. \(x\) and \(y\)
  • Vectors are displacements between points
    • i.e., the difference between two points is a vector
    • A point is like a vector starting from (0,0)
  • We can get the vector perpendicular (or normal) to a vector by swapping its components and negating \(x\)

Axes and coordinate systems

  • In 2D we work in two major axes: x (horizontal) and y (vertical).
    • We usually use a space where downwards in y is positive, but this is an arbitrary choice.
  • Points are defined relative to an origin or basis.
    • Usually we use \((0,0)\) — 0 in x and y.
  • Points can also define their own basis!
    • \((3,0)\) with respect to \((1,1)\) is \((4,1)\)
  • Translations (offset point \(p\) by vector \(v\)) are the only things we can do to points.
  • Vectors are also coordinate pairs, but they mean something different
    • A direction and a magnitude
    • Or equivalently, a magnitude in x and a magnitude in y
  • We can move a vector's end point to adjust its direction and magnitude (moving the tip of the arrow)
    • This is actually a kind of scaling
  • If we have a point and a scalar (e.g., a circle), we can both translate and scale
  • If we have a point and a vector (e.g., an ellipse or a rectangle), we can both translate and scale
  • When we have multiple coordinate spaces, we can convert between them using transformations by defining one in terms of the other
    • Look at a nearby wall with a window on it.
    • The top left of the wall anchors one coordinate space.
    • The top left of the window anchors another.
    • If we measured from the right edge to the left edge of the window, we'd have a distance \(w\)
      • The scale of the two spaces is the same, so the distance is the same in both spaces
    • In the window's coordinate space, \((0,w)\) is the top right corner of the window
    • In the wall's coordinate space, \((0,w)\) is along the top edge of the wall and might not be anywhere near the window!
      • We would need to measure both the horizontal and vertical distance from the top left of the wall to the top right of the window to obtain that point.
      • Or, if we knew where the window's top left corner \((x,y)\) was with respect to the wall, we could just add that to \((0,w)\) to get \((x,y+w)\)
      • Does this sound familiar? We did stuff like this last time for bitblt
    • We'll talk a lot more about coordinate spaces in the 3D unit.

Dot Product or Scalar Product \(A \dot B\)

  • How far in the direction of B is A?
    • Or in math-ese, "what is the magnitude of the projection of A onto B"?
  • \(A \dot B = A_x B_x + A_y B_y\)
  • Also \(\lVert A \rVert \lVert B \rVert \cos \theta\), where θ is the angle between the two vectors.
  • Also called the inner product

Polygons

  • Closed sequences of edges between points (vertices)
    • Can be clockwise or counterclockwise, but be consistent!
  • We'll focus on convex polygons
    • Polygons have an outside and an inside, and between any two inside points there are only inside points.
    • Intuition: no holes inside, wouldn't work as a bowl no matter how you spin it
  • Remember for later: you can build any polygon (convex or concave) out of triangles!

Collision

We'll work from these two interlocking assumptions to simplify our code:

  1. Objects move slowly relative to their size and the size of obstacles.
  2. Objects move instaneously from their position at one frame to their position in the next frame.

These mean that we can check whether collisions are happening after objects have moved, and just push the objects back a bit if they end up interpenetrating other objects at the end of the frame.

But these assumptions are not universally true! A projectile might move very quickly, and if the wall it's heading towards is narrower than the distance the projectile covers in a frame it will tunnel right through. Techniques like swept collision and smaller physics timesteps can mitigate these types of issues, but ultimately any numeric simulation will have instabilities and edge cases (analytic physics based on solving equations of motion can be perfectly precise, but prone instead to Zeno conditions and unpredictable execution time).

So at a high level our code will look like this:

  1. See how much time has elapsed since the last frame.
    • If we have not saved up 1/60 a second, go to sleep or spin until then
    • We'll revisit this in the 3D unit
  2. Move objects
  3. Detect collisions
  4. Handle collisions
  5. Wait for the next frame, draw when we're asked to

Moving objects, for now, will just be pos = pos + velocity. We'll fix our timestep to 60fps and set our velocity constants with respect to that frame rate. We handled drawing last time, so let's dig in to collisions.

Today's Skeleton

cargo new --bin --edition 2018 collision2d

[package]
name = "collision2d"
version = "0.1.0"
authors = ["Joseph C. Osborn <joseph.osborn@pomona.edu>"]
edition = "2018"

[dependencies]
pixels = "0.2.0"
winit = "0.22.0"
winit_input_helper = "0.6.0"
use pixels::{Pixels, SurfaceTexture};
use std::time::Instant;
use winit::dpi::PhysicalSize;
use winit::event::{Event, VirtualKeyCode};
use winit::event_loop::{ControlFlow, EventLoop};
use winit::window::WindowBuilder;
use winit_input_helper::WinitInputHelper;

// seconds per frame
const DT:f64 = 1.0/60.0;

const DEPTH: usize = 4;
const WIDTH: usize = 320;
const HEIGHT: usize = 240;
const PITCH: usize = WIDTH * DEPTH;

// We'll make our Color type an RGBA8888 pixel.
type Color = [u8; DEPTH];

const CLEAR_COL: Color = [32, 32, 64, 255];
const WALL_COL: Color = [200, 200, 200, 255];
const PLAYER_COL: Color = [255, 128, 128, 255];

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Rect {
    x: i32,
    y: i32,
    w: u16,
    h: u16,
}

struct Wall {
    rect: Rect,
}

struct Mobile {
    rect: Rect,
    vx: i32,
    vy: i32,
}

// pixels gives us an rgba8888 framebuffer
fn clear(fb: &mut [u8], c: Color) {
    // Four bytes per pixel; chunks_exact_mut gives an iterator over 4-element slices.
    // So this way we can use copy_from_slice to copy our color slice into px very quickly.
    for px in fb.chunks_exact_mut(4) {
        px.copy_from_slice(&c);
    }
}

#[allow(dead_code)]
fn rect(fb: &mut [u8], r: Rect, c: Color) {
    assert!(r.x < WIDTH as i32);
    assert!(r.y < HEIGHT as i32);
    // NOTE, very fragile! will break for out of bounds rects!  See next week for the fix.
    let x1 = (r.x + r.w as i32).min(WIDTH as i32) as usize;
    let y1 = (r.y + r.h as i32).min(HEIGHT as i32) as usize;
    for row in fb[(r.y as usize * PITCH)..(y1 * PITCH)].chunks_exact_mut(PITCH) {
        for p in row[(r.x as usize * DEPTH)..(x1 * DEPTH)].chunks_exact_mut(DEPTH) {
            p.copy_from_slice(&c);
        }
    }
}

fn main() {
    let event_loop = EventLoop::new();
    let mut input = WinitInputHelper::new();
    let window = {
        let size = PhysicalSize::new(WIDTH as f64, HEIGHT as f64);
        WindowBuilder::new()
            .with_title("Collision2D")
            .with_inner_size(size)
            .with_min_inner_size(size)
            .with_resizable(false)
            .build(&event_loop)
            .unwrap()
    };
    let mut pixels = {
        let window_size = window.inner_size();
        let surface_texture = SurfaceTexture::new(window_size.width, window_size.height, &window);
        Pixels::new(WIDTH as u32, HEIGHT as u32, surface_texture).unwrap()
    };
    let mut player = Mobile {
        rect: Rect {
            x: 32,
            y: HEIGHT as i32 - 16 - 8,
            w: 8,
            h: 8,
        },
        vx: 0,
        vy: 0,
    };
    let walls = [
        Wall {
            rect: Rect {
                x: 0,
                y: 0,
                w: WIDTH as u16,
                h: 16,
            },
        },
        Wall {
            rect: Rect {
                x: 0,
                y: 0,
                w: 16,
                h: HEIGHT as u16,
            },
        },
        Wall {
            rect: Rect {
                x: WIDTH as i32 - 16,
                y: 0,
                w: 16,
                h: HEIGHT as u16,
            },
        },
        Wall {
            rect: Rect {
                x: 0,
                y: HEIGHT as i32 - 16,
                w: WIDTH as u16,
                h: 16,
            },
        },
        Wall {
            rect: Rect {
                x: WIDTH as i32/2 - 16,
                y: HEIGHT as i32/2 - 16,
                w: 32,
                h: 32,
            },
        },
    ];
    // How many frames have we simulated?
    let mut frame_count:usize = 0;
    // How many unsimulated frames have we saved up?
    let mut available_time = 0.0;
    // Track beginning of play
    let start = Instant::now();
    let mut contacts = vec![];
    let mut mobiles = [player];
    // Track end of the last frame
    let mut since = Instant::now();
    event_loop.run(move |event, _, control_flow| {
        // Draw the current frame
        if let Event::RedrawRequested(_) = event {
            let fb = pixels.get_frame();
            clear(fb, CLEAR_COL);
            // Draw the walls
            for w in walls.iter() {
                rect(fb, w.rect, WALL_COL);
            }
            // Draw the player
            rect(fb, mobiles[0].rect, PLAYER_COL);
            // Flip buffers
            if pixels.render().is_err() {
                *control_flow = ControlFlow::Exit;
                return;
            }

            // The renderer "produces" time...
            available_time += since.elapsed().as_secs_f64();
            since = Instant::now();
        }
        // Handle input events
        if input.update(event) {
            // Close events
            if input.key_pressed(VirtualKeyCode::Escape) || input.quit() {
                *control_flow = ControlFlow::Exit;
                return;
            }
            // Resize the window if needed
            if let Some(size) = input.window_resized() {
                pixels.resize(size.width, size.height);
            }
        }
        // And the simulation "consumes" it
        while available_time >= DT {
            let player = &mut mobiles[0];
            // Eat up one frame worth of time
            available_time -= DT;

            // Player control goes here; determine player acceleration

            // Determine player velocity

            // Update player position

            // Detect collisions: Generate contacts
            contacts.clear();
            gather_contacts(&walls, &mobiles, &mut contacts);

            // Handle collisions: Apply restitution impulses.
            restitute(&walls, &mut mobiles, &mut contacts);

            // Update game rules: What happens when the player touches things?

            // Increment the frame counter
            frame_count += 1;
        };
        // Request redraw
        window.request_redraw();
    });
}

Separating Axis Theorem

We'll draw from a couple of good explanations for this section. The text here will be brief so please refer to those sources for detailed accounts.

There are lots of ways to determine whether particular combinations of shapes overlap. For example:

  • A point touches a circle if the point is within radius of the circle's center
  • Two circles touch if their centers are within the sums of their radii
  • Two line segments intersect if we can find a point (x,y) that is on their corresponding lines and within the segments' start and end points
  • A line \((p_1,p_2)\) touches a circle at \(c\) with radius \(r\) if the circle is not further than \(r\) from the line (check the dot product of the circle's center with the vector orthogonal to the line) and the dot product of the vector from \(p_1\) to \(c\) is between 0 and the length of the line (i.e., that the closest point on the circle to the line segment lies between the endpoints of the line segment)

We can extend the point and line checks to polygons by thinking of the polygon's edges as half-planes. A point is inside a polygon if it's inside every half-plane, and a line touches a polygon if either endpoint is inside or if the line segment intersects any edge. But what about polygon versus polygon collisions? We could check whether any edge of one intersects any edge of the other, or whether any vertex of one is inside the other, but this is a lot of work. Can we do better?

Yes! Intuitively, we can say that two polygons intersect if we can't draw a line that would separate them. So if one is inside the other then we certainly can't, or if they're touching then again we can't find an axis along which they could be split apart. Imagine that you have a flashlight and you can walk around the perimeter of the two polygons—if the light ever shines all the way through to the other side, there must be a gap between them. This claim is called the separating axis theorem.

How do we find a separating axis? We know that if a polygon is intersecting another polygon, it must be overlapping on every possible axis. But there are an infinite number of half-planes we could construct, so we certainly can't test every axis. It turns out that we only need to look at axes which are normal to the edges of one or the other polygon. Then, we find the interval that each polygon occupies along each axis (projecting each polygon's points onto the axis and taking the min and max of those projections), and if there's any axis where these intervals don't overlap we don't have a collision.

Let's make this concrete with an example of two rectangles.

Special Case: Two Axis-Aligned Rectangles

With two axis-aligned rectangles we have two axes to test along: \(x\) and \(y\). \(x\) because each rectangle has vertical sides (we might sneak through by the vertical edge) and \(y\) because each rectangle has horizontal sides. We don't need to consider repeated axes (e.g. those due to the normal of the top edges of each rectangle) or even pairs of parallel axes (e.g. the top and bottom edge of a single rectangle), so this takes us from eight to two axes. More complex combinations of shapes may need to consider every axis in the worst case, but adding up the number of edges in the two polygons and multiplying by the sum of the number of vertices is still better than multiplying the edges of the two polygons and adding the product of the vertices of each by the edges of the other, so we come out way ahead in the end.

Now that we know the two axes worth checking, we have to determine whether either is a separating axis—an axis out of which we could draw a line to separate the two rectangles. Given a candidate separating axis, we can only draw such a line if the two rectangles don't overlap along this axis. If the axis is \(x\), then it's a separating axis if the rectangles don't overlap in \(x\) (e.g. one's right edge is to the left of the other). If neither axis is a separating axis, then the rectangles overlap. Luckily, axis-aligned rectangles make the projections easy to compute! The projection of the rectangle onto the \(x\) axis is just its leftmost and rightmost extents, and its projection onto the \(y\) axis is its upper and lower edges. So our test becomes:

fn rect_touching(r1:Rect, r2:Rect) -> bool {
    // r1 left is left of r2 right
    r1.x <= r2.x+r2.w as i32 &&
        // r2 left is left of r1 right
        r2.x <= r1.x+r1.w as i32 &&
        // those two conditions handle the x axis overlap;
        // the next two do the same for the y axis:
        r1.y <= r2.y+r2.h as i32 &&
        r2.y <= r1.y+r1.h as i32
}

The nice thing about using SAT for collision detection is that when we compute for each axis whether it's a separating axis we also compute a vector—the overlap between the objects along this axis! It's really valuable to know for a collision contact which displacement would push the objects apart, and SAT gives us the displacement along each axis.

fn rect_displacement(r1:Rect, r2:Rect) -> Option<(i32,i32)> {
    // Draw this out on paper to double check, but these quantities
    // will both be positive exactly when the conditions in rect_touching are true.
    let x_overlap = (r1.x+r1.w as i32).min(r2.x+r2.w as i32) - r1.x.max(r2.x);
    let y_overlap = (r1.y+r1.h as i32).min(r2.y+r2.h as i32) - r1.y.max(r2.y);
    if x_overlap >= 0 && y_overlap >= 0 {
        // This will return the magnitude of overlap in each axis.
        Some((x_overlap, y_overlap))
    } else {
        None
    }
}

This gives us a specialized version of SAT for axis aligned rectangles—so, no need for dot products to get the projections. If these were arbitrary edges, we'd want to project vertices over the normals to the axes but we'd do the same types of checks between the bounds of the respective intervals formed by the least and greatest projected points of each polygon, and we'd calculate the overlap in the same type of way.

If you like, you could throw this into impl Rect.

This is enough collision detection fundamentals for now; in your games you could approximate characters as multiple boundary points or you could model them with AABBs. As for what to do with the displacement vector, we'll come to that later.

Warm-Up

Phew! Let's take a coding break. Put together a quick program where you control the movement of a square in four directions with the arrow keys. Start from the skeleton given above, and add the ColliderID and Contact types and the gather_contacts and restitute functions from later in the lecture notes. You can add player control at the "player control goes here" comment and use input.key_held from the WinitInputHelper struct along with the keyboard keys defined in the VirtualKeyCode enum.

The game you're making also needs a floor and ceiling (two wide stationary rectangles), two walls (tall stationary rectangles), and a platform (a flat, wide rectangle elevated above the floor). We won't introduce gravity yet (you'd fall right through!), but we will have physics: your character will accelerate at a constant rate depending on which directions are held down. If neither direction of an axis is held down, apply a braking acceleration in that axis. This could go in at the "determine player velocity" comment.

Print out a distinct message when the square you control is touching a wall, floor, or platform. You could do this by (for example) adding data to the Wall struct.

You can do this exercise in groups.

You may need to add these functions and types to the skeleton.

#[derive(PartialEq, Eq, Clone, Copy, Debug)]
enum ColliderID {
    Static(usize),
    Dynamic(usize)
}

#[derive(PartialEq, Eq, Clone, Copy, Debug)]
struct Contact {
    a:ColliderID,
    b:ColliderID,
    mtv:(i32,i32)
}

// Here we will be using push() on into, so it can't be a slice
fn gather_contacts(statics: &[Wall], dynamics:&[Mobile], into:&mut Vec<Contact>) {
    // collide mobiles against mobiles (beware, here be dragons)
    for (ai,a) in dynamics.iter().enumerate() {
        for (bi,b) in dynamics.iter().enumerate().skip(ai+1) {
            // ...
        }
    }
    // collide mobiles against walls
    for (ai,a) in dynamics.iter().enumerate() {
        for (bi,b) in statics.iter().enumerate() {
            // ...
        }
    }
}

fn restitute(statics: &[Wall], dynamics:&mut [Mobile], contacts:&mut [Contact]) {
    // You can ignore implementing this for now!  Or maybe put your print statements in here.

    // handle restitution of dynamics against dynamics and dynamics against statics wrt contacts.
    // You could instead make contacts `Vec<Contact>` if you think you might remove contacts.
    // You could also add an additional parameter, a slice or vec representing how far we've displaced each dynamic, to avoid allocations if you track a vec of how far things have been moved.
    // You might also want to pass in another &mut Vec<Contact> to be filled in with "real" touches that actually happened.
    contacts.sort_unstable_by_key(|c| -(c.mtv.0*c.mtv.0+c.mtv.1*c.mtv.1));
    // Keep going!  Note that you can assume every contact has a dynamic object in .a.
    // You might decide to tweak the interface of this function to separately take dynamic-static and dynamic-dynamic contacts, to avoid a branch inside of the response calculation.
    // Or, you might decide to calculate signed mtvs taking direction into account instead of the unsigned displacements from rect_displacement up above.  Or calculate one MTV per involved entity, then apply displacements to both objects during restitution (sorting by the max or the sum of their magnitudes)
}

Contacts

We often want to keep track of what has touched what, so we can do things like make Pong balls bounce or squish enemies in Mario. In order to avoid weird situations where handling one collision causes later objects to sometimes collide and sometimes not collide, we use collision detection to generate contacts which we later process. Another benefit of doing things this way is that we can let the computer repeat the same type of calculation (hopefully even the same exact code) over and over again, which makes better use of caches and pipelining.

A contact should store the two colliding bodies (we'll use usize indices into a Vec<Collider>) and the amount by which they're overlapping. Other information might be stored there as well.

In a real game, we have more than two boxes. We can find which things are touching which other things by checking pairs of shapes:

let mut contacts = vec![];
for (i, body_i) in colliders.iter().enumerate() {
    for (j, body_j) in colliders.iter().enumerate().skip(i+1) {
        let displacement = match (body_i.shape, body_j.shape) {
            (Shape::Circle(_), Shape::Point) => point_circle_disp(body_j, body_i),
            (Shape::Point, Shape::Circle(_)) => point_circle_disp(body_i, body_j),
            //... a dozen more cases...
            (Shape::Poly(_), Shape::Poly(_)) => sat(body_i, body_j)
        };
        if let Some(disp) = displacement {
            contacts.push(Contact(i, j, disp));
        }
    }
}

Note that we use skip to avoid half of the possible collision checks. Note also that this kind of stinks due to all those match statements! We could shove the logic for picking the right collision function into the Shape enum using double dispatch (e.g. if self is a point, call other.collide_point(self), which itself matches on self (previously known as other) to select the right function), and while that would fix the problem for human readers it doesn't change much for the computer. If we decided to use virtual functions (in Rust world, trait objects) we'd have to pay even greater costs.

So instead we'll group shapes together. Instead of one loop over a collection of every shape, we'll have one collection per shape type and check each against the others.

let mut contacts = vec![];
for (i, pi) in points.iter().enumerate() {
    for (j, cj) in circles.iter().enumerate() {
        if let Some(disp) = point_circle_disp(pi, cj) {
            // Whoops, Contact now tracks the collections the contact bodies came from as well.
            // Instead of body indices we may prefer to track game object IDs and forget the specific collider shapes involved.
            contacts.push(Contact(&points, i, &circles, j, disp));
        }
    }
}
for (i, ci) in circles.iter().enumerate() {
    for (j, cj) in circles.iter().enumerate().skip(i+1) {
        // same deal here, circle vs circle
    }
}
// More loops like those

Is it more code? Undoubtedly! But it's much more predictable and less branchy.

But we have another way to both reduce branching and improve legibility: only support collision checking between axis-aligned rectangles—AABBs. The lesson here is to structure your code using all the knowledge you have at hand, and not make the computer do extra work because you haven't prepared the data the way it likes (e.g. demanding more flexibility than you actually make use of); or even better, to take on reasonable design restrictions to avoid the need for flexibility at all!

Broad vs Narrow Phase

No matter what tricks we use to manage the flow of data through collision checks, we're still looking at comparing all pairs of objects in the scene---\(O(n^2)\) collision checks every frame!

But this is a bit silly, isn't it? At the scale of the whole screen or the whole level, very few things are colliding with each other; either the things are different static parts of the game world or they're so far away from each other they can't possibly be interacting. We can split collision detection into a distinct broad phase and narrow phase, where the broad phase focuses on quickly finding groups of objects that might collide and the narrow phase does \(n^2\) precise checks—hopefully on as small an \(n\) as possible!

Spatial Partitioning

Most techniques for broad phase collision detection use some form of spatial partitioning datastructure, where objects are somehow binned according to their relative locations (or sometimes absolute locations, as in locality sensitive hashing or by recording objects' memberships in a coarse grid superimposed on the game world).

One particularly common technique is based around tree partitioning (e.g. BSP trees, quadtrees, octrees). In these approaches, we represent space as a tree (with some branching factor) whose nodes hold buckets of objects of some maximum capacity (a non-leaf node's objects will be those that overlap two or more of its child nodes). Because we know the tree splits space according to some rule, we can find the buckets and therefore the objects nearest to a particular point in logarithmic time, rather than by iterating through the whole list of objects. As objects move we can rebalance the tree, or we can just recreate the tree from scratch every so often. Besides querying for the objects within a volume, we can use the tree structure to guide our collision detection: to generate contacts for a node we generate contacts within its objects and between its objects and those of its children. We still can't avoid \(O(n^2)\) collision detection, but at least we can keep \(n\) very low in most cases.

Spatial partitioning data structures are also really useful to determine what we need to actually draw on screen, especially in 3D: we shouldn't spend valuable time drawing objects that are off-screen or behind other opaque objects!

Data Parallelism

If we already know that objects in cells of unrelated spatial partitions won't collide with each other, then there's no reason to process all the objects in the first cell before moving on to those of the second. So we can process all the cells in parallel, taking advantage of our preponderance of CPU cores to generate contacts even faster. In Rust, the rayon crate implements a work-stealing queue which can be a good fit for this kind of data parallelism, especially since less-populous tree nodes will take less time to process than more densely populated ones.

Part of why we set up contact generation as a distinct step from collision resolution is to enable this kind of order-independent and concurrent processing. Especially since the era of the PlayStation 3, taking full advantage of data parallelism has been vital for achieving the performance targets set by recent trends in game design.

Collision Response

Now that we know what's touching what, we can do something about it. To recap something mentioned earlier, we have a few alternative approaches to making virtual objects collide with each other:

  1. Assuming that things are moving slowly relative to their sizes, allow for small interpenetrations and correct them either with immediate position updates or impulses.
  2. If we notice an interpenetration would occur due to a movement, instead simulate forward with a smaller timestep; repeat until no interpenetrations occur or the movements are too small.
  3. Solve for the times when objects will come into contact, and just integrate normally until that exact moment.

We're going with (1) here, but it's not the only choice; there are also in-between moves, like making four small "physics time steps" per game time step. Another option is to only move objects a very small amount at a time, stopping if they encounter any obstacle. But option (1) is a good trade-off between correctness, simplicity, and speed—although it can lead to some weird bugs if we're not careful!

Since we're committed to (1) at this point, it's time to handle that second bit—correcting the interpenetrations that do occur.

Restitution

Even if it's moving very slowly, a video game object that bumps into a wall is most likely, at the moment it bumps into the wall, actually partially inside of that wall. Because of our assumption that objects are moving slowly, we can further assume we can always find a good displacement that would cause the collision to cease happening. This is often called the minimum translation vector since it's the least difficult way to move the objects out of interpenetration. In this example it's the blue arrow:

interpenetration.png

In this case, the whole MTV gets assigned to the moving object rather than, say, half to the object and half to the wall. If both parties in the collision were movable, then we might displace each by half (in opposite directions) or by share the vector based on the relative momentum of the two objects.

No matter how you decide on the displacements, if you ever displace opposite to the character's velocity in a component, you should probably force that velocity to reflect the displacement somehow (for example, if a character bumps the ceiling its y velocity should be set to 0).

Sometimes, we might be simultaneously experiencing two collisions, and they have contradictory resolutions. Imagine the following scene, with a character moving upwards and rightwards at a 45 degree angle. They'll end up inside of both the wall and the closed door:

multiple-contacts.png

Collision against the closed door is mostly on the character's top edge, while collision against the wall is mostly on the character's right edge. So what do we do? If we process horizontal collisions first, then vertical ones, we'll bump the player out to the left and then bump them downwards—even though they're not colliding with the door after restitution! If we just picked the bigger of the restitutions, then if we were up in the top-right corner we'd find ourselves starting to move through either the door or the top wall. I've only shown the minimal component of the MTVs \(a\) and \(b\) here, but remember those are both vectors pointing roughly from the invading object's leading corner or edge towards the point where it entered the wall/door. (Our displacement vector from SAT before wasn't yet an MTV—we hadn't yet picked the best direction to separate the objects. Luckily, we can compare the relative positions of the objects' centers to obtain the right signs to put on the components of our displacement vector. You can do this in e.g. rect_displacement or when computing the collision response.)

There are a variety of tricks one can use to mitigate this type of bug in different settings, and the true answer will involve solving systems of linear equations to find the optimal impulses that will displace the objects correctly, then updating the positions using a differential equation solver. For now, in the 2D, AABB setting, we'll solve it by asking which collision happened first and trying to restitute our way out of that one.

In the example above, the wall collision happens before the door collision since in some sense we're already touching the wall. At any rate, more of the "mass" of the contact is in the wall than in the door, so the wall-player displacement is larger than the door-player displacement (even though both are their contacts' respective MTVs). So we can sort the contacts by decreasing magnitude (or squared magnitude) of their MTVs, and process the bigger contacts first. Note that we've thrown all the contacts into one big list above (or e.g. one per broad-phase partition unit), so if we can continue processing big lists of contacts in uniform ways our instruction cache, data cache, and load predictor will thank us. If, on the other hand, we need to handle different sorts of contacts differently, we should either separate them out or change the processing so that we can handle them uniformly.

Sorting the contacts tells us which ones to handle first, but it doesn't on its own fix the double-restitution issue. For that, we have two options after we move the objects out of collision when handling a particular contact:

  1. Record, for each object, how much it has been restituted already; we can do this by altering the MTV of later contacts involving this object after the collision response is applied, or by storing an auxiliary data structure.
    • If this change to the MTV causes it to cross zero in either dimension, then we know the objects aren't touching anymore! If it exactly hits zero then they're perfectly lined up.
  2. Right before we handle a contact, check to be sure the objects involved are still actually touching and if so use the new MTV. This does extra work, so beware!

No matter which approach we use here, it's always possible that restituting one collision will cause another one to happen. This is unavoidable if we're just looking at one contact at a time rather than solving the entire system of equations simultaneously. But that's a lot of heavy machinery to bring in right now, and we can avoid the need for it with careful game design for now. Also note that if our character from the example above were represented as just four corner pixels, we could write more specialized code: If the northeast and southeast pixels are both colliding, but the northwest and southwest ones are not, just restitute westward; If only the northeast pixel is colliding, we could just restitute downward; and so on.

Note that not all collision contacts will be important for restitution. You might consider having two classes of colliders: solid objects and trigger volumes. It's vital to generate contacts against triggers, but they shouldn't cause the object touching them to move around. It's up to you whether to combine both types of collision checks into one phase, or whether to process collisions with triggers separately from collisions with regular bodies.

Whatever techniques you use, at this point you have a world with (hopefully) no interpenetrations and rich information about which objects actually touched which other objects in the scene. It's time to make a videogame!

To extend the warm-up from before, try implementing and using these two functions:

#[derive(PartialEq, Eq, Clone, Copy, Debug)]
enum ColliderID {
    Static(usize),
    Dynamic(usize)
}

#[derive(PartialEq, Eq, Clone, Copy, Debug)]
struct Contact {
    a:ColliderID,
    b:ColliderID,
    mtv:(i32,i32)
}

// Here we will be using push() on into, so it can't be a slice
fn gather_contacts(statics: &[Wall], dynamics:&[Mobile], into:&mut Vec<Contact>) {
    // collide mobiles against mobiles
    for (ai,a) in dynamics.iter().enumerate() {
        for (bi,b) in dynamics.iter().enumerate().skip(ai+1) {
            // ...
        }
    }
    // collide mobiles against walls
    for (ai,a) in dynamics.iter().enumerate() {
        for (bi,b) in statics.iter().enumerate() {
            // ...
        }
    }
}

fn restitute(statics: &[Wall], dynamics:&mut [Mobile], contacts:&mut [Contact]) {
    // handle restitution of dynamics against dynamics and dynamics against statics wrt contacts.
    // You could instead make contacts `Vec<Contact>` if you think you might remove contacts.
    // You could also add an additional parameter, a slice or vec representing how far we've displaced each dynamic, to avoid allocations if you track a vec of how far things have been moved.
    // You might also want to pass in another &mut Vec<Contact> to be filled in with "real" touches that actually happened.
    contacts.sort_unstable_by_key(|c| -(c.mtv.0*c.mtv.0+c.mtv.1*c.mtv.1));
    // Keep going!  Note that you can assume every contact has a dynamic object in .a.
    // You might decide to tweak the interface of this function to separately take dynamic-static and dynamic-dynamic contacts, to avoid a branch inside of the response calculation.
    // Or, you might decide to calculate signed mtvs taking direction into account instead of the unsigned displacements from rect_displacement up above.  Or calculate one MTV per involved entity, then apply displacements to both objects during restitution (sorting by the max or the sum of their magnitudes)
}

Game Mechanics

For game design reasons, it's helpful to know the sides on which an object is touching things. The truest way to know the sides of an AABB which were touching things is to track that information during restitution—in our example above, even though there was a contact with a downwards-pointing MTV, the character did not bump into anything north of it. So, it suffices to record the dimensions in which position offsets were actually applied (and remember, if I moved the player leftwards out of the wall, the player is touched on the right and the wall is touched on the left). You can store this information directly in the contacts, set a bitfield for each agent to indicate which sides it's colliding on, or use some other means to remember what's touching what.

A common thing to do when an object hits a wall or ceiling is to neutralize its velocity in that direction, so it can't build up speed without moving or stick against ceilings when jumping.

As the game programmer, once you know whether the player's feet are on the ground or whether they have hit a spike trap, you can cause any game effect you like to happen. You might do this by walking through the filtered contacts (the ones that actually had an impact on the player) or by examining flags set on the player character or other entities in the game world.

Some other tricks and considerations you might keep in mind:

  1. If an entity is on a moving platform, does it add the platform's velocity to its own or not? What about if it jumps? What about a running jump?
  2. Is there a difference between a continuous contact (e.g., standing on the ground) and an instantaneous one (e.g., touching a fireball or a springboard)?
  3. How can you capture e.g. enemies whose bodies are harmful but whose heads act like platforms you can stand on?
  4. Games commonly have one-way platforms, where you can walk through them in one direction but not the other, or jump up through them but not fall through them (unless perhaps you hold down and press jump). How are a character's feet or sides different from the rest of their body?

I want to emphasize that it's totally okay to model characters as four or six or whatever points, and just check point collisions to determine displacements. The SAT approach presented here is meant to be a precursor to our 3D implementation later on.

Object-Oriented Programming

I didn't talk about it above because we won't be needing it where we're going. I won't get too into it now, but consider that as programmers we want to write general-purpose code that solves the most general problems we need to solve. Asking one object if it's touching a second object is not a very general case in collision detection, but an extremely special case. Really, we have a big soup of things that may or may not be touching, and we need to output data describing what's touching what. It doesn't hurt that this approach doesn't pay for virtual function calls, pointer and reference chasing, and a host of other overheads in pursuit of flexibility along an axis of generality we don't really need (how many fundamentally different collision checkers are we really going to have?).

Testing

Rust has built-in support for unit testing. If you add this to the end of your main.rs, you can run some quick checks against the contact gathering and restitution code using cargo test:

// Items (in this case a module) tagged with cfg(test) are only compiled
// in the test profile (e.g., `cargo test`)
#[cfg(test)]
mod tests {
    // Bring in definitions from above
    use super::*;
    // Project out just the colliders from a slice of contacts
    // This way we can write terse tests without worrying about displacements
    fn contact_pairs(cs: &[Contact]) -> Vec<(ColliderID, ColliderID)> {
        cs.iter().map(|c| (c.a, c.b)).collect()
    }
    const HW:u16 = WIDTH as u16 / 2;
    const HH:u16 = HEIGHT as u16 / 2;
    // Set up a level to use for unit tests.
    // This one is a room with four walls, each of which is split into two halves.  There's also a square in the middle.
    const LEVEL: [Wall; 9] = [
        Wall {
            rect: Rect {
                x: 0,
                y: 0,
                w: HW,
                h: 16,
            },
        },
        Wall {
            rect: Rect {
                x: HW as i32,
                y: 0,
                w: HW,
                h: 16,
            },
        },
        Wall {
            rect: Rect {
                x: 0,
                y: HEIGHT as i32 - 16,
                w: HW,
                h: 16,
            },
        },
        Wall {
            rect: Rect {
                x: HW as i32,
                y: HEIGHT as i32 - 16,
                w: HW,
                h: 16,
            },
        },
        Wall {
            rect: Rect {
                x: 0,
                y: 0,
                w: 16,
                h: HH,
            },
        },
        Wall {
            rect: Rect {
                x: 0,
                y: HH as i32,
                w: 16,
                h: HH,
            },
        },
        Wall {
            rect: Rect {
                x: WIDTH as i32 - 16,
                y: 0,
                w: 16,
                h: HH,
            },
        },
        Wall {
            rect: Rect {
                x: WIDTH as i32 - 16,
                y: HH as i32,
                w: 16,
                h: HH,
            },
        },
        Wall {
            rect: Rect {
                x: HW as i32 - 16,
                y: HH as i32 - 16,
                w: 32,
                h: 32,
            },
        },
    ];
    // Functions annotated `test` are unit test functions and are run automatically
    // during `cargo test`
    #[test]
    fn test_collision_bl_br() {
        let mut player = [Mobile {
            rect: Rect {
                x: 17,
                y: HEIGHT as i32 - 16 - 8 - 1,
                w: 8,
                h: 8,
            },
            vx: 0,
            vy: 0,
        }];
        let mut cs = vec![];
        gather_contacts(&LEVEL, &player, &mut cs);
        assert_eq!(cs, vec![]);
        player[0].rect.x = 16;
        // Slide the player down-rightly across the level
        for _step in 0..((HW-16-8-2)/2) {
            player[0].vx = 2;
            player[0].vy = 2;
            player[0].rect.x += player[0].vx;
            player[0].rect.y += player[0].vy;
            cs.clear();
            gather_contacts(&LEVEL, &player, &mut cs);
            assert_eq!(
                contact_pairs(&cs),
                vec![(ColliderID::Dynamic(0), ColliderID::Static(2))],
                "{:?}",
                player[0].rect
            );
            restitute(&LEVEL, &mut player, &mut cs);
            assert!(player[0].rect.x < HW as i32 - 8);
            assert_eq!(player[0].rect.y, HEIGHT as i32 - 16 - 8);
        }
        assert_eq!(player[0].rect.x, HW as i32 - 8 - 2);
        // For some period they'll be touching both halves of the bottom wall
        for _step in 0..5 {
            player[0].vx = 2;
            player[0].vy = 2;
            let lx = player[0].rect.x;
            player[0].rect.x += player[0].vx;
            player[0].rect.y += player[0].vy;
            cs.clear();
            gather_contacts(&LEVEL, &player, &mut cs);
            assert_eq!(
                contact_pairs(&cs),
                vec![
                    (ColliderID::Dynamic(0), ColliderID::Static(2)),
                    (ColliderID::Dynamic(0), ColliderID::Static(3))
                ],
                "{:?}",
                player[0].rect
            );
            restitute(&LEVEL, &mut player, &mut cs);
            assert!(player[0].rect.x <= HW as i32);
            assert!(lx < player[0].rect.x);
            assert_eq!(player[0].rect.y, HEIGHT as i32 - 16 - 8);
        }
        assert_eq!(player[0].rect.x, HW as i32);
        // Then just the right half
        for _step in 0..((HW-16-8)/2-1) {
            player[0].vx = 2;
            player[0].vy = 2;
            player[0].rect.x += player[0].vx;
            player[0].rect.y += player[0].vy;
            cs.clear();
            gather_contacts(&LEVEL, &player, &mut cs);
            assert_eq!(
                contact_pairs(&cs),
                vec![(ColliderID::Dynamic(0), ColliderID::Static(3))],
                "{:?}",
                player[0].rect
            );
            restitute(&LEVEL, &mut player, &mut cs);
            assert!(player[0].rect.x < WIDTH as i32-16-8);
            assert_eq!(player[0].rect.y, HEIGHT as i32 - 16 - 8);
        }
        // And then the right half as well as the bottom half of the right wall
        player[0].vx = 2;
        player[0].vy = 2;
        player[0].rect.x += player[0].vx;
        player[0].rect.y += player[0].vy;
        cs.clear();
        gather_contacts(&LEVEL, &player, &mut cs);
        assert_eq!(
            contact_pairs(&cs),
            vec![
                (ColliderID::Dynamic(0), ColliderID::Static(3)),
                (ColliderID::Dynamic(0), ColliderID::Static(7)),
            ],
            "{:?}",
            player[0].rect
        );
        restitute(&LEVEL, &mut player, &mut cs);
        assert_eq!(player[0].rect.x, WIDTH as i32 - 16 - 8);
        assert_eq!(player[0].rect.y, HEIGHT as i32 - 16 - 8);
    }
    #[test]
    fn move_up_left() {
        let mut player = [Mobile {
            rect: Rect {
                x: 17,
                y: 17,
                w: 8,
                h: 8,
            },
            vx: -2,
            vy: -2,
        }];
        let mut cs = vec![];
        player[0].rect.x -= 2;
        player[0].rect.y -= 2;
        gather_contacts(&LEVEL, &player, &mut cs);
        assert_eq!(contact_pairs(&cs), vec![(ColliderID::Dynamic(0), ColliderID::Static(0)), (ColliderID::Dynamic(0), ColliderID::Static(4))]);
        restitute(&LEVEL, &mut player, &mut cs);
        assert_eq!(player[0].rect.x, 16);
        assert_eq!(player[0].rect.y, 16);
    }
    // You can add your own unit tests too, for example moving up and
    // right along the right wall or moving around the central square
}

You'll want to debug collision using a combination of fast, strict unit tests like these with some interactive experimentation to figure out if you're missing any edge cases. If you have to test every change interactively it will take forever to get it working.

Game Scenes

The last thing we'll talk about today is a little bit about tools, in particular the humble level editor. We'll use OGMO Editor 3 as an example. The unit of OGMO's discourse is a single level, made up of stackable layers including, among other types, tiles (on a regular grid) and entities (placed freely in space). Whereas we've been using just axis aligned bounding boxes for everything so far, in a couple of weeks we'll talk about tiles in depth. For now, you could try using OGMO to throw together a collection of rectangles (either as entities or as decals) for the lab we're about to do. You can get started with OGMO at its webpage.

OGMO also has some handy support for sharing tilesets and other resources between levels, and an open-source format which you can even read from Rust with the ogmo3 crate.

Lab: Jetpack Maze

Add gravity to your warmup game from earlier today. Get rid of up and down arrow controls, and replace them by holding the space bar to accelerate the player upwards (maybe with a little initial velocity boost) for as long as they have fuel; fuel should run out after about a second or two and be replenished on touching the ground. Construct a maze out of a bunch of rectangles and try to get to the goal zone as fast as you can!

Efficiency tip: You will need to track a Vec of contacts every frame. It would be a good idea to have one vec which you clear and fill up again every frame rather than creating a new Vec every frame.