Issue with collisions using Three.js and Ammo.js

I decided to start learning JavaScript with what I thought would be an easy project (in the past I have experience with AS2&3 some HTML, PHP and UE blueprints). I am attemting to make a basic 3d dice roller, however, no matter what method I use for the collisions my object seems to ignore them and begin falling through the ground plane until it collides with what looks like the pivot point of my mesh, then it pivots and dances around this point in the mesh.

I originally thought this must have been an issue with glb export so have attempted again with an FBX file. I have applied a red wire material to see what the collisions are doing and they look correct to me. And have made the mesh have 0.5 alpha to ensure there is nothing hidden in the file causing the problem.

I have tried using a custom collision mesh as well as the Ammo collisions but this is just not behaving correctly and I cannot see where the issue is… I will share my entire script in the hope that it is maybe a typo or something somewhere that I am missing, or maybe an ordering problem. You can see the issue here https://streamable.com/uisoub

import * as THREE from 'https://cdn.skypack.dev/three@0.128.0';
import { FBXLoader } from 'https://cdn.skypack.dev/three@0.128.0/examples/jsm/loaders/FBXLoader.js';

document.addEventListener("DOMContentLoaded", function() {
    const rollDiceButton = document.getElementById("rollDiceButton");
    const rewardElement = document.getElementById("reward");

    // Disable the roll dice button initially
    rollDiceButton.disabled = true;

    // Three.js setup
    const canvas = document.getElementById("canvas");
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer({ canvas: canvas });
    renderer.setSize(canvas.clientWidth, canvas.clientHeight);

    // Lighting
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    scene.add(ambientLight);

    const pointLight = new THREE.PointLight(0xffffff, 1);
    pointLight.position.set(5, 5, 5);
    scene.add(pointLight);

    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
    directionalLight.position.set(-5, 5, 5);
    scene.add(directionalLight);

    // Ground plane
    const groundGeometry = new THREE.PlaneGeometry(20, 20);
    const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5, metalness: 0.5 });
    const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
    groundMesh.rotation.x = -Math.PI / 2;
    groundMesh.position.y = -0.5; // Ensure the ground plane is at the correct height
    scene.add(groundMesh);

    // Ammo.js setup
    let physicsWorld;
    let transformAux1;
    const rigidBodies = [];
    const diceModels = [];
    const diceBodies = [];

    // Initialize Ammo.js physics world
    const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
    const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
    const broadphase = new Ammo.btDbvtBroadphase();
    const solver = new Ammo.btSequentialImpulseConstraintSolver();
    physicsWorld = new Ammo.btDiscreteDynamicsWorld(dispatcher, broadphase, solver, collisionConfiguration);
    physicsWorld.setGravity(new Ammo.btVector3(0, -9.82, 0));
    transformAux1 = new Ammo.btTransform();

    // Create ground body
    const groundShape = new Ammo.btBoxShape(new Ammo.btVector3(10, 0.5, 10));
    const groundTransform = new Ammo.btTransform();
    groundTransform.setIdentity();
    groundTransform.setOrigin(new Ammo.btVector3(0, -0.5, 0));
    const groundMass = 0;
    const groundLocalInertia = new Ammo.btVector3(0, 0, 0);
    const groundMotionState = new Ammo.btDefaultMotionState(groundTransform);
    const groundRbInfo = new Ammo.btRigidBodyConstructionInfo(groundMass, groundMotionState, groundShape, groundLocalInertia);
    const groundBody = new Ammo.btRigidBody(groundRbInfo);
    physicsWorld.addRigidBody(groundBody);

    // Load models and set up dice
    const loader = new FBXLoader();
    const diceFiles = { D4: '../meshes/D4.fbx' };

    const loadModel = (key, value) => {
        return new Promise((resolve, reject) => {
            loader.load(value, function(object) {
                const scale = 0.1; // Set the scale to 0.1
                object.scale.set(scale, scale, scale);
                let visualMesh = null;

                // Traverse the model to find the visual mesh
                object.traverse(function(child) {
                    if (child.isMesh && !child.name.startsWith('UCX_')) {
                        visualMesh = child;
                        child.material = new THREE.MeshStandardMaterial({
                            color: child.material.color,
                            roughness: 0.5,
                            metalness: 0.5,
                            transparent: true, // Enable transparency
                            opacity: 0.5 // Set opacity to 50%
                        });
                        console.log(`Visual mesh found for ${key}:`, visualMesh);
                    }
                });

                if (visualMesh) {
                    diceModels[key] = { visual: visualMesh };
                    resolve();
                } else {
                    reject(new Error(`Could not find visual mesh for ${key}`));
                }
            }, undefined, function(error) {
                reject(error);
            });
        });
    };

    const loadAllModels = () => {
        const promises = [];
        for (const [key, value] of Object.entries(diceFiles)) {
            promises.push(loadModel(key, value));
        }
        return Promise.all(promises);
    };

    loadAllModels().then(() => {
        // Enable the roll dice button after all models are loaded
        rollDiceButton.disabled = false;
        rollDiceButton.addEventListener("click", rollDice);

        camera.position.set(0, 5, 15);
        camera.lookAt(0, 0, 0);

        function createConvexHullShape(model) {
            const vertices = [];
            const matrixWorld = new THREE.Matrix4();
            model.updateMatrixWorld(true);
            model.traverse(function(child) {
                if (child.isMesh) {
                    const geometry = child.geometry;
                    const position = geometry.attributes.position;
                    for (let i = 0; i < position.count; i++) {
                        const vertex = new THREE.Vector3(
                            position.getX(i),
                            position.getY(i),
                            position.getZ(i)
                        );
                        vertex.applyMatrix4(child.matrixWorld);
                        vertices.push(new Ammo.btVector3(vertex.x, vertex.y, vertex.z));
                    }
                }
            });

            const shape = new Ammo.btConvexHullShape();
            vertices.forEach(vertex => {
                shape.addPoint(vertex, true);
            });

            return shape;
        }

        function removePreviousDice() {
            // Remove dice models from the scene
            diceModels.forEach(model => {
                scene.remove(model.visual);
            });
            diceModels.length = 0; // Clear the array

            // Remove physics bodies from the physics world
            diceBodies.forEach(body => {
                physicsWorld.removeRigidBody(body);
            });
            diceBodies.length = 0; // Clear the array
        }

        function rollDice() {
            // Remove previous dice
            removePreviousDice();

            const dice = [
                { sides: 4, value: 0, model: diceModels.D4, body: diceBodies.D4 }
            ];
            let total = 0;

            // Calculate spacing based on the bounding box of one of the dice
            const boundingBox = new THREE.Box3().setFromObject(diceModels.D4.visual);
            const size = new THREE.Vector3();
            boundingBox.getSize(size);
            const spacing = size.x * 1.5; // Add some extra space to avoid overlap
            const initialPosition = -((dice.length - 1) * spacing) / 2; // Center the dice

            dice.forEach((die, index) => {
                die.value = Math.floor(Math.random() * die.sides) + 1;
                total += die.value;

                if (die.model) {
                    const xPos = initialPosition + index * spacing;
                    die.model.visual.position.set(xPos, 5, 0); // Position dice in a line
                    die.model.visual.scale.set(0.1, 0.1, 0.1); // Ensure the model is scaled appropriately

                    // Apply random rotation to the visual model
                    const randomRotation = new THREE.Euler(
                        Math.random() * 2 * Math.PI,
                        Math.random() * 2 * Math.PI,
                        Math.random() * 2 * Math.PI
                    );
                    die.model.visual.rotation.copy(randomRotation);
                    scene.add(die.model.visual);

                    // Create a corresponding physics body using the visual mesh
                    const shape = createConvexHullShape(die.model.visual);

                    // Scale the collision shape to match the visual mesh
                    const scale = die.model.visual.scale;
                    shape.setLocalScaling(new Ammo.btVector3(scale.x, scale.y, scale.z));

                    const transform = new Ammo.btTransform();
                    transform.setIdentity();
                    transform.setOrigin(new Ammo.btVector3(xPos, 5, 0));

                    // Apply the same random rotation to the physics body
                    const quaternion = new THREE.Quaternion().setFromEuler(randomRotation);
                    transform.setRotation(new Ammo.btQuaternion(quaternion.x, quaternion.y, quaternion.z, quaternion.w));

                    const mass = 1;
                    const localInertia = new Ammo.btVector3(0, 0, 0);
                    shape.calculateLocalInertia(mass, localInertia);

                    const motionState = new Ammo.btDefaultMotionState(transform);
                    const rbInfo = new Ammo.btRigidBodyConstructionInfo(mass, motionState, shape, localInertia);
                    const body = new Ammo.btRigidBody(rbInfo);
                    physicsWorld.addRigidBody(body);

                    // Set friction and restitution
                    body.setFriction(0.5);
                    body.setRestitution(0.6);

                    die.body = body;
                    die.model.visual.userData.physicsBody = body; // Set userData.physicsBody

                    // Store references for removal
                    rigidBodies.push(die.model.visual);
                    diceModels.push(die.model);
                    diceBodies.push(die.body);

                    // Create and add the wireframe
                    const wireframeMaterial = new THREE.LineBasicMaterial({ color: 0xff0000 });
                    const wireframeGeometry = new THREE.WireframeGeometry(die.model.visual.geometry);
                    const wireframe = new THREE.LineSegments(wireframeGeometry, wireframeMaterial);
                    die.model.visual.add(wireframe);
                } else {
                    console.warn(`Model for ${die.sides}-sided die not loaded yet`);
                }
            });

            const reward = total * 6;
            rewardElement.textContent = reward;
        }

        function animate() {
            requestAnimationFrame(animate);

            // Step the physics world
            physicsWorld.stepSimulation(1 / 60, 10);

            // Update the positions of the dice models based on the physics bodies
            rigidBodies.forEach((model) => {
                const body = model.userData.physicsBody;
                if (body) {
                    const motionState = body.getMotionState();
                    if (motionState) {
                        motionState.getWorldTransform(transformAux1);
                        const p = transformAux1.getOrigin();
                        const q = transformAux1.getRotation();
                        model.position.set(p.x(), p.y(), p.z());
                        model.quaternion.set(q.x(), q.y(), q.z(), q.w());
                    }
                }
            });

            renderer.render(scene, camera);
        }

        animate();
    }).catch(error => {
        console.error("Error loading models:", error);
    });
});

Looks like what i’d expect to happen if my object were a singularity in the centroid of the object… You sure your dice is a dice? (Are you sure it’s not rendering a scale 1 dice, but calculating everything for a 0.1 scale dice? Or rendering an 0.1 scale, but calculating for a 0.01 (0.1*0.1) scale?)

Good thought, I adapted the rollDice function to use the scale variable to ensure that both the visual model and the collision shape are scaled consistently. I have also tested the model and collision objects by spawning them in different locations. But still no luck

If you set that to 1,1,1 instead of scale.x, scale.y, and scale.z, does anything change? (it should, even if it’s not correct)

It appears to fall completely through the ground plane but does not continue to fall. it is sitting below the surface

… it sounds like its colliding with the bottom of the plane instead of the top. Odd.

That or it thinks your dice is inside out.

2 Likes

I’ll check the normal directions and report back. thanks for the help

After checking the normals, the ground normals where fine but I could not get it to show the dice normals no matter what I tried, there was issues detecting the verts… I scrapped my custom model and used three.js to construct a Tetrahedron and now it is working as expected.

I’m still wondering specifically what is happening on the export of my model files. I checked the 3D files and the normals where fine. the mesh is solid with no holes. everything was triangulated and it had UV mapping and a basic phong material applied.

I’ll have to look into this again in the future to see if I can work out what was happeing.

Thanks for your help though, without your suggestion about the vertex normals I would not have come to this conclusion.

2 Likes