DEV Community

Cover image for Collision detection in ThreeJs made easy using BVH
bandinopla
bandinopla

Posted on

Collision detection in ThreeJs made easy using BVH

So you just downloaded a very cool level model that you want to test with your character and walk on it like in a game without feeling like a ghost that can pass trough geometry. Is there an easy way to do this without having to spend hours reading documentation? Yes there is! 

And that's exactly what I did when I coded my "Silent Hill Demo" . I found the school level in Sketchfab and Sherry's model from Resident evil and wanted to combine the two. For the collision detection ( with the floor, walls, tables, doors, etc... ) I went the three-mesh-bvh route.

What is BHV?

I always think that real world analogies are the best way to understand things, realistic scenarios, so, with that goal in mind, imagine Ana de Armas is celebrating her birthday, and since you are dating her, she obviously invites you over, but somehow Sydney Sweeney finds her way into the party, and she is angry with Ana because she has a crush on you and can't have you since you are already taken, so she decides to get close to the birtday cake and spits on it. Now, how can you know where the spit is on the cake so you avoid eating the entire thing just to find the portion with her spit? Eating the entire cake would be ludacris, so you visually see the grid formed by the slices and you only eat the slice with Sydney Sweeney's spit on it. This optimization allows you to skip the entire cake and just focus on the important part. That's what BVH does. It slices and picks one chunk only.

screenshot

Colliding by shooting rays

Tipically to get your character to "hit the floor" you would "shoot a ray" from the character's origin in the downward direction, and detect if the floor mesh ( or any other solid ) was hit, in which case you will position the character at that position, giving the illusion of a solid object.

When you shoot a ray to detect collisions, every ray cast into the scene would need to be tested against every single polygon. This brute-force approach quickly becomes impractical because the number of intersection tests grows linearly with the number of polygons.

Ok, but... What the hell is BVH?

It is a technique, called Bounding Volume Hierarchy ( BVH ), a way to sort and group faces in a spatial data structure, used in computer graphics, physics engines, and ray tracing to accelerate intersection tests between rays and complex geometry. "accelerate" as in: not wasting time.

Instead of checking every polygon in a scene individually (which would be very slow), BVH organizes geometry into a tree of nested bounding volumes ( boxes... ) that enclose groups of polygons.

Green bounding boxes dividing the level in segments

In the image above, for example, sherry's raycast hit going downward will only analize the polygons under her and will ignore the objects and walls and any other area outside of that room.

example showing a level mesh subdivided in boundig boxes

The image above is from the official demo, you can easily see how this BVH mechanism subdivides a level.

Ok fine, how do I implement BVH in my game/app ?

First you have to understand that this technique requires sorting and grouping polygons that are meant to exist in a common mesh. So we will need to create a new mesh that will combine all the polygons in it to allow for this method to work. It will only be used for collision detection, it won't be visible ( unless you are debugging in which case you may chose to make this collider visible )

The setup

Install

npm install three-mesh-bvh
Enter fullscreen mode Exit fullscreen mode

Create the Level Collider

Collect all static meshes

During your level/world initialization is usually where you will do this. We need to create a mesh that represents all the static objects in the scene, everything that should be considered a collidable thing. Scan your scene looking for objects that are meant to be static / collidables.

const staticMeshes: THREE.Mesh[] = []; // we collect all "static" meshes

myLevel.traverse( child =>{ 
    if (child instanceof THREE.Mesh) {
        // check if this mesh should be considered static... 
        // this is up to you then...
        if( child.name.includes("static") ) // let's say we test this...
        {
            staticMeshes.push( child ); // ok, it is a static collider...
        }
    }
})
Enter fullscreen mode Exit fullscreen mode

Create the level mesh collider

Once we have our static objects we are ready to create the "collider" ( the mesh that will be used to implement BVH ) It will combine all the static meshes into one big mesh.

This is done in 4 steps:

  1. Extract the geometries from the static meshes we collected earlier
  2. merge them into one single geometry
  3. prepare the geometry ( optimize that geometry for bvh to work efficiently )
  4. Generate the BVH geometry ( the object we will use to test collisions )
import * as THREE from 'three';
import { MeshBVH, MeshBVHHelper, StaticGeometryGenerator } from "three-mesh-bvh"
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
//...

const environment = new THREE.Group();
const visualGeometries: THREE.BufferGeometry[] = [];

//
// 1) extract the geometries from all the static meshes
//
staticMeshes.forEach(mesh => {
    const geom = mesh.geometry.clone();

    //
    // Because each mesh’s geometry is defined in its local space, 
    // but when you want to merge them into a single geometry 
    // you need all their vertices in the same world space.
    //
    geom.applyMatrix4(mesh.matrixWorld);
    visualGeometries.push(geom);
});

//
// 2) Merge the geometries into one
//
const colliderGeometry = BufferGeometryUtils.mergeGeometries(visualGeometries);
const colliderMesh = new THREE.Mesh( colliderGeometry ); 

environment.add( colliderMesh );

//
// 3) Prepare the data to be generated ( optimized version )
//
const staticGenerator = new StaticGeometryGenerator(environment);

// Tell it to only keep vertex positions (no normals, uvs, etc.) since those aren’t needed for collision/raycast checks.
staticGenerator.attributes = ['position'];


//
// 4) Generate the BVH geometry
//  
const mergedGeometry = staticGenerator.generate();
mergedGeometry.boundsTree = new MeshBVH(mergedGeometry);

//
// that's it!
//
const collider = new THREE.Mesh( mergedGeometry, 

    // this material is only added as a way for you to debug. In case you want to see the mesh that was generated that will be used for testing collisions.
    new THREE.MeshBasicMaterial({ wireframe: true, opacity: .1, transparent: true })
);

Enter fullscreen mode Exit fullscreen mode

things you may be wondering:

  • staticGenerator.generate() → produces a plain merged BufferGeometry ( just vertices/indices in world space ). At this point it’s just data, nothing “smart.”
  • new MeshBVH(mergedGeometry) → analyzes that geometry and builds the acceleration structure. Assigning it to mergedGeometry.boundsTree is how three-mesh-bvh hooks into Three.js’s raycasting system.

Create the player collider

Now, the player and every entity you want to move and collide in this world will need also to have a setup like this.

Usually, to detect collision, the math is done using simple shapes. For characters, a Capsule is usually used because:

  • Smooth Movement Over Uneven Surfaces: The rounded bottom of the capsule allows the character to slide smoothly over small bumps, cracks, and up gentle slopes without getting stuck. A box collider, by contrast, has sharp corners that would catch on these small geometric details, leading to jerky, unnatural movement.
  • Prevents Snagging on Corners: When moving past a corner or through a doorway, the rounded sides of a capsule allow it to slide along the wall smoothly. A box collider would often snag its corner on the wall's corner, abruptly stopping the player's movement.
  • Good Approximation of a Character: For most upright characters ( like people ), a capsule is a much better and more realistic approximation of their overall shape than a simple sphere or a box. It's tall and rounded, just like a person's general silhouette.

So for that reason, we first need to attatch the information of the player capsule shape to the player:

// you have to adjust this depending on your model's dimensions...  
myPlayer.userData.capsuleInfo = {
        radius: 0.5,
        segment: new THREE.Line3( 
            new THREE.Vector3(), 
            new THREE.Vector3( 0, - 1.0, 0.0 ) 
        )
    }
Enter fullscreen mode Exit fullscreen mode

Colliding

We have a mesh collider and our players have each their capsule info set ( their collision shape ) To start checking for collisions we have to do a few things.

This will usually happen in your update loop ( if you have constant forces like gravity always pulling entities down ) or anytime you want to move an entity.

But let's say is that time in your code where you want to test for collisions. You will have to do this:

1) Initialization and Space Transformation

The first step is to set up the necessary variables and transform the player's collision capsule into the local space of the collider object.

// tempXXXX are top-level or global variables used to be re-used...

const capsuleInfo = myPlayer.userData.capsuleInfo; // or the current entity being tested... 

// tempMat = THREE.Matrix4()
// `collider.matrixWorld` represents the collider's position and orientation in the WORLD. By inverting it we get a matrix that can transform coordinates from world space into the collider's LOCAL space
tempMat.copy( collider.matrixWorld ).invert();

tempSegment.copy( capsuleInfo.segment );

// tempSegment = THREE.Line3()
// get the position of the capsule in the local space of the collider
tempSegment.start.applyMatrix4( myPlayer.matrixWorld ).applyMatrix4( tempMat );
tempSegment.end.applyMatrix4( myPlayer.matrixWorld ).applyMatrix4( tempMat );
Enter fullscreen mode Exit fullscreen mode

2) Creating a Bounding Box

Next, an Axis-Aligned Bounding Box (AABB) is created that fully encloses the player's capsule. This box serves as a quick, initial check; if this box doesn't intersect with a part of the environment, then the more complex capsule shape can't either.

// tempBox = new THREE.Box3();
tempBox.makeEmpty();

// get the axis aligned bounding box of the capsule
tempBox.expandByPoint( tempSegment.start );
tempBox.expandByPoint( tempSegment.end );

tempBox.min.addScalar( - capsuleInfo.radius );
tempBox.max.addScalar( capsuleInfo.radius );
Enter fullscreen mode Exit fullscreen mode

The box is first expanded to contain the capsule's central line segment. Then, its minimum and maximum corners are pushed outwards by the capsule's radius, ensuring the entire volume of the capsule is contained within the box.

3) The BVH Shapecast

This is the core of the collision detection process. The shapecast function from three-mesh-bvh efficiently checks the capsule's bounding box against the collider's BVH tree. It consists of 2 phases:

  1. the "broad phase" : It performs a fast box-to-box intersection test. If the player's bounding box doesn't intersect the BVH node's box, the function returns false, and the entire branch of the geometry tree is skipped, saving immense computation.
  2. the "narrow phase." : If intersectsBounds returns true for a leaf node, this function is called for each triangle within that node. It performs the precise, more expensive check to see if the player's capsule actually touches the triangle.
collider.geometry.boundsTree.shapecast( { 
    intersectsBounds: box => box.intersectsBox( tempBox ), //<-- tempBox is the bounding box containing our player/entity 

    intersectsTriangle: tri => { 
        // (*)
    }

} );
Enter fullscreen mode Exit fullscreen mode

4) Collision Resolution Inside intersectsTriangle

When a triangle is found to be intersecting the capsule, the code must calculate how to move the player to resolve the collision.

// (*)
const triPoint = tempVector; // re-using vector: this will be filled by closestPointToSegment
const capsulePoint = tempVector2; // re-using vector: this will be filled by closestPointToSegment
// distance = shortest distance between the triangle (tri) and the capsule's central line
const distance = tri.closestPointToSegment( tempSegment, triPoint, capsulePoint );
if ( distance < capsuleInfo.radius ) {

    // depth = how far the capsule has penetrated the triangle?
    const depth = capsuleInfo.radius - distance;

    // direction = the direction the player needs to be pushed to resolve the collision
    const direction = capsulePoint.sub( triPoint ).normalize();

    //
    // push the player/entity away from the collision point...
    //
    tempSegment.start.addScaledVector( direction, depth );
    tempSegment.end.addScaledVector( direction, depth );
}

Enter fullscreen mode Exit fullscreen mode

5) Applying the Correction and Updating Player State

After the shapecast has checked all potential triangles and adjusted the tempSegment, the final corrected position must be applied back to the actual player object in world space.

// tempVector & tempVector2 = new THREE.Vector3() 
const newPosition = tempVector;
newPosition.copy( tempSegment.start ).applyMatrix4( collider.matrixWorld ); // local to world...

// the total displacement from the player's original position to the new, collision-free position.
const deltaVector = tempVector2;
deltaVector.subVectors( newPosition, player.position );

// apply the push... 
player.position.add( deltaVector ); // here we are assuming player is in world's space.
Enter fullscreen mode Exit fullscreen mode

6) Optional: Ground Check and Velocity Adjustment

if the player was primarily adjusted vertically we assume it's on something we should consider ground...


    //how much the character’s vertical coordinate has actually changed compared to how much it should have changed if the character were still in free-fall for the same length of time?
    // The 0.25 is just a small fudge factor so we don’t demand an exact match—floating-point noise, terrain bumps, or physics solver tolerances make a perfect 0 unrealistic.
    playerIsOnGround = deltaVector.y > Math.abs( delta * playerVelocity.y * 0.25 );

// “zero-threshold re-normalization” trick.
// the code says: “Keep the intended direction, but throw away any sub-millimetre leftovers that could cause numerical trouble.”
    const offset = Math.max( 0.0, deltaVector.length() - 1e-5 );
    deltaVector.normalize().multiplyScalar( offset );

    // adjust the player model
    player.position.add( deltaVector );

    if ( ! playerIsOnGround ) {

        // using the direction of the corrected displacement (deltaVector) as the contact-normal:
        deltaVector.normalize();

        // Whatever kept me from moving further during this frame—floor, wall, or slope—take the velocity that was trying to carry me through it and strip that component away.
        playerVelocity.addScaledVector( deltaVector, - deltaVector.dot( playerVelocity ) );

    } else {

        playerVelocity.set( 0, 0, 0 );

    }
Enter fullscreen mode Exit fullscreen mode

So that's it! We corrected the position and the velocity of the player/entity to respond to the level collider.

Now go on and start using BVH!

Top comments (0)