Files
2026-08-11 09:53:42 -04:00

575 lines
18 KiB
JavaScript

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");
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 = 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 resetBodyForThrow(body, launch, motion) {
body.wakeUp();
body.position.set(...launch.position);
randomQuaternion(body.quaternion);
body.velocity.set(
launch.velocity[0] * motion.velocityScale,
motion.launchY
? randomBetween(motion.launchY[0], motion.launchY[1])
: launch.velocity[1],
launch.velocity[2] * motion.velocityScale,
);
body.angularVelocity.set(
randomSigned(10, 15) * motion.spinScale,
randomSigned(11, 17) * motion.spinScale,
randomSigned(10, 15) * motion.spinScale,
);
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) {
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;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-8.95, 8.95, 5.8, -5.8, 0.1, 40);
camera.position.set(0, 9.5, 14.4);
camera.lookAt(0, 0.72, 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);
key.shadow.camera.left = -8;
key.shadow.camera.right = 8;
key.shadow.camera.top = 8;
key.shadow.camera.bottom = -8;
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 } = makePhysicsWorld();
const dice = [];
const spacing = 2.75;
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);
dice.push({ holder, body, 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);
}
}
function resize() {
const width = Math.max(1, canvas.clientWidth);
const height = Math.max(1, canvas.clientHeight);
const aspect = width / height;
const halfHeight = 5.8;
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.current;
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 plan = makeThrowPlan(dice.length);
dice.forEach((die, index) => resetBodyForThrow(die.body, plan[index], motion));
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 / motion.fixedStep);
for (let step = 0; step < settleSteps; step++) world.step(motion.fixedStep);
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(
motion.fixedStep,
frameSeconds * motion.playback,
motion.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,
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) };