This project implements ragdoll physics in 3D using Westlake Engine. It features simple rigid body collision detection and resolution, along with a skeletal system based on node hierarchy. The ragdoll structure includes constraints that can limit both the distance and angles between paired nodes, creating realistic physical interactions and movement.
This project features:
Verlet velocity integration (a variation of the Verlet integration) is a numerical method used to integrate Newton's equations of motion. It's particularly useful in physics simulations and games for calculating the position and velocity of objects over time.
Key advantages of Verlet integration:
More stable than simple Euler integration
Better energy conservation
Particularly good for simulating constrained systems like cloth or rope
Well-suited for real-time physics simulations
When solving one iteration, each time step begins by calculating all forces (like gravity and other interactions) acting on each object. Using these forces, the system updates both the position and rotation of objects through velocity verlet integration, which also determines their velocities for the next frame. Once this calculation is complete, all force values are cleared to prepare for the next time step. Finally, the system applies any constraints to ensure objects maintain their required relationships or limitations.
Each Node in the system inherits from the GameObject base class (which provides common attributes like position and velocity). A key feature of this Node structure is its parent-child relationship system - each Node maintains a link to its parent and stores its position as a relative offset from that parent, rather than as an absolute position. When the system needs to create a ragdoll, it applies a 4x4 transformation matrix to calculate the actual world positions by converting these relative offsets into absolute coordinates.
The humanoid ragdoll structure follows a hierarchical chain of body parts, starting with the pelvis as the root node. From there, the spine is constructed with multiple segments leading upward. The head connects at the top of the spine, while shoulder joints branch out to create the arm chains. Leg chains extend downward from the pelvis. Additional detail can be achieved by including extremities like hands and feet as end nodes in their respective chains.
The system primarily uses pin constraints to connect body parts. Pin constraints combine two key elements: distance constraints (keeping body parts at proper lengths from each other) and angle constraints (controlling joint rotation). This choice is well-suited for modeling human body physics since actual human joints can move in multiple directions rather than being limited to a single axis of rotation. The pin constraint achieves this flexibility by using quaternions to handle rotation across all three axes.
However, there is one limitation: since the pin constraint treats connected body parts as separate entities joined at a point, rather than as a continuous structure, this can sometimes allow other objects to pass through the small gap between constrained bodies - something that wouldn't happen with a real human body.
The purpose of a distance constraint is to maintain a specific target length (L) between two rigid bodies. Each pin constraint stores an anchor point, typically located either within or on the surface of each rigid body. When either rigid body moves, the constraint system works to preserve this predefined distance between their anchor points.
To determine how much force (impulse magnitude - j) should be applied to maintain the constraint, we use a formula from David Baraff's paper on Rigid Body Simulation:
The angle constraint controls how much one rigid body can rotate in relation to another. Specifically, we want the child node's rotation to follow its parent's rotation while staying within defined limits. To achieve this, we calculate the difference between the child and parent rotations (δq) and ensure it stays within the allowed range - between the minimum (q min) and maximum (q max) rotation limits set for the child node.
By using Slerp (Spherical Linear Interpolation) to rotate the child angle, we can control the speed of the angle correction by adjusting the delta angle per FixedUpdate. This flexibility allows us to:
Use a smaller deltaAngle to create a slower rotation, giving the ragdoll a more squishy, flexible feel
Use a larger deltaAngle for faster rotation correction, making the constraint feel more rigid and stiff
This simple adjustment lets us fine-tune how "loose" or "tight" the joint constraints feel in the ragdoll simulation.
The ragdoll system uses two types of primitive shapes for its nodes:
Spheres
Capsules
Based on collision detection methods from Christer Ericson's book, the system can detect interactions between the ragdoll and various primitive collision shapes:
AABB (Axis-Aligned Bounding Box)
OBB (Oriented Bounding Box)
Spheres
Capsules
This variety of supported collision shapes allows the ragdoll to interact realistically with different types of geometry in the environment.
To determine the appropriate collision response, we first check if the objects are in a resting state by comparing their relative velocity magnitude to a resting threshold. If the relative velocity is below this threshold, we handle it as a resting contact. Otherwise, for dynamic collisions, we calculate the impulse force magnitude (j) which determines how the normal force will affect both linear and angular velocities of the objects.
When objects come into contact while at rest, we want to handle their interaction smoothly rather than creating sudden movements. Instead of directly modifying their velocities and rotations, we apply a pushing force that prevents objects from overlapping. This force gently separates the objects, maintaining a more stable and realistic physical simulation.
And finally, to make our simulation more realistic, we incorporate friction. By computing the friction forces, we gradually decrease both the linear and angular velocities of the objects over time:
The performance challenge with ragdoll physics comes from the intensive collision detection calculations. Each ragdoll contains 18 nodes and requires multiple layers of collision checks: internal collisions between its own nodes, collisions with static objects in the scene, and potential collisions with other ragdolls. These calculations involve nested loops, which become computationally expensive and can prevent the system from maintaining 60 frames per second when multiple ragdolls are present.
However, the inefficiency stems from performing collision checks even when ragdolls are far apart and unlikely to collide. This is where an octree data structure provides an elegant solution. While not the most efficient spatial partitioning method, octrees are straightforward to understand and implement. They effectively divide the space to reduce unnecessary collision checks, allowing the system to handle a significantly larger number of ragdolls without compromising performance.
An octree organizes 3D space by recursively dividing it into eight equal-sized axis-aligned bounding boxes (AABB). Each of these subdivisions, or nodes, maintains a list of physics objects that fall within its boundaries. The placement of objects in the octree follows specific rules:
If a physics object fits completely within one of the eight subdivisions, it gets assigned to that child node.
If an object straddles multiple subdivisions, it gets stored in the parent node instead of being split across children.
Objects that are too large to fit in any single subdivision are stored in the parent node. If they're too large even for the parent, this process continues upward.
At the very top of the tree is the root node, which encompasses the entire space. Any objects that don't fully fit within any lower subdivisions are stored here.
Following this approach, we can construct octree structures recursively, placing physics objects into their appropriate subdivisions as we go deeper into each child.
When physics objects move, we need to keep the octree structure up-to-date. While we could rebuild the entire tree each frame (which isn't computationally expensive), it's inefficient since most branches remain unchanged. Instead, we can optimize this process:
We maintain a list of objects that are in motion
For each moving object, we perform these steps every frame:
Remove it from the current octree's object list
Check if it still fits within its current octree node
If it doesn't fit, move it up to the parent node
Use the Insert() function to place it back in the correct position
The Insert() function works by:
Examining each child of the current octree node
If any child node completely contains the object, place it there
If no child can fully contain it, keep it in the current node
This approach is more efficient as it only updates the parts of the tree that need to change, rather than rebuilding everything.
When objects are moved around, we need to check if each octree node should be kept after the update. Empty octree nodes (those without objects or child nodes) are inefficient since they cause unnecessary recursive calls. To handle this, we implement a timer system with specific conditions - if a node has no children, contains no objects, and its timer has expired, we remove it from the tree structure.
The project uses an octree data structure primarily for collision detection between two types of objects: nodes (which are moving objects) and either fixed objects or other nodes. Since every collision must involve at least one node, we can organize collision detection by focusing on node interactions. Each node has specific functions to handle collisions with different types of shapes.
Starting from the root octree node, the Update() function begins checking for intersections by calling GetIntersection(). This function recursively examines child nodes, with each octree node only comparing objects in its own space and its parent's space. By organizing objects spatially this way, distant objects (like those in top-right versus bottom-left regions) are never compared, which greatly improves performance.
Once all child nodes have reported their collisions, the root octree compiles a comprehensive list of Collision Records. The root then iterates through this list to resolve each collision that was detected.