logo

Babylon.js Market

By Lawrence

5 minutes

Catching up?

Dropping in at this lesson? One command reinstalls the library components the build uses so far and writes the scene as it stood at the end of the previous lesson to public/scenes/WingmanFlight-lesson-2/scene.json:

bjs download scene WingmanFlight-lesson-2 --all

Copy that scene.json over your src/scenes/wingman.json and you are caught up to the start of this lesson.

Last lesson you filled the field with rocks. But those rocks are fake. You line up a boulder and hold the trigger, and your shots pass right through it. You aim your nose at a tall spire and fly straight in. You come out the other side without a scratch. The rocks are just scenery, not something you can hit.

A real rock needs to do two things. Ram it at full speed and you should die. Shoot it and it should chip away. Both cases are really the same problem. You need to test a fast-moving thing against a spinning box. That thing might be a bullet or a ship at full speed. And it must not slip through the box between two frames.

The boxes are already in your scene. Lesson 1 stored each rock's half-extents in its Asteroid component. Asteroid1 holds { radius: 58, hx: 59, hy: 36, hz: 80 }. That is a hitbox shaped to fit the lumpy rock. A fat sphere would be worse, since it would kill you out in open space. This lesson writes the system that reads those boxes.

The system owns the ripple buffer, not the component

Every hit sends a ring rippling out across the rock's surface. That ring is just for looks. It should never be saved. Serialize an asteroid mid-fight, write it to JSON, and reload, and the ripple should not come back. So the pure component holds none of it. Instead, the system owns the ring buffer, a plain Float32Array. Picking a slot in it takes one small helper:

src/components/Asteroid/Asteroid.core.ts
/** Max simultaneous ripples in an asteroid's ring buffer. */
export const MAX_RIPPLES = 8;
/** Floats per ripple slot: [dirX, dirY, dirZ, age, amplitude]. */
export const RIPPLE_STRIDE = 5;

/** Allocate a fresh, empty ripple ring buffer for one asteroid. */
export function makeRippleBuffer(): Float32Array {
  return new Float32Array(MAX_RIPPLES * RIPPLE_STRIDE);
}

/**
 * Push one surface ripple into the ring buffer at unit direction (nx, ny, nz)
 * with starting `amplitude`. Free slot first; once full, overwrite the OLDEST
 * (largest age, age = the float at offset +3) so the freshest hit always shows.
 * Returns the new active count. Pure: mutates only the passed array.
 */
export function pushRipple(
  ripples: Float32Array,
  count: number,
  nx: number,
  ny: number,
  nz: number,
  amplitude: number,
): number {
  let slot: number;
  if (count < MAX_RIPPLES) {
    slot = count;
    count += 1;
  } else {
    slot = 0;
    let oldestAge = ripples[3];
    for (let i = 1; i < MAX_RIPPLES; i++) {
      const age = ripples[i * RIPPLE_STRIDE + 3];
      if (age > oldestAge) {
        oldestAge = age;
        slot = i;
      }
    }
  }
  const off = slot * RIPPLE_STRIDE;
  ripples[off] = nx;
  ripples[off + 1] = ny;
  ripples[off + 2] = nz;
  ripples[off + 3] = 0;
  ripples[off + 4] = amplitude;
  return count;
}

The buffer has eight slots of five floats each. Give pushRipple a fresh buffer and a unit direction, and it hands back the new count:

const buf = makeRippleBuffer();                // 8 slots × 5 floats, all zero
let count = pushRipple(buf, 0, 0, 0, 1, 0.18); // one ripple facing +z, amplitude 0.18
// → count === 1; slot 0 now reads [0, 0, 1, age:0, amp:0.18]

An empty slot fills first. Once all eight slots are full, the next hit overwrites the oldest one. The oldest is the slot with the largest age. So a rock under fire always shows its newest hits and never runs out of room. None of this touches the serialized component.

Storing the buffers in a WeakMap

The system keeps those buffers in a WeakMap, keyed by the component. This keeps the runtime state off the saved data, so the save-and-reload round-trip stays clean:

src/components/Asteroid/Asteroid.ts
/** Non-serializable runtime: one asteroid's ripple ring buffer + active count. */
interface AsteroidRipples {
  buf: Float32Array;
  count: number;
}

export class AsteroidCollisionSystem extends System {
  protected query = { required: [Transform6DOFComponent, PartsComponent] };

  /** Ship hit-sphere radius — OBB half-extents are grown by this for the ram test. */
  private readonly shipCollisionRadius = 3.2;
  /** Swept-segment samples for the bullet pass (catches tunneling). */
  private readonly sweepSamples = 5;

  /** Per-asteroid ripple ring buffer; created lazily on first hit. */
  private ripples = new WeakMap<AsteroidComponent, AsteroidRipples>();

  private _r: Vec3 = [1, 0, 0];
  private _u: Vec3 = [0, 1, 0];
  private _f: Vec3 = [0, 0, 1];

  constructor(eventBus: EventBus) {
    super(eventBus);
    this.priority = 55;
  }

  protected onInitialize(): void {
    // A rammed ship sheds shootable debris via the shared destroyShip path, so
    // the `debris` pool must exist even if no gun system registered it.
    // registerDebrisPool is idempotent (World.registerPool ignores a dup name).
    if (this.world) registerDebrisPool(this.world);
  }

  /** Lazily allocate the ripple buffer for an asteroid component. */
  private getRipples(comp: AsteroidComponent): AsteroidRipples {
    let rt = this.ripples.get(comp);
    if (!rt) {
      rt = { buf: makeRippleBuffer(), count: 0 };
      this.ripples.set(comp, rt);
    }
    return rt;
  }

Two details stand out at the top. priority = 55 runs this system just above BulletImpactSystem (52). So if a bullet must cross a rock to reach an enemy behind it, the rock eats the bullet first. And onInitialize registers the debris pool. It does this safely even if it runs twice. A rammed ship breaks into shootable chunks through the shared kill path. That pool has to exist even if no gun ever registered it.

Pass one: a shot chips the rock

src/components/Asteroid/Asteroid.ts
protected onUpdate(dt: number): void {
  if (!this.world || dt <= 0) return;
  const asteroids = this.collectAsteroids();
  if (asteroids.length === 0) return;

  const toRemove: Entity[] = [];

  // ---- Pass 1: bullets vs rock --------------------------------------------
  const bullets = this.collectBullets();
  for (const bullet of bullets) {
    if (toRemove.indexOf(bullet) >= 0) continue;
    const b = bullet.get(BulletComponent)!;
    const bt = bullet.get(Transform6DOFComponent);
    if (!bt) continue;
    const bv = bullet.get(Velocity6DOFComponent);
    const curX = bt.x, curY = bt.y, curZ = bt.z;
    const prevX = bt.x - (bv?.vx ?? 0) * dt;
    const prevY = bt.y - (bv?.vy ?? 0) * dt;
    const prevZ = bt.z - (bv?.vz ?? 0) * dt;

    for (const a of asteroids) {
      if (a.id === b.ownerEntityId) continue;
      if (toRemove.indexOf(a) >= 0) continue;
      const apos = a.get(Transform6DOFComponent);
      const ac = a.get(AsteroidComponent);
      if (!apos || !ac) continue;

      // Broad-phase: midpoint sphere reject using the longest half-extent + a
      // small bullet margin.
      const midX = (curX + prevX) * 0.5 - apos.x;
      const midY = (curY + prevY) * 0.5 - apos.y;
      const midZ = (curZ + prevZ) * 0.5 - apos.z;
      const longest = Math.max(ac.hx, ac.hy, ac.hz) + 5;
      if (midX * midX + midY * midY + midZ * midZ > longest * longest) continue;

      quatRight(apos.q, this._r);
      quatUp(apos.q, this._u);
      quatForward(apos.q, this._f);
      const rxw = this._r[0], ryw = this._r[1], rzw = this._r[2];
      const ux = this._u[0], uy = this._u[1], uz = this._u[2];
      const fx = this._f[0], fy = this._f[1], fz = this._f[2];

      let hitX = 0, hitY = 0, hitZ = 0;
      let hit = false;
      for (let s = 0; s <= this.sweepSamples; s++) {
        const u = s / this.sweepSamples;
        const sxw = prevX + (curX - prevX) * u;
        const syw = prevY + (curY - prevY) * u;
        const szw = prevZ + (curZ - prevZ) * u;
        const dlx = sxw - apos.x, dly = syw - apos.y, dlz = szw - apos.z;
        const lx = dlx * rxw + dly * ryw + dlz * rzw;
        const ly = dlx * ux + dly * uy + dlz * uz;
        const lz = dlx * fx + dly * fy + dlz * fz;
        if (lx >= -ac.hx && lx <= ac.hx &&
            ly >= -ac.hy && ly <= ac.hy &&
            lz >= -ac.hz && lz <= ac.hz) {
          hitX = sxw; hitY = syw; hitZ = szw;
          hit = true;
          break;
        }
      }
      if (!hit) continue;

      toRemove.push(bullet);
      const bvx = bv?.vx ?? 0, bvy = bv?.vy ?? 0, bvz = bv?.vz ?? 0;
      this.eventBus.emit(BulletEvents.HIT, {
        shooterId: b.ownerEntityId, targetId: a.id,
        part: 'asteroid', partHp: 9999,
        x: hitX, y: hitY, z: hitZ,
        dirX: bvx, dirY: bvy, dirZ: bvz,
      });

      // Ripple at the impact point — direction from asteroid centre out.
      let hnx = hitX - apos.x;
      let hny = hitY - apos.y;
      let hnz = hitZ - apos.z;
      const hlen = Math.hypot(hnx, hny, hnz) || 1;
      hnx /= hlen; hny /= hlen; hnz /= hlen;
      const rt = this.getRipples(ac);
      rt.count = pushRipple(rt.buf, rt.count, hnx, hny, hnz, 0.18);

      // Bullet imparts a tiny push (+ spin) scaled inversely by original
      // radius — a boulder barely budges, a chunk drifts noticeably.
      const av = a.get(Velocity6DOFComponent);
      if (av) {
        const bspd = Math.hypot(bvx, bvy, bvz) || 1;
        const impulse = 6 / Math.max(20, ac.originalRadius);
        av.vx += (bvx / bspd) * impulse;
        av.vy += (bvy / bspd) * impulse;
        av.vz += (bvz / bspd) * impulse;
        const txr = hny * bvz - hnz * bvy;
        const tyr = hnz * bvx - hnx * bvz;
        const tzr = hnx * bvy - hny * bvx;
        const tscale = 0.0008 * impulse;
        av.wx += txr * tscale;
        av.wy += tyr * tscale;
        av.wz += tzr * tscale;
      }

      // Shrink 1.5% of original radius per hit; despawn below 40% of original
      // (the bump texture mips to flat below that and the remnant reads 2D).
      const shrinkAmt = ac.originalRadius * 0.015;
      ac.radius = Math.max(0, ac.radius - shrinkAmt);
      const scale = ac.radius / ac.originalRadius;
      ac.hx = ac.originalHx * scale;
      ac.hy = ac.originalHy * scale;
      ac.hz = ac.originalHz * scale;
      if (ac.radius < ac.originalRadius * 0.40) toRemove.push(a);
      break;
    }
  }

Read the code by what it does to one bullet. First it rebuilds the bullet's path. It takes this frame's position and steps back one dt of velocity to find where the bullet sat last frame. Then it samples that line at a few points. Each point gets transformed into the rock's local frame and tested against the half-extents with an AABB check. This sweep matters. A fast bullet can cover more ground in one frame than the rock is wide. Without the sweep, it would skip right over the rock. On the first sample inside the box, the code uses up the bullet and emits BulletEvents.HIT (bullet.hit) tagged part: 'asteroid'. That event is only a signal for hit-sparks and sound. It is not a damage route, and the rock never reaches HealthSystem. Then the code adds a ripple. It nudges the rock with a push and spin scaled inversely by size, so a boulder barely twitches and a small chunk drifts. It shrinks the rock by 1.5% of its original radius. Once the rock drops under 40% of its build size, the code queues it to despawn.

Pass two: a ram destroys your ship

src/components/Asteroid/Asteroid.ts
  // ---- Pass 2: ships vs rock ----------------------------------------------
  for (const ship of this.entities) {
    const st = ship.get(Transform6DOFComponent);
    if (!st) continue;
    for (const a of asteroids) {
      if (toRemove.indexOf(a) >= 0) continue;
      const ap = a.get(Transform6DOFComponent);
      const ac = a.get(AsteroidComponent);
      if (!ap || !ac) continue;

      // Broad-phase: cheap sphere reject using the longest half-extent + ship radius.
      const longest = Math.max(ac.hx, ac.hy, ac.hz) + this.shipCollisionRadius;
      const dx = st.x - ap.x;
      const dy = st.y - ap.y;
      const dz = st.z - ap.z;
      if (dx * dx + dy * dy + dz * dz > longest * longest) continue;

      // Narrow-phase: transform the ship into the rock's body frame, AABB-test
      // against half-extents grown by the ship radius.
      quatRight(ap.q, this._r);
      quatUp(ap.q, this._u);
      quatForward(ap.q, this._f);
      const lx = dx * this._r[0] + dy * this._r[1] + dz * this._r[2];
      const ly = dx * this._u[0] + dy * this._u[1] + dz * this._u[2];
      const lz = dx * this._f[0] + dy * this._f[1] + dz * this._f[2];
      const phx = ac.hx + this.shipCollisionRadius;
      const phy = ac.hy + this.shipCollisionRadius;
      const phz = ac.hz + this.shipCollisionRadius;
      if (lx >= -phx && lx <= phx &&
          ly >= -phy && ly <= phy &&
          lz >= -phz && lz <= phz) {
        // Big ripple at the contact point — a whole ship's worth of mass.
        let hnx = st.x - ap.x;
        let hny = st.y - ap.y;
        let hnz = st.z - ap.z;
        const hlen = Math.hypot(hnx, hny, hnz) || 1;
        hnx /= hlen; hny /= hlen; hnz /= hlen;
        const rt = this.getRipples(ac);
        rt.count = pushRipple(rt.buf, rt.count, hnx, hny, hnz, 0.6);
        destroyShip(this.world, this.eventBus, ship);
        break;
      }
    }
  }

  for (const e of toRemove) this.world.removeEntity(e);
}

Testing a ship against a box has a shortcut. It is the same as testing a single point against a bigger box. You just grow the box by the ship's radius. So the code adds the 3.2-unit hull radius to each half-extent. Then it checks the ship's centre in the rock's frame. A contact adds a big ripple and sends the rammer through Course 2's shared destroyShip. That is the same death a gun kill triggers. The rock flies on, unharmed. The queued removals run at the end.

Wire it into the cabinet

The scene loader only auto-adds a system when it finds a ${Name}System. This one exports as AsteroidCollisionSystem, so the loader skips it. You add it yourself, once, after loadScene:

if (!world.getSystem(AsteroidCollisionSystem))
  world.addSystem(new AsteroidCollisionSystem(world.getEventBus()));

There is no new pool here. The system reads the same bullet pool that MachineGunSystem registered back in Course 2.

Run it

Fire at a rock and it chips. A ring blooms at the hit, the rock shrinks a bit, and after enough hits it pops out below 40% size. Open the EventBus panel (≈F4) and hold the trigger. Watch bullet.hit tick under the rock's entity, with part: 'asteroid' in the row dump. Now aim your nose at the spire and fly in. Your ship dies through the same chain a gun kill fires in Course 2. The death effect blooms, and the rock sails on without a mark. The field can hurt you now. Next up: a rock that shoots back.

Continue reading

Unlock the Full Course

Every lesson, the runnable examples, and the finished build — yours to keep.

$9one-time

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search