Tutorial on JavaScript Physics Using AmmoJS and ThreeJS

Hello All

Just wanted to share a tutorial I wrote on medium about JavaScript 3D physics. Its more like an introductory tutorial using Ammo.js and threejs.

I hope someone finds it helpful :smiley:

Intro to JavaScript 3D Physics using Ammo.js and Three.js

Update
Still in the same spirit, a follow up article has been published :grin:

Moving Objects In JavaScript 3D Physics using Ammo.js and Three.js

Another Update
Third article; based on collision detection :wink:

Collision Detection In Javascript 3D Physics using Ammo.js and Three.js

18 Likes

Nice tutorial :grin:

BTW the grammarly extension will help you catch basic grammar mistakes in your article.

4 Likes

Thanks :smile:

1 Like

Updated to include link to follow up article

is ammo.js best physics engine ?

I can’t really say. But I do know that Bullet Physics, it’s parent, is widely used.

@extremety1989

Think of it like this. Ammo.js to, say, Phys.js is what Fighter Jet is to a paper airplane. If you need a fully featured physics engine - there’s just no competition on the market to Ammo at the moment. If you only need a subset of what a physics engine typically is - you can get excellent results with the few other decent engines on the market.

4 Likes

Added a new article on collision detection.

1 Like

Thanks for this tutorial, I appreciate your efforts.

1 Like

This is the best tutorial about Ammo.js for beginners in Ammo.js. I study the Collision Detection lesson. I use TypeScript as a programming language and pure WebGL 1.0 to draw objects:

move-cube

I want to print names of colliding objects as in the lesson. I try to use setUserPointer/getUserPointer.

I created an object to keep a user data:

const userData = { name: name };

I keep this object in a body using setUserPointer:

this.body.setUserPointer(userData);

I try to get these names using getUserPointer and print them. But I get “undefined” instead of names:

function detectCollison(): void
{
    const dispatcher = physicsWorld.getDispatcher();
    const numManifolds = dispatcher.getNumManifolds();

    for (let i = 0; i < numManifolds; i++)
    {
        const contactManifold = dispatcher.getManifoldByIndexInternal(i);
        const body0 = contactManifold.getBody0();
        const body1 = contactManifold.getBody1();

        const p0 = body0.getUserPointer();
        const p1 = body1.getUserPointer();

        console.log("first object: " + (p0 as any).name);
        console.log("second object: " + (p1 as any).name);
    }
}

This is a solution for my problem above:

const userData = { name: name };
(this.body as any).userData = userData;
function detectCollison(): void
{
    const dispatcher = physicsWorld.getDispatcher();
    const numManifolds = dispatcher.getNumManifolds();

    for (let i = 0; i < numManifolds; i++)
    {
        const contactManifold = dispatcher.getManifoldByIndexInternal(i);
        const body0 = contactManifold.getBody0();
        const body1 = contactManifold.getBody1();

        const rb0 = (Ammo as any).castObject( contactManifold.getBody0(), Ammo.btRigidBody );
        const rb1 = (Ammo as any).castObject( contactManifold.getBody1(), Ammo.btRigidBody );

        console.log("first object:", rb0.userData);
        console.log("second object:", rb1.userData);
    }
}
2 Likes

Glad you found a solution. Sorry I wasn’t on time to reply.

1 Like

Hi, thanks for the tutorial links they are great!

I haven’t had the chance to test the code yet. BUT (:grin:) It looks to me as though all transforms are flat. As in they are all tied to the scene/world transform (or rather the .setIdentity() matrix) and not nested children of other Mesh/RigidBody objects. In your example it works fine as the ThreeJS Mesh objects are all children of the Scene.

In the initialisation functions, createBall and createBlock.

let transform = new Ammo.btTransform();
transform.setIdentity();
transform.setOrigin( new Ammo.btVector3( pos.x, pos.y, pos.z ) );
transform.setRotation( new Ammo.btQuaternion( quat.x, quat.y, quat.z, quat.w ) );
let motionState = new Ammo.btDefaultMotionState( transform );

It doesn’t look like parent transforms are factored in.

And neither in the updatePhysics function:

ms.getWorldTransform( tmpTrans );
let p = tmpTrans.getOrigin();
let q = tmpTrans.getRotation();
objThree.position.set( p.x(), p.y(), p.z() );
objThree.quaternion.set( q.x(), q.y(), q.z(), q.w() );

Here the .getWorldTransform is computed, but nested transforms weren’t setup in the createBlock and createBall functions.

Is it the case that for this tutorial we just don’t worry about nested transforms or will this still work?

Is bullet even designed to work this way with nested transforms (Possibly need to use some sort of weld joint instead)?

If so, do you know what the extra steps required would be to include a joint or parent/child relationship?

Thanks again!

1 Like

Hi @Deahgib I’m glad you found the tutorial links great.

The transforms were made flat on purpose to avoid complexities that might obscure the “learning process” .

While updating the transform of mesh objects that are parented you can either have a one to one mapping from physics world to the local space (as portrayed in the tutorials), or you perform further transformation to its parent’s (local) space or to global/world space.

As was noted earlier this was not included to keep the tutorials simple.

I translated an example from the the first tutorial to Cannon-ES:

ThreeJSAndCannonES

Instruction:

  • Install Rollup: npm i -D rollup
  • Use this command to debug: rollup -cmw
  • To release install UglifyJS: npm i uglify-js -g and use this command rollup -c, and next: uglifyjs public/js/bundle.js -o public/js/bundle.min.js

rollup.config.js

export default {
    input: "./src/client/main.js",
    output: {
        file: "public/js/bundle.js"
    }
}

public/index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>Document</title>

    <style>
        html,
        body {
            overflow: hidden;
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }

        #renderCanvas {
            width: 100%;
            height: 100%;
        }
    </style>
</head>

<body>
    <script async src="https://unpkg.com/es-module-shims@1.5.5/dist/es-module-shims.js"></script>

    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.141.0/build/three.module.js",
                "collada-loader": "https://unpkg.com/three@0.141.0/examples/jsm/loaders/ColladaLoader.js",
                "orbit-controls": "https://unpkg.com/three@0.141.0/examples/jsm/controls/OrbitControls.js",
                "cannon-es": "https://cdn.jsdelivr.net/npm/cannon-es@0.19.0/dist/cannon-es.js"
            }
        }
    </script>

    <script type="module" src="js/bundle.js"></script>
</body>

</html>

src/client/main.js

import * as THREE from "three";
import { OrbitControls } from "orbit-controls";
import * as CANNON from "cannon-es";

let scene;
const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -10, 0) });
const rigidBodies = [];

function init()
{
    initScene();
    createGround();
    createBox({ x: 0, y: 5, z: 0.5 });
    createBox({ x: 0.5, y: 2, z: 0 });
}
init();

function createGround()
{
    const pos = { x: 0, y: 0, z: 0 };
    const scale = { x: 50, y: 2, z: 50 };
    const quat = { x: 0, y: 0, z: 0, w: 1 };
    const mass = 0;

    // Three.js section

    const ground = new THREE.Mesh(
        new THREE.BoxBufferGeometry(),
        new THREE.MeshPhongMaterial({ color: 0xa0afa4 }));

    ground.position.set(pos.x, pos.y, pos.z);
    ground.scale.set(scale.x, scale.y, scale.z);

    ground.receiveShadow = true;

    scene.add(ground);

    // Cannon-ES section

    const colShape = new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2));
    const body = new CANNON.Body({ mass: mass });
    body.position.set(pos.x, pos.y, pos.z);
    body.quaternion.set(quat.x, quat.y, quat.z, quat.w);
    body.addShape(colShape);

    world.addBody(body);
}

function createBox(pos)
{
    const scale = { x: 1, y: 1, z: 1 };
    const quat = { x: 0, y: 0, z: 0, w: 1 };
    const mass = 10;

    // Three.js section

    const box = new THREE.Mesh(
        new THREE.BoxGeometry(1, 1, 1),
        new THREE.MeshPhongMaterial({ color: 0xe77732 }));
    scene.add(box);

    box.position.set(pos.x, pos.y, pos.z);
    box.scale.set(scale.x, scale.y, scale.z);

    box.castShadow = true;
    box.receiveShadow = true;

    scene.add(box);

    // Cannon-ES section

    const colShape = new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2));
    const body = new CANNON.Body({ mass: mass });
    body.position.set(pos.x, pos.y, pos.z);
    body.quaternion.set(quat.x, quat.y, quat.z, quat.w);
    body.addShape(colShape);

    world.addBody(body);

    box.userData.physicsBody = body;
    rigidBodies.push(box);
}

function updatePhysics()
{
    world.fixedStep();

    for (let i = 0; i < rigidBodies.length; i++)
    {
        let objThree = rigidBodies[i];
        let objCannon = objThree.userData.physicsBody;

        objThree.position.copy(objCannon.position);
        objThree.quaternion.copy(objCannon.quaternion);
    }
}

function initScene()
{
    // Create the scene
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xbfd1e5);

    // Add hemisphere light
    let hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.1);
    hemiLight.color.setHSL(0.6, 0.6, 0.6);
    hemiLight.groundColor.setHSL(0.1, 1, 0.4);
    hemiLight.position.set(0, 50, 0);
    scene.add(hemiLight);

    // Add directional light
    let dirLight = new THREE.DirectionalLight(0xffffff, 1);
    dirLight.color.setHSL(0.1, 1, 0.95);
    dirLight.position.set(-1, 1.75, 1);
    dirLight.position.multiplyScalar(100);
    scene.add(dirLight);

    dirLight.castShadow = true;

    dirLight.shadow.mapSize.width = 2048;
    dirLight.shadow.mapSize.height = 2048;

    let d = 50;

    dirLight.shadow.camera.left = -d;
    dirLight.shadow.camera.right = d;
    dirLight.shadow.camera.top = d;
    dirLight.shadow.camera.bottom = -d;

    dirLight.shadow.camera.far = 13500;

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setClearColor(0xbfd1e5);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);

    renderer.gammaInput = true;
    renderer.gammaOutput = true;

    renderer.shadowMap.enabled = true;

    const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.01, 1000);
    camera.position.set(2, 4, 7);

    const orbitControls = new OrbitControls(camera, renderer.domElement);
    orbitControls.target = new THREE.Vector3(0, 0, 0);

    (function anim()
    {
        requestAnimationFrame(anim);
        orbitControls.update();
        updatePhysics();
        renderer.render(scene, camera);
    })();

    window.onresize =
        () =>
        {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        };
}
1 Like