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);
});
});