import * as THREE from "three"; import * as CANNON from "cannon-es"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import dieModelBytes from "./assets/dice/die.glb"; const LIMIT = 252; const TAU = Math.PI * 2; const FACE_NORMALS = { 1: new CANNON.Vec3(0, 0, -1), 2: new CANNON.Vec3(0, -1, 0), 3: new CANNON.Vec3(-1, 0, 0), 4: new CANNON.Vec3(1, 0, 0), 5: new CANNON.Vec3(0, 1, 0), 6: new CANNON.Vec3(0, 0, 1), }; function rollDie() { const bytes = new Uint8Array(1); for (;;) { crypto.getRandomValues(bytes); if (bytes[0] < LIMIT) return (bytes[0] % 6) + 1; } } function randomBetween(min, max) { return min + Math.random() * (max - min); } function randomSigned(min, max) { return (Math.random() < 0.5 ? -1 : 1) * randomBetween(min, max); } function randomQuaternion(target) { const u1 = Math.random(); const u2 = Math.random(); const u3 = Math.random(); const a = Math.sqrt(1 - u1); const b = Math.sqrt(u1); target.set( a * Math.sin(TAU * u2), a * Math.cos(TAU * u2), b * Math.sin(TAU * u3), b * Math.cos(TAU * u3), ); return target; } function restingQuaternion(value, yaw = 0) { const rotations = { 1: [Math.PI / 2, 0, 0], 2: [Math.PI, 0, 0], 3: [0, 0, -Math.PI / 2], 4: [0, 0, Math.PI / 2], 5: [0, 0, 0], 6: [-Math.PI / 2, 0, 0], }; const face = new THREE.Quaternion().setFromEuler( new THREE.Euler(...rotations[value], "XYZ"), ); const aroundUp = new THREE.Quaternion().setFromAxisAngle( new THREE.Vector3(0, 1, 0), yaw, ); return aroundUp.multiply(face); } function upwardFace(body) { let result = 1; let highest = -Infinity; for (const [value, localNormal] of Object.entries(FACE_NORMALS)) { const worldNormal = body.quaternion.vmult(localNormal); if (worldNormal.y > highest) { highest = worldNormal.y; result = Number(value); } } return result; } function modelBuffer() { return dieModelBytes.buffer.slice( dieModelBytes.byteOffset, dieModelBytes.byteOffset + dieModelBytes.byteLength, ); } function makeMaterial(source) { const isPip = /white|enamel/i.test(source?.name || ""); return new THREE.MeshPhysicalMaterial({ name: isPip ? "Ivory pips" : "Red resin", color: isPip ? 0xf8f5ec : 0xc91f2b, roughness: isPip ? 0.38 : 0.27, metalness: 0, clearcoat: isPip ? 0.14 : 0.34, clearcoatRoughness: isPip ? 0.42 : 0.32, }); } function makeShadowTexture() { const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; const context = canvas.getContext("2d"); const gradient = context.createRadialGradient(64, 64, 3, 64, 64, 58); gradient.addColorStop(0, "rgba(0,0,0,.44)"); gradient.addColorStop(0.42, "rgba(0,0,0,.27)"); gradient.addColorStop(1, "rgba(0,0,0,0)"); context.fillStyle = gradient; context.fillRect(0, 0, 128, 128); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } async function loadTemplate() { const gltf = await new GLTFLoader().parseAsync(modelBuffer(), ""); gltf.scene.traverse((object) => { if (!object.isMesh) return; object.material = Array.isArray(object.material) ? object.material.map(makeMaterial) : makeMaterial(object.material); object.geometry.computeBoundingSphere(); }); const bounds = new THREE.Box3().setFromObject(gltf.scene); const center = bounds.getCenter(new THREE.Vector3()); const size = bounds.getSize(new THREE.Vector3()); const centered = new THREE.Group(); gltf.scene.position.sub(center); centered.add(gltf.scene); centered.scale.setScalar(2 / Math.max(size.x, size.y, size.z)); return centered; } /* A slightly inset box is the stable collision proxy. The visible mesh keeps Fusion's exact fillets; the 5% inset keeps the proxy inside that silhouette while avoiding stacked contacts that can inject energy into small bodies. */ function addDieCollider(body) { body.addShape(new CANNON.Box(new CANNON.Vec3(0.95, 0.95, 0.95))); } function makePhysicsWorld() { const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -22, 0) }); world.allowSleep = true; world.broadphase = new CANNON.SAPBroadphase(world); world.solver.iterations = 18; const dieMaterial = new CANNON.Material("die"); const tableMaterial = new CANNON.Material("table"); world.addContactMaterial(new CANNON.ContactMaterial(dieMaterial, tableMaterial, { friction: 0.6, restitution: 0.2, contactEquationStiffness: 1e8, contactEquationRelaxation: 3, })); world.addContactMaterial(new CANNON.ContactMaterial(dieMaterial, dieMaterial, { friction: 0.3, restitution: 0.3, contactEquationStiffness: 1e8, contactEquationRelaxation: 3, })); const table = new CANNON.Body({ mass: 0, material: tableMaterial }); table.addShape(new CANNON.Plane()); table.quaternion.setFromEuler(-Math.PI / 2, 0, 0); world.addBody(table); return { world, dieMaterial }; } function makeThrowPlan(count) { if (count !== 2) { return Array.from({ length: count }, (_, index) => { const side = index % 2 === 0 ? -1 : 1; return { position: [side * 1.8, randomBetween(2.45, 2.8), randomBetween(-0.75, -0.45)], velocity: [-side * randomBetween(1.7, 2.3), randomBetween(0.3, 0.8), randomBetween(0.2, 0.65)], }; }); } const chance = Math.random(); const mode = chance < 0.55 ? "cross" : chance < 0.9 ? "collision" : "overtake"; return [0, 1].map((index) => { const side = index === 0 ? -1 : 1; if (mode === "cross") { return { position: [side * 1.8, randomBetween(2.45, 2.8), index === 0 ? 1.65 : -1.65], velocity: [-side * randomBetween(2.6, 3.2), randomBetween(0.3, 0.8), randomBetween(-0.1, 0.1)], }; } if (mode === "overtake") { return { position: [index === 0 ? -0.8 : 0.8, randomBetween(2.45, 2.8), index === 0 ? -2.15 : 1.25], velocity: [randomBetween(-0.25, 0.25), randomBetween(0.3, 0.8), index === 0 ? randomBetween(2.1, 2.7) : randomBetween(0.35, 0.8)], }; } return { position: [side * 1.8, randomBetween(2.45, 2.8), randomBetween(-0.75, -0.45)], velocity: [-side * randomBetween(1.7, 2.3), randomBetween(0.3, 0.8), randomBetween(0.2, 0.65)], }; }); } function resetBodyForThrow(body, launch) { body.wakeUp(); body.position.set(...launch.position); randomQuaternion(body.quaternion); body.velocity.set(...launch.velocity); body.angularVelocity.set( randomSigned(9, 14), randomSigned(10, 16), randomSigned(9, 14), ); body.force.setZero(); body.torque.setZero(); body.previousPosition.copy(body.position); body.interpolatedPosition.copy(body.position); body.previousQuaternion.copy(body.quaternion); body.interpolatedQuaternion.copy(body.quaternion); body.aabbNeedsUpdate = true; } async function mount(canvas, count = 2) { if (!(canvas instanceof HTMLCanvasElement)) { throw new TypeError("Dice3D.mount requires a canvas element"); } const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); renderer.setClearColor(0x000000, 0); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.08; const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-6.85, 6.85, 4.35, -4.35, 0.1, 40); camera.position.set(0, 9.1, 13.8); camera.lookAt(0, 0.72, 0); scene.add(new THREE.HemisphereLight(0xffffff, 0x6b2426, 2.05)); const key = new THREE.DirectionalLight(0xffffff, 2.8); key.position.set(-4, 8, 7); scene.add(key); const fill = new THREE.DirectionalLight(0xffc8c4, 0.9); fill.position.set(5, 3, 7); scene.add(fill); const template = await loadTemplate(); const shadowTexture = makeShadowTexture(); const { world, dieMaterial } = makePhysicsWorld(); const dice = []; const spacing = 2.75; for (let index = 0; index < count; index++) { const holder = new THREE.Group(); holder.add(template.clone(true)); const x = (index - (count - 1) / 2) * spacing; const initialValue = rollDie(); const initialRotation = restingQuaternion(initialValue, randomBetween(0, TAU)); holder.position.set(x, 1, 0); holder.quaternion.copy(initialRotation); scene.add(holder); const body = new CANNON.Body({ mass: 1, material: dieMaterial, linearDamping: 0.24, angularDamping: 0.22, allowSleep: true, sleepSpeedLimit: 0.16, sleepTimeLimit: 0.38, }); addDieCollider(body); body.position.set(x, 1, 0); body.quaternion.set( initialRotation.x, initialRotation.y, initialRotation.z, initialRotation.w, ); world.addBody(body); const shadowMaterial = new THREE.MeshBasicMaterial({ map: shadowTexture, transparent: true, opacity: 0.44, depthWrite: false, toneMapped: false, side: THREE.DoubleSide, }); const shadow = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 1.55), shadowMaterial); shadow.rotation.x = -Math.PI / 2; shadow.position.set(x, 0.01, 0); scene.add(shadow); dice.push({ holder, body, shadow, value: initialValue }); } function syncVisuals() { for (const die of dice) { const { position, quaternion } = die.body; die.holder.position.set(position.x, position.y, position.z); die.holder.quaternion.set(quaternion.x, quaternion.y, quaternion.z, quaternion.w); die.shadow.position.set(position.x, 0.01, position.z); const clearance = Math.max(0, position.y - 1); const shadowScale = 1 - Math.min(0.36, clearance * 0.18); die.shadow.scale.set(shadowScale, shadowScale, 1); die.shadow.material.opacity = 0.44 - Math.min(0.31, clearance * 0.16); } } function resize() { const width = Math.max(1, canvas.clientWidth); const height = Math.max(1, canvas.clientHeight); const aspect = width / height; const halfHeight = 4.35; camera.left = -halfHeight * aspect; camera.right = halfHeight * aspect; camera.top = halfHeight; camera.bottom = -halfHeight; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); renderer.render(scene, camera); } const observer = new ResizeObserver(resize); observer.observe(canvas); resize(); let busy = false; let frameRequest = 0; let destroyed = false; function finishRoll(resolve) { syncVisuals(); renderer.render(scene, camera); const values = dice.map((die) => { die.value = upwardFace(die.body); return die.value; }); busy = false; resolve(values); } function roll() { if (busy) return Promise.resolve(null); busy = true; const plan = makeThrowPlan(dice.length); dice.forEach((die, index) => resetBodyForThrow(die.body, plan[index])); const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; if (reducedMotion) { for (let step = 0; step < 720; step++) world.step(1 / 120); dice.forEach((die) => die.body.sleep()); return new Promise((resolve) => finishRoll(resolve)); } const startedAt = performance.now(); let previous = startedAt; return new Promise((resolve) => { function frame(now) { if (destroyed) return; const elapsedSeconds = (now - startedAt) / 1000; const frameSeconds = Math.min(0.05, (now - previous) / 1000); previous = now; /* The rigid-body path remains physical; playing simulated time at 1.55x removes the slow-motion feel of a tiny object on screen. */ world.step(1 / 120, frameSeconds * 1.55, 10); syncVisuals(); renderer.render(scene, camera); const asleep = dice.every((die) => die.body.sleepState === CANNON.Body.SLEEPING); if ((elapsedSeconds > 0.8 && asleep) || elapsedSeconds > 6) { if (!asleep) dice.forEach((die) => die.body.sleep()); finishRoll(resolve); } else { frameRequest = requestAnimationFrame(frame); } } frameRequest = requestAnimationFrame(frame); }); } return { roll, dice, isBusy: () => busy, destroy() { destroyed = true; cancelAnimationFrame(frameRequest); observer.disconnect(); renderer.dispose(); shadowTexture.dispose(); }, }; } window.Dice3D = { mount, rollDie };