Vol. 07 · Dispatch2026-07-01

Building a Three.js snooker game — aim lines, Rapier CCD, and canvas-baked balls

The three things that decide whether a browser pool game feels good — a predictive aim line that respects the *first* ball you'd hit, a Rapier world with CCD turned on so 3 m/s balls don't tunnel through cushions, and 512×512 canvas-generated ball textures so you never ship a PNG. All from the production code of our /gamecenter Snooker.

by MetaWorldOS Engineering
Three.jsWebGLRapierTypeScriptGameDev

TL;DR — A snooker game in the browser lives or dies on three things: (1) the predictive aim line has to break at the first ball the cue would touch, not “any ball near the ray”; (2) the physics world needs Continuous Collision Detection, otherwise a hard break shot punches balls straight through the cushion; (3) you don’t ship 22 ball PNGs — you generate them at boot from a 512×512 canvas and hand THREE.CanvasTexture the pixels. This post walks through the actual code we wrote for /gamecenter/snooker.

Live demo: /gamecenter · engine tree: src/features/gamecenter/games/snooker/


Snooker is a good stress test for a browser 3D engine. You have 22 rigid bodies, six pockets, four cushions, spin, and — the reason people keep playing — an aim line that has to be right or the illusion breaks. Three months into shipping our version, the three files that ate the most iteration were:

snooker/
├── physics/SnookerPhysics.ts   ← Rapier world, cushions, pockets
├── objects/AimingGuide.ts      ← the dashed white line + yellow prediction
└── textures/SnookerTextureGenerator.ts  ← 512² canvases baked into GPU textures

Everything else — rules, input, the cue stick model, the AI opponent — is boring by comparison. Here’s what was interesting.


1. The aim line that lies vs. the aim line that doesn’t

Every “3D pool” tutorial has an aim line. Almost all of them are wrong in the same way: they draw a ray from the cue ball, check every other ball for “is my center within ballRadius of this line”, and highlight the closest one. If your line grazes ball A on its way to ball B, the tutorial happily highlights B. In real snooker the line has to stop the moment the cue would first make contact.

Here’s the shape of the correct algorithm — this is verbatim from AimingGuide.ts, the loop that walks the ray forward in 5 cm steps and terminates on first contact:

// AimingGuide.ts — updateMainLine
let currentPos = startPos.clone();
const step = 0.05;
let hasCollision = false;
let collisionPoint: THREE.Vector3 | null = null;

while (lineLength < maxLength) {
  const nextPos = currentPos.clone().add(
    direction.clone().multiplyScalar(step)
  );

  // out-of-table? draw the last segment and stop
  if (!this.isWithinTable(nextPos)) {
    points.push(nextPos);
    break;
  }

  // first-hit test against every other ball
  if (!hasCollision) {
    for (const ball of balls) {
      if (ball.id === "cue") continue;
      const dist = nextPos.distanceTo(ball.position);
      if (dist < 0.0525) {          // 2 × ballRadius
        hasCollision = true;
        collisionPoint = nextPos.clone();
        break;
      }
    }
  }

  points.push(nextPos);
  currentPos.copy(nextPos);
  lineLength += step;

  if (hasCollision) break;
}

Two subtle things here that matter more than the algorithm itself:

The threshold is 2 × ballRadius, not ballRadius. Both objects are spheres, so contact happens when their centers are radius_a + radius_b apart. The naive “distance from point to line ≤ radius” test — which we started with, using a signed projection like this:

private pointToLineDistance(
  point: THREE.Vector3,
  lineStart: THREE.Vector3,
  lineDirection: THREE.Vector3,
): number {
  const startToPoint = point.clone().sub(lineStart);
  const projection = startToPoint.dot(lineDirection);
  const closestPoint = lineStart.clone().add(
    lineDirection.clone().multiplyScalar(projection),
  );
  return point.distanceTo(closestPoint);
}

— is what tutorials use, and it’s fine for picking the target ball, but it’s wrong for the stop point. The ray can be 1 × radius from the target’s center and still not have made contact, because the cue ball is 1 × radius wide. We use the point-to-line test to select the target, then walk the ray to find the actual first-contact position.

Marching in fixed steps beats an analytic solve for this specific case. Yes, you can solve ‖(P + t·d) − C‖ = 2r for t in closed form and get the exact collision point. We tried that first. It’s five lines of quadratic. But then you also want to know: does the line clip the cushion first? Does it exit through a pocket? Marching handles all of them uniformly and the branch cost is invisible next to the render loop.

The prediction line for the target ball — the yellow one in the screenshot — is even simpler once you have the collision point:

// direction the target ball leaves the collision, along the line
// from cue-contact-point to target-center
const targetDirection = targetBall.position
  .clone()
  .sub(collisionPoint)
  .normalize();

That single line, plus another 5 cm march until you hit a cushion, gives you the yellow arc. It’s not physically accurate (real snooker breaks are affected by cut angle, spin, and cushion friction), but it’s plausible, and human players read it as an intent line, not a prediction. That’s the right level of realism.


2. Rapier’s CCD flag is not optional for pool

We use @dimforge/rapier3d-compat for physics. The world setup is straightforward:

// SnookerPhysics.ts
const gravity = { x: 0, y: this.config.gravity, z: 0 };
this.world = new World(gravity);
this.eventQueue = new EventQueue(true);

Cushions are four thin cuboids around the perimeter, pockets are logical circles you check every step in checkPockets() (Rapier doesn’t need to know about them — a ball inside pocket radius is just despawned). None of that is the interesting part. This is:

const rigidBodyDesc = RigidBodyDesc.dynamic()
  .setTranslation(position.x, position.y, position.z)
  .setAdditionalMass(this.config.ballMass)
  .setLinearDamping(this.config.linearDamping)
  .setAngularDamping(this.config.angularDamping)
  .setCanSleep(true)
  .setCcdEnabled(true);   // ← 30 minutes of debugging, one line

The first version of the game shipped without .setCcdEnabled(true). On soft shots everything looked great. On hard breaks — the ones where the cue ball leaves at 3–4 m/s — balls occasionally vanished. We eventually caught it in a slow-mo replay: the ball had tunneled through a cushion between two physics steps.

The math is simple. Our physics runs at 60 Hz, so each step is 1/60 s ≈ 16.6 ms. A ball at 3 m/s moves 50 mm per step. Cushions are 100 mm thick in the collision proxy — so a swept ball can be entirely on the wrong side of the cushion the next time the solver looks at it. Rapier’s discrete collision detection is a point-in-time check; it never knows about the intermediate positions.

Continuous Collision Detection casts the collider forward along its velocity for the step and finds the earliest contact in that swept volume. It’s more expensive per body, but with 22 balls the cost is invisible on modern hardware.

Two related settings we tuned by feel, not by physics:

const DEFAULT_CONFIG: PhysicsConfig = {
  gravity: -9.81,
  ballMass: 0.2,        // real snooker ball is ~0.14 kg
  ballRadius: 0.02625,  // real is 0.02619 m
  friction: 0.1,
  restitution: 0.9,     // 0.95+ = bouncy castle, 0.8 = wet felt
  angularDamping: 0.8,
  linearDamping: 0.5,   // this is where realism dies or lives
};

linearDamping: 0.5 is not physical — real cloth-on-ball rolling resistance is closer to 0.015. But at that value balls roll for 30 seconds after a break shot, which is exactly correct and completely unplayable in a browser game where the player wants to shoot again. 0.5 gives you a “balls come to rest in about 4–6 seconds” feel that everybody’s muscle memory expects from mobile pool games. Physically correct is not the goal. Recognizably correct is the goal.

The “all balls stopped” detection is the other place people over-engineer. Rapier will happily tell you a body isSleeping() — but sleeping is asymmetric (it takes multiple frames to enter), and if you gate the next shot on “all sleeping” the player waits an extra half-second every turn. We use an explicit velocity check with a hysteresis counter:

areAllBallsStationary(): boolean {
  for (const [_, rigidBody] of this.balls) {
    const v = rigidBody.linvel();
    const speed = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
    if (speed > this.sleepingThreshold) return false;   // 0.001 m/s
  }
  return true;
}

private checkBallsStopped(): void {
  if (this.areAllBallsStationary()) {
    this.stoppedCheckFrames++;
    if (this.stoppedCheckFrames >= this.STOPPED_FRAMES_THRESHOLD) {
      this.onBallsStopped?.();      // 60 frames ≈ 1 s
    }
  } else {
    this.stoppedCheckFrames = 0;
  }
}

0.001 m/s is about 1 mm/s — below what a human would perceive as motion but still above numerical noise in Rapier’s integrator. The 60-frame gate keeps a single lucky frame of near-zero velocity from ending the shot early on a ball that’s still slowly rolling.


3. Never ship 22 ball PNGs

The naive way to render snooker balls is to author 22 textures in Photoshop, ship them in /public, load them with TextureLoader, and pray. It works — the download is only ~500 KB gzipped — but it’s the wrong shape for a browser game because:

  1. The PNGs are static. Want to swap the black ball’s number font for a Chinese numeral because the site is on a zh locale? Now you’re re-authoring 22 files.
  2. The PNGs render at whatever resolution you baked them at. A user on a 4K display sees the seams.
  3. First-load latency isn’t zero, and there’s a moment where the balls are untextured.

We generate every ball texture at boot from a <canvas>. The generator is one class with three shapes: white, red-solid, colored-with-number. Here’s the felt (table) generator, which is small enough to reproduce whole:

generateFeltTexture(): HTMLCanvasElement {
  const canvas = document.createElement("canvas");
  canvas.width = this.size;
  canvas.height = this.size;
  const ctx = canvas.getContext("2d")!;

  ctx.fillStyle = "#1a5c1a";
  ctx.fillRect(0, 0, this.size, this.size);

  this.addFabricNoise(ctx);      // per-pixel ±15 luminance jitter
  this.addFeltDirection(ctx);    // horizontal 3% opacity strokes every 4 px
  this.addFeltGradient(ctx);     // radial vignette, dark at edges
  return canvas;
}

The fabric noise pass is where people over-engineer. A single line loop over getImageData at 1024² is ~1 M pixels; you can afford any per-pixel operation once at boot. Ours is:

for (let i = 0; i < data.length; i += 4) {
  const noise = (Math.random() - 0.5) * 15;
  data[i]     = clamp(data[i]     + noise);
  data[i + 1] = clamp(data[i + 1] + noise);
  data[i + 2] = clamp(data[i + 2] + noise);
}

That’s it. You can spend a week trying to write a fragment shader that produces “cloth”, and it will look worse than five lines of Math.random() because the human eye sees cloth as high-frequency luminance jitter, not as a coherent pattern. When you’re generating once per session, the CPU is your friend.

For the balls themselves, the texture pipeline is:

generateAllBallCanvases(): Map<string, HTMLCanvasElement> {
  const canvases = new Map<string, HTMLCanvasElement>();

  canvases.set("cue", this.generateCueBallTexture());

  const redCanvas = this.generateRedBallTexture();  // one texture, 15 balls
  for (let i = 0; i < 15; i++) canvases.set(`red${i}`, redCanvas);

  for (const id of ["yellow", "green", "brown", "blue", "pink", "black"]) {
    canvases.set(id, this.generateBallTexture(SNOOKER_BALL_CONFIGS[id]));
  }
  return canvases;
}

Note the sharing: 15 red balls point at the same canvas, so the same THREE.CanvasTexture gets bound once on the GPU. There’s no per-ball rotation baked into the texture, and rotation-in-play is handled by the mesh’s quaternion — so identical textures on identical meshes are cheap. If you’re careful about .needsUpdate = false after the first upload, the driver never re-uploads.

The boot cost on a mid-range laptop is under 40 ms for all 22 balls plus the felt at 1024². It’s not free, but it’s paid once, at a moment when the user is looking at a “loading” screen anyway.


What we didn’t build

Two features that we consciously didn’t implement, and I think we made the right call:

Full physical spin (English). Real snooker uses ball rotation to influence post-collision paths — top/back/side spin all matter. Rapier’s applyTorqueImpulse gives you the mechanism, and we do fake it (see calculateSpin in the AI), but the aim line doesn’t try to predict the effect. Predicting spin realistically means simulating the whole shot forward for a few seconds and drawing the path. That’s a heavy loop to run every frame while the player drags the aim, and the payoff — the last 5% of realism — is invisible to anyone who isn’t a semi-pro. Skip it.

Cushion ricochet prediction. The white line stops at the first ball. It doesn’t bounce off cushions. The AI already does a “will this ball reach the pocket” check with a full physics sim, but doing it live while the player aims would burn a Rapier world every frame. If you want to preview a rail shot, you play it. This is fine — real players judge cushion angles by eye anyway.

Both are examples of the same rule: the thing you don’t build is the thing that makes browser games ship. Every feature you skip is a Chrome tab that stays under 60 fps.


The shape of the whole system

If you’re building something similar, this is the topology that ends up working:

input (mouse/touch) ──► cue-stick angle & power
                              │
                              ▼
                       AimingGuide.update(cueBall, angle, power, allBalls)
                              │            walks a ray, dashed line + impact dot
                              ▼
                       (user releases)
                              │
                              ▼
        SnookerPhysics.shootBall(cueId, force, spin)
                              │
                              ▼
              Rapier.step(dt)  — 60 Hz, CCD on
                              │
                    ┌─────────┼─────────────┐
                    ▼         ▼             ▼
             checkPockets  ballStates   isStationary + hysteresis
                    │         │             │
                    ▼         ▼             ▼
              ScoreEvent   Renderer   next-turn callback

Everything upstream of Rapier.step is a UI concern (aim, force, spin selection). Everything downstream is a game-state concern (score, foul detection, next player). The physics itself is a pure function — (worldₙ, impulse) → worldₙ₊₁ — and if you keep it that way, replays and AI simulation come for free. The AI opponent in SnookerAI.ts runs the exact same physics, on a cloned world, with different impulses, to score candidate shots. That’s only possible because nothing above the physics layer holds hidden state.


Coda

The three things in the TL;DR — first-hit aim line, CCD, canvas-baked textures — are each about a day of work individually. The version that shipped was the one where we stopped trying to be physically correct and started trying to be recognizably correct. A pool game that feels 95% right in 2 000 lines of TypeScript is a much better product than a pool game that feels 99% right and never ships.

Live demo: /gamecenter — pick Snooker, break, and watch the yellow line.

— Read Next —

推荐延伸阅读

更多关于 3D 渲染、物理仿真与网页游戏架构的深度技术解析

Three.js

A four-anchor raycast suspension for a tank in raw Rapier

Tank Battle's chassis rides on four downward raycasts, each of which contributes a spring impulse, a track-drive impulse, and a lateral friction impulse. It's the standard raycast-vehicle pattern from Unity's Wheel Collider or BeamNG, written straight against @dimforge/rapier3d-compat with no vehicle plugin. Here's the whole loop, the constants, the front/rear drive split, and the lateral-friction cliff that makes it feel like a tank.

阅读全文
Three.js

Three ways Rapier tells you two things touched, and when to use which

Rapier surfaces contact detection through three interfaces on top of the same underlying check — the raw event queue, R3F's `onCollisionEnter` prop, and sensor colliders with `onIntersectionEnter`. Same feature, three ergonomics, different tradeoffs. This post walks through each shape with real call sites from Tank Battle and 3D Race, and where each one belongs.

阅读全文
Three.js

From a keyboard press to an anchor impulse — Tank Battle's throttle chain

A tank's throttle in Tank Battle is not "one number that becomes engine force". It's a chain — key or joystick input → gear index into GEAR_KMH → target left/right track speeds with a steer differential → slew-limited actual track speeds → per-anchor drive impulses. Auto-shift decides gear from commanded track speed, not from the physics body's velocity, which produces one deliberate quirk. Here's every step.

阅读全文

订阅工程深潜文章 — 约每月一篇,没有营销邮件。