Files
entscheidomat/posts/11-building-3d-polyhedral-dice-roller-threejs-physics.md
2026-08-05 19:33:11 +02:00

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.
threejs
javascript
webgl
gamedev
https://entscheidomat.com/ratgeber/zufallsgenerator-richtig-nutzen
würfel online
würfel online werfen
wuerfel generator
d20 würfel online
threejs dice physics

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:

  1. Linear Velocity (\vec{v}): Translates the die through 3D space.
  2. Angular Velocity (\vec{\omega}): Rotates the die around its center of mass.
  3. Gravity (\vec{g} = -9.81 \text{ m/s}^2): Accelerates the die downward toward the floor collision plane.
  4. 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)."
      }
    }
  ]
}