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 MAX_RETHROWS = 8; const RANDOM_WORD = new Uint32Array(1); const MOTION_PRESETS = Object.freeze({ current: Object.freeze({ name: "Current", gravity: 24, playback: 1.7, fixedStep: 1 / 120, maxSubSteps: 12, velocityScale: 1, spinScale: 1, launchY: null, linearDamping: 0.22, angularDamping: 0.2, sleepSpeedLimit: 0.16, sleepTimeLimit: 0.38, minDuration: 0.8, }), balanced: Object.freeze({ name: "Balanced realism", gravity: 96, playback: 1.15, fixedStep: 1 / 240, maxSubSteps: 20, velocityScale: 2, spinScale: 1.45, launchY: [4, 7], linearDamping: 0.08, angularDamping: 0.06, sleepSpeedLimit: 0.28, sleepTimeLimit: 0.18, minDuration: 0.45, }), literal: Object.freeze({ name: "Literal 20 mm scale", gravity: 981, playback: 1, fixedStep: 1 / 600, maxSubSteps: 32, velocityScale: Math.sqrt(981 / 24), spinScale: Math.sqrt(981 / 24), launchY: [18, 30], linearDamping: 0.02, angularDamping: 0.015, sleepSpeedLimit: 1, sleepTimeLimit: 0.08, minDuration: 0.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 randomUnit() { crypto.getRandomValues(RANDOM_WORD); return RANDOM_WORD[0] / 0x100000000; } function randomBetween(min, max) { return min + randomUnit() * (max - min); } function randomSigned(min, max) { return (randomUnit() < 0.5 ? -1 : 1) * randomBetween(min, max); } function randomQuaternion(target) { const u1 = randomUnit(); const u2 = randomUnit(); const u3 = randomUnit(); 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) { const alignments = []; for (const [value, localNormal] of Object.entries(FACE_NORMALS)) { const worldNormal = body.quaternion.vmult(localNormal); alignments.push({ value: Number(value), alignment: worldNormal.y }); } alignments.sort((a, b) => b.alignment - a.alignment); return { value: alignments[0].value, alignment: alignments[0].alignment, margin: alignments[0].alignment - alignments[1].alignment, }; } 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, }); } 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.castShadow = true; 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, -24, 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"); const bumperMaterial = new CANNON.Material("bumper"); 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, })); world.addContactMaterial(new CANNON.ContactMaterial(dieMaterial, bumperMaterial, { friction: 0.52, restitution: 0.18, 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, tableMaterial, bumperMaterial }; } function addStaticBox(world, material, halfExtents, position) { const body = new CANNON.Body({ mass: 0, material }); body.addShape(new CANNON.Box(new CANNON.Vec3(...halfExtents))); body.position.set(...position); world.addBody(body); return body; } function addCrapsTable(scene, world, tableMaterial, bumperMaterial) { const felt = new THREE.MeshStandardMaterial({ color: 0x0b6337, roughness: 0.96, metalness: 0, }); const railMaterial = new THREE.MeshStandardMaterial({ color: 0x164832, roughness: 0.88, metalness: 0, }); const bumperVisualMaterial = new THREE.MeshStandardMaterial({ color: 0x0b3626, roughness: 0.82, metalness: 0, }); const floor = new THREE.Mesh(new THREE.PlaneGeometry(21.4, 13.4), felt); floor.rotation.x = -Math.PI / 2; floor.position.y = -0.055; floor.receiveShadow = true; scene.add(floor); function addVisualBox(size, position, material = railMaterial) { const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material); mesh.position.set(...position); mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); return mesh; } addVisualBox([21.4, 6, 0.46], [0, 3, -6.7]); addVisualBox([0.5, 5, 13.4], [-10.7, 2.5, 0]); addVisualBox([0.5, 5, 13.4], [10.7, 2.5, 0]); addVisualBox([21.4, 1.4, 0.4], [0, 0.7, 6.7]); addStaticBox(world, tableMaterial, [0.25, 2.5, 6.7], [-10.7, 2.5, 0]); addStaticBox(world, tableMaterial, [0.25, 2.5, 6.7], [10.7, 2.5, 0]); addStaticBox(world, tableMaterial, [10.7, 0.7, 0.2], [0, 0.7, 6.7]); const panelYaw = [0.04, 0.08, -0.08, -0.04]; for (let panel = 0; panel < 4; panel++) { const bumperBody = new CANNON.Body({ mass: 0, material: bumperMaterial }); bumperBody.addShape(new CANNON.Box(new CANNON.Vec3(2.7, 3, 0.2))); bumperBody.position.set(-8.025 + panel * 5.35, 3, -6.55); bumperBody.quaternion.setFromEuler(0, panelYaw[panel], 0); bumperBody.userData = { surface: "backstop" }; world.addBody(bumperBody); } const bumpPositions = []; const rowCount = 8; const columnCount = 34; for (let row = 0; row < rowCount; row++) { const y = 0.42 + row * 0.56; const offset = row % 2 === 0 ? 0 : 0.29; for (let column = 0; column < columnCount; column++) { const x = -9.7 + column * 0.58 + offset; if (x > 9.7) continue; bumpPositions.push([x, y, -6.28]); } } const bumpGeometry = new THREE.ConeGeometry(0.27, 0.3, 4); const bumps = new THREE.InstancedMesh( bumpGeometry, bumperVisualMaterial, bumpPositions.length, ); const bumpTransform = new THREE.Object3D(); bumpPositions.forEach(([x, y, z], index) => { bumpTransform.position.set(x, y, z + 0.01); bumpTransform.rotation.set(Math.PI / 2, 0, index % 2 === 0 ? 0 : Math.PI / 4); bumpTransform.updateMatrix(); bumps.setMatrixAt(index, bumpTransform.matrix); }); bumps.castShadow = true; bumps.receiveShadow = true; scene.add(bumps); } 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 = randomUnit(); 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.55, 2.05), randomBetween(0.3, 0.8), randomBetween(0.15, 0.55)], }; }); } function makeCrapsThrowPlan(count) { return Array.from({ length: count }, (_, index) => { const side = index % 2 === 0 ? -1 : 1; return { position: [ side * randomBetween(1.8, 2.3), randomBetween(2.45, 2.9), randomBetween(4.2, 4.75) + index * randomBetween(-0.12, 0.12), ], velocity: [ -side * randomBetween(0.2, 0.6) + randomBetween(-0.2, 0.2), randomBetween(0.3, 0.8), -randomBetween(25, 28), ], }; }); } function resetBodyForThrow(body, launch, motion, strength = 1) { const liftScale = Math.sqrt(strength); body.wakeUp(); body.position.set(...launch.position); randomQuaternion(body.quaternion); body.velocity.set( launch.velocity[0] * motion.velocityScale * strength, motion.launchY ? randomBetween(motion.launchY[0], motion.launchY[1]) * liftScale : launch.velocity[1] * liftScale, launch.velocity[2] * motion.velocityScale * strength, ); const spinStrength = 0.7 + strength * 0.3; body.angularVelocity.set( randomSigned(10, 15) * motion.spinScale * spinStrength, randomSigned(11, 17) * motion.spinScale * spinStrength, randomSigned(10, 15) * motion.spinScale * spinStrength, ); 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; } function rethrowBody(body, towardCenter, motion) { const velocityScale = motion.velocityScale; body.wakeUp(); body.position.y += 0.18; body.velocity.set( towardCenter ? THREE.MathUtils.clamp( (-body.position.x * 0.48 + randomBetween(-0.3, 0.3)) * velocityScale, -3.2 * velocityScale, 3.2 * velocityScale, ) : randomBetween(-1.15, 1.15) * velocityScale, randomBetween(2.1, 2.8) * velocityScale, towardCenter ? THREE.MathUtils.clamp( (-body.position.z * 0.48 + randomBetween(-0.3, 0.3)) * velocityScale, -3.2 * velocityScale, 3.2 * velocityScale, ) : randomBetween(-1.15, 1.15) * velocityScale, ); body.angularVelocity.set( randomSigned(8, 13) * motion.spinScale, randomSigned(9, 14) * motion.spinScale, randomSigned(8, 13) * motion.spinScale, ); body.force.setZero(); body.torque.setZero(); body.aabbNeedsUpdate = true; } async function mount(canvas, count = 2, options = {}) { if (!(canvas instanceof HTMLCanvasElement)) { throw new TypeError("Dice3D.mount requires a canvas element"); } const sceneMode = options.scene === "craps" ? "craps" : "open"; const isCrapsScene = sceneMode === "craps"; const cameraHalfHeight = isCrapsScene ? 7.6 : 5.8; 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; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; const scene = new THREE.Scene(); const initialAspect = isCrapsScene ? 14 / 9 : 54 / 35; const camera = new THREE.OrthographicCamera( -cameraHalfHeight * initialAspect, cameraHalfHeight * initialAspect, cameraHalfHeight, -cameraHalfHeight, 0.1, 50, ); camera.position.set(0, isCrapsScene ? 12.2 : 9.5, isCrapsScene ? 18.8 : 14.4); camera.lookAt(0, isCrapsScene ? 0.85 : 0.72, isCrapsScene ? -0.35 : 0); const dieCorners = []; for (const x of [-1, 1]) { for (const y of [-1, 1]) { for (const z of [-1, 1]) dieCorners.push(new THREE.Vector3(x, y, z)); } } const projectedCorner = new THREE.Vector3(); const framePosition = new THREE.Vector3(); const frameQuaternion = new THREE.Quaternion(); function dieIsFullyVisible(body) { framePosition.set(body.position.x, body.position.y, body.position.z); frameQuaternion.set( body.quaternion.x, body.quaternion.y, body.quaternion.z, body.quaternion.w, ); return dieCorners.every((corner) => { projectedCorner .copy(corner) .applyQuaternion(frameQuaternion) .add(framePosition) .project(camera); return Math.abs(projectedCorner.x) <= 0.98 && Math.abs(projectedCorner.y) <= 0.98; }); } scene.add(new THREE.HemisphereLight(0xffffff, 0x6b2426, 2.05)); const key = new THREE.DirectionalLight(0xffffff, 2.8); key.position.set(-4, 8, 7); key.castShadow = true; key.shadow.mapSize.set(1024, 1024); const shadowExtent = isCrapsScene ? 12 : 8; key.shadow.camera.left = -shadowExtent; key.shadow.camera.right = shadowExtent; key.shadow.camera.top = shadowExtent; key.shadow.camera.bottom = -shadowExtent; key.shadow.camera.near = 1; key.shadow.camera.far = 30; key.shadow.bias = -0.0004; key.shadow.normalBias = 0.025; key.shadow.radius = 4; 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 { world, dieMaterial, tableMaterial, bumperMaterial } = makePhysicsWorld(); const dice = []; const spacing = 2.75; if (isCrapsScene) { addCrapsTable(scene, world, tableMaterial, bumperMaterial); } else { const shadowFloor = new THREE.Mesh( new THREE.PlaneGeometry(24, 18), new THREE.ShadowMaterial({ color: 0x000000, opacity: 0.2 }), ); shadowFloor.rotation.x = -Math.PI / 2; shadowFloor.position.y = -0.055; shadowFloor.receiveShadow = true; scene.add(shadowFloor); } 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.22, angularDamping: 0.2, 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 die = { holder, body, value: initialValue, hitBackstop: false }; body.addEventListener("collide", (event) => { if (event.body?.userData?.surface === "backstop") die.hitBackstop = true; }); dice.push(die); } 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); } } function resize() { const width = Math.max(1, canvas.clientWidth); const height = Math.max(1, canvas.clientHeight); const aspect = width / height; const halfHeight = cameraHalfHeight; 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; let motion = MOTION_PRESETS.balanced; let throwForce = 50; function finishRoll(resolve) { syncVisuals(); renderer.render(scene, camera); const values = dice.map((die) => { die.value = upwardFace(die.body).value; return die.value; }); busy = false; resolve(values); } function diceNeedingRethrow() { return dice.flatMap((die) => { const face = upwardFace(die.body); const cocked = face.alignment < 0.9 || face.margin < 0.08; const outsideFrame = !dieIsFullyVisible(die.body); return cocked || outsideFrame ? [{ die, outsideFrame }] : []; }); } function rethrowInvalidDice(invalidDice) { invalidDice.forEach(({ die, outsideFrame }) => { rethrowBody(die.body, outsideFrame, motion); }); } function roll() { if (busy) return Promise.resolve(null); busy = true; world.gravity.set(0, -motion.gravity, 0); dice.forEach((die) => { die.body.linearDamping = motion.linearDamping; die.body.angularDamping = motion.angularDamping; die.body.sleepSpeedLimit = motion.sleepSpeedLimit; die.body.sleepTimeLimit = motion.sleepTimeLimit; }); const strength = isCrapsScene ? 0.9 + throwForce * 0.0025 : 1; const plan = isCrapsScene ? makeCrapsThrowPlan(dice.length) : makeThrowPlan(dice.length); dice.forEach((die, index) => { die.hitBackstop = false; resetBodyForThrow(die.body, plan[index], motion, strength); }); const physicsStep = isCrapsScene ? Math.min(motion.fixedStep, 1 / 360) : motion.fixedStep; const maxSubSteps = isCrapsScene ? Math.max(motion.maxSubSteps, 24) : motion.maxSubSteps; const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; if (reducedMotion) { /* Every rethrow is followed by a complete settle pass. At the cap we accept the best settled reading; we never freeze a fresh launch. */ for (let rethrowCount = 0; ; rethrowCount++) { const settleSteps = Math.ceil(6 / physicsStep); for (let step = 0; step < settleSteps; step++) world.step(physicsStep); const invalidDice = diceNeedingRethrow(); if (!invalidDice.length || rethrowCount === MAX_RETHROWS) break; rethrowInvalidDice(invalidDice); } dice.forEach((die) => die.body.sleep()); return new Promise((resolve) => finishRoll(resolve)); } let startedAt = performance.now(); let previous = startedAt; let rethrowCount = 0; 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; /* Each preset owns its integration and presentation timescale. */ world.step( physicsStep, frameSeconds * motion.playback, maxSubSteps, ); syncVisuals(); renderer.render(scene, camera); const asleep = dice.every((die) => die.body.sleepState === CANNON.Body.SLEEPING); if ((elapsedSeconds > motion.minDuration && asleep) || elapsedSeconds > 6) { if (!asleep) dice.forEach((die) => die.body.sleep()); const invalidDice = diceNeedingRethrow(); if (invalidDice.length && rethrowCount < MAX_RETHROWS) { rethrowInvalidDice(invalidDice); rethrowCount++; startedAt = now; previous = now; frameRequest = requestAnimationFrame(frame); } else { finishRoll(resolve); } } else { frameRequest = requestAnimationFrame(frame); } } frameRequest = requestAnimationFrame(frame); }); } return { roll, dice, isBusy: () => busy, getMotionPreset: () => motion.name, getThrowForce: () => throwForce, getLastThrowDiagnostics: () => ({ backstopHits: dice.filter((die) => die.hitBackstop).length, dice: dice.length, }), setThrowForce(value) { if (busy) return false; throwForce = THREE.MathUtils.clamp(Number(value) || 0, 0, 100); return true; }, setMotionPreset(name) { if (!(name in MOTION_PRESETS)) throw new RangeError(`Unknown motion preset: ${name}`); if (busy) return false; motion = MOTION_PRESETS[name]; return true; }, destroy() { destroyed = true; cancelAnimationFrame(frameRequest); observer.disconnect(); const geometries = new Set(); const materials = new Set(); scene.traverse((object) => { if (!object.isMesh) return; if (object.geometry) geometries.add(object.geometry); const list = Array.isArray(object.material) ? object.material : [object.material]; list.filter(Boolean).forEach((material) => materials.add(material)); }); geometries.forEach((geometry) => geometry.dispose()); materials.forEach((material) => { for (const value of Object.values(material)) { if (value?.isTexture) value.dispose(); } material.dispose(); }); world.bodies.slice().forEach((body) => world.removeBody(body)); renderer.renderLists.dispose(); renderer.dispose(); scene.clear(); }, }; } window.Dice3D = { mount, rollDie, motionPresets: Object.keys(MOTION_PRESETS) };