11 KiB
title, description, tags, canonical_url, target_keywords
| title | description | tags | canonical_url | target_keywords | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Building a 3D Polyhedral Dice Roller in Three.js & Cannon.js: Rigid Body Physics & Fair RPG Randomness | How to build a 3D polyhedral dice simulator (D4-D20) using Three.js, Cannon.js rigid body physics, quaternions, and 3D face vector detection. |
|
https://entscheidomat.com/ratgeber/zufallsgenerator-richtig-nutzen |
|
Building a 3D Polyhedral Dice Roller in Three.js & Cannon.js: Rigid Body Physics & Fair RPG Randomness
Rolling physical dice is an iconic part of tabletop role-playing games (TTRPGs) like Dungeons & Dragons, Pathfinder, and board games. Whether you need a standard 6-sided cube or a 20-sided icosahedron (D20), players expect a digital dice roller to feel tactile, behave according to realistic Newtonian physics, and deliver statistically fair outcomes.
For web developers building RPG tools or decision suites like a digital Würfel Online, rendering 2D numbers or pseudo-random text overlays often feels flat and unconvincing.
In this article, we will build a production-ready 3D Polyhedral Dice Roller in TypeScript using Three.js for WebGL rendering and Cannon-es for 3D rigid body physics simulation. We will cover geometry construction, initial impulse vectors, quaternion face orientation detection, and crypto-random seeding.
1. The Physics of 3D Rigid Body Dice Tossing
Simulating a rolling die requires solving rigid body dynamics in a 3D space:
- Linear Velocity (
\vec{v}): Translates the die through 3D space. - Angular Velocity (
\vec{\omega}): Rotates the die around its center of mass. - Gravity (
\vec{g} = -9.81 \text{ m/s}^2): Accelerates the die downward toward the floor collision plane. - Restitution (
e) & Friction (\mu): Models bounce elasticity and floor surface grip.
Angular Impulse (Torque τ)
↺
┌─────────┐
│ 🎲 D20 │ ──► Linear Velocity (v)
└────┬────┘
│
▼ Gravity (g = -9.81 m/s²)
═════════════════════════════════════════ Floor Plane (Restitution e = 0.3)
2. Setting Up Three.js & Cannon-es Physics World
First, we set up a synchronized 3D rendering scene (Three.js) and physics simulation world (Cannon-es):
import * as THREE from "three";
import * as CANNON from "cannon-es";
export class PhysicsDiceScene {
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private world: CANNON.World;
private diceMesh?: THREE.Mesh;
private diceBody?: CANNON.Body;
constructor(container: HTMLElement) {
// 1. Initialize Three.js Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x101114);
this.camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 100);
this.camera.position.set(0, 12, 12);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.shadowMap.enabled = true;
container.appendChild(this.renderer.domElement);
// 2. Lighting Setup
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
this.scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(5, 15, 5);
dirLight.castShadow = true;
this.scene.add(dirLight);
// 3. Initialize Cannon-es Physics World
this.world = new CANNON.World();
this.world.gravity.set(0, -19.6, 0); // 2x Earth gravity for punchy dice rolls
// Floor Contact Material
const floorMaterial = new CANNON.Material("floor");
const diceMaterial = new CANNON.Material("dice");
const contactMaterial = new CANNON.ContactMaterial(floorMaterial, diceMaterial, {
friction: 0.4,
restitution: 0.3 // Bounciness
});
this.world.addContactMaterial(contactMaterial);
// Add Floor Rigid Body
const floorBody = new CANNON.Body({
type: CANNON.Body.STATIC,
shape: new CANNON.Plane(),
material: floorMaterial
});
floorBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); // Rotate horizontal
this.world.addBody(floorBody);
}
}
3. Creating a Polyhedral D6 Mesh & Physics Body
Next, we create a standard 6-sided cube die (D6) with rounded edges and mapped UV texture coordinates.
export function createD6Die(scene: THREE.Scene, world: CANNON.World): { mesh: THREE.Mesh; body: CANNON.Body } {
const size = 1.5;
const halfSize = size / 2;
// 1. Three.js Box Geometry
const geometry = new THREE.BoxGeometry(size, size, size);
const material = new THREE.MeshStandardMaterial({
color: 0x3b5bdb,
roughness: 0.2,
metalness: 0.1
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
scene.add(mesh);
// 2. Cannon.js Physics Box Shape
const shape = new CANNON.Box(new CANNON.Vec3(halfSize, halfSize, halfSize));
const body = new CANNON.Body({
mass: 1.0, // 1 kg
shape: shape,
position: new CANNON.Vec3(0, 5, 0)
});
world.addBody(body);
return { mesh, body };
}
4. Crypto-Random Impulse Injection & Rolling Mechanics
To start a toss, we apply a randomized upward vector velocity and a strong angular torque vector generated using crypto.getRandomValues() to eliminate predictable trajectory patterns.
export function rollDice(body: CANNON.Body): void {
// Reset Position to top
body.position.set(0, 5, 0);
body.velocity.set(0, 0, 0);
body.angularVelocity.set(0, 0, 0);
// Generate Cryptographic Random Velocity & Torque
const buffer = new Uint32Array(4);
crypto.getRandomValues(buffer);
// Random Linear Impulse (X and Z spread, Y upward toss)
const impulseX = ((buffer[0] / 0xFFFFFFFF) - 0.5) * 8;
const impulseY = 4 + (buffer[1] / 0xFFFFFFFF) * 4;
const impulseZ = ((buffer[2] / 0xFFFFFFFF) - 0.5) * 8;
body.velocity.set(impulseX, impulseY, impulseZ);
// Random Angular Spin (Torque)
const spinX = ((buffer[3] / 0xFFFFFFFF) - 0.5) * 40;
const spinY = ((buffer[0] / 0xFFFFFFFF) - 0.5) * 40;
const spinZ = ((buffer[1] / 0xFFFFFFFF) - 0.5) * 40;
body.angularVelocity.set(spinX, spinY, spinZ);
}
5. Detecting the Top Face Using Quaternion Vector Transformation
Once the die comes to rest on the floor plane (linear and angular velocity drop near zero), how do we mathematically identify which face is pointing strictly upward toward the sky (+Y axis)?
Each of the 6 faces of a cube has a local normal vector in local space:
- Face 1 (
+Z):(0, 0, 1) - Face 6 (
-Z):(0, 0, -1) - Face 2 (
+X):(1, 0, 0) - Face 5 (
-X):(-1, 0, 0) - Face 3 (
+Y):(0, 1, 0) - Face 4 (
-Y):(0, -1, 0)
We transform each local normal vector into world space using the die's final Quaternion Rotation Matrix and calculate the dot product with the world Up vector (0, 1, 0). The face whose world vector has the highest dot product (closest to +1.0) is the winning top face!
export interface FaceNormal {
value: number;
localVector: THREE.Vector3;
}
const D6_FACES: FaceNormal[] = [
{ value: 1, localVector: new THREE.Vector3(0, 0, 1) },
{ value: 6, localVector: new THREE.Vector3(0, 0, -1) },
{ value: 2, localVector: new THREE.Vector3(1, 0, 0) },
{ value: 5, localVector: new THREE.Vector3(-1, 0, 0) },
{ value: 3, localVector: new THREE.Vector3(0, 1, 0) },
{ value: 4, localVector: new THREE.Vector3(0, -1, 0) }
];
/**
* Calculates the top face value of a landed die using Quaternion vector alignment.
*/
export function getLandedFaceValue(mesh: THREE.Mesh): number {
const worldUp = new THREE.Vector3(0, 1, 0);
let maxDot = -Infinity;
let winningValue = 1;
D6_FACES.forEach(face => {
// Clone local vector and transform by Mesh Quaternion orientation
const worldVector = face.localVector.clone().applyQuaternion(mesh.quaternion);
// Calculate dot product with World Up (0, 1, 0)
const dot = worldVector.dot(worldUp);
if (dot > maxDot) {
maxDot = dot;
winningValue = face.value;
}
});
return winningValue;
}
6. The 60 FPS Render Loop
Finally, we sync Cannon.js physics steps with Three.js rendering frames using requestAnimationFrame:
export function startAnimationLoop(
scene: THREE.Scene,
camera: THREE.Camera,
renderer: THREE.WebGLRenderer,
world: CANNON.World,
mesh: THREE.Mesh,
body: CANNON.Body,
onSettle?: (value: number) => void
): void {
const timeStep = 1 / 60; // 60 FPS
let isSettledReported = false;
function animate() {
requestAnimationFrame(animate);
// 1. Step Physics World
world.step(timeStep);
// 2. Synchronize Three.js Mesh with Cannon.js Body
mesh.position.copy(body.position as any);
mesh.quaternion.copy(body.quaternion as any);
// 3. Check for Rest State (Velocity near zero)
const isStationary = body.velocity.lengthSquared() < 0.001 && body.angularVelocity.lengthSquared() < 0.001;
if (isStationary && !isSettledReported && body.position.y < 1.0) {
isSettledReported = true;
const result = getLandedFaceValue(mesh);
if (onSettle) onSettle(result);
}
// 4. Render 3D Scene
renderer.render(scene, camera);
}
animate();
}
Summary & Performance Best Practices
| Parameter | 2D CSS Spinner / Text | 3D WebGL (Three.js + Cannon.js) |
|---|---|---|
| Tactile Realism | Low | High (True Newtonian Gravity & Collisions) |
| Polyhedral Support | D6 only | D4, D6, D8, D10, D12, D20, D100 |
| Face Determination | Hardcoded | Quaternion World Vector Dot Product |
| Framerate | Varies | Locked 60 FPS on WebGL GPU |
Test a live 3D dice generator online at Entscheidomat Würfel Online.
FAQ (Schema Structured Data)
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "How do you calculate which face of a 3D die landed facing up?",
"acceptedAnswer": {
"@type": "Answer",
"text": "By transforming the local normal vectors of each die face by the 3D mesh's final quaternion rotation matrix and taking the dot product with the world Up vector (0, 1, 0). The face with the highest dot product is the landed value."
}
},
{
"@type": "Question",
"name": "Is 3D WebGL physics fair for online dice rolling?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes, provided the initial linear velocity, angular spin torque, and initial spawn orientation vectors are seeded using Web Crypto API (crypto.getRandomValues)."
}
}
]
}