Skip to content

Voxel-ball bugs - ghost collisions and non-collisions #993

Description

@fenomas

Hi! I'm experimenting with Rapier for physics in a voxel world - mostly things work great, but I'm seeing a lot of non-physical behavior when ball primitives collide with edges or corners of adjacent voxels. I'm using Typescript but at a guess it's probably not TS related.

Visually, here's a repro of an egregious example - a ball object shoots into a 3x3x3 block of voxels, and depending on the positioning will pass through the chunk, only bouncing around the inside.

Screen.Recording.2026-08-16.at.22.49.31.mov

Code reproduction is below.

I noticed this TODO comment in the voxels-ball collision code, so I guess maybe this results from a known issue? If so, I thought maybe the repro steps might make it easier to fix.

But I should note that the thing in the video isn't the only bug I saw - generally whenever a ball primitive collides with the edge of two adjacent voxels, depending on the angle/velocity/etc the collision often sends the ball off in a nonphysical direction.

Thanks for checking!


Minimal repro of what's in the video (in an empty world except for the 3x3x3 span of solid voxels). If you check the console logs, after 15-20 ticks the ball projectile is fully within the solid voxels.

import RAPIER from "@dimforge/rapier3d-compat"

const timestep = 1 / 60
const ticks = 20
const projectileRadius = 0.25

const blockMinimum = 5
const blockMaximum = 7

const voxelData = new Int32Array(3 * 3 * 3 * 3)
let index = 0
for (let y = blockMinimum; y <= blockMaximum; y += 1) {
  for (let z = blockMinimum; z <= blockMaximum; z += 1) {
    for (let x = blockMinimum; x <= blockMaximum; x += 1) {
      voxelData[index] = x
      voxelData[index + 1] = y
      voxelData[index + 2] = z
      index += 3
    }
  }
}

/**
 * Reproduces a sphere shooting into the top edge of a 3×3×3 voxel cube,
 * then passing into the interior and bouncing around inside.
 */
export const runReproduction = async () => {
  await RAPIER.init()

  const world = new RAPIER.World({ x: 0, y: -10, z: 0 })
  const events = new RAPIER.EventQueue(true)
  try {
    const terrain = RAPIER.ColliderDesc.voxels(voxelData, { x: 1, y: 1, z: 1 })
      .setFriction(0.8)
      .setRestitution(0)
    world.createCollider(terrain)

    const projectile = world.createRigidBody(
      RAPIER.RigidBodyDesc.dynamic()
        .setTranslation(5.068835807168076, 10.666715670475918, 10.803566521990922)
        .setLinvel(5.966327745035736, -12.77696104762374, -14.182813529984893)
        .setCcdEnabled(true),
    )
    const projectileCollider = RAPIER.ColliderDesc.ball(projectileRadius)
      .setDensity(1)
      .setFriction(0.8)
      .setRestitution(0.45)
      .setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS)
    world.createCollider(projectileCollider, projectile)

    let collisionStartCount = 0
    for (let tick = 0; tick < ticks; tick += 1) {
      world.timestep = timestep
      world.step(events)
      events.drainCollisionEvents((_first, _second, started) => {
        if (started) collisionStartCount += 1
        if (started) console.log(`tick ${tick + 1}: collision start event`)
      })
      console.log(`tick ${tick + 1}: position=${JSON.stringify(projectile.translation())}`)
    }

    return {
      position: projectile.translation(),
      velocity: projectile.linvel(),
      collisionStartCount,
    }
  } finally {
    events.free()
    world.free()
  }
}

runReproduction().then((result) => {
  console.info("Rapier voxel-ball repro result", result)
})

Log output:

$ node repro.ts     
using deprecated parameters for the initialization function; pass a single object instead
tick 1: position={"x":5.168275356292725,"y":10.452030181884766,"z":10.567185401916504}
tick 2: position={"x":5.267714977264404,"y":10.234565734863281,"z":10.330803871154785}
tick 3: position={"x":5.367154598236084,"y":10.014324188232422,"z":10.094422340393066}
tick 4: position={"x":5.466594219207764,"y":9.791305541992188,"z":9.858040809631348}
tick 5: position={"x":5.566033840179443,"y":9.565509796142578,"z":9.621659278869629}
tick 6: position={"x":5.665473461151123,"y":9.336936950683594,"z":9.38527774810791}
tick 7: position={"x":5.764913082122803,"y":9.105583190917969,"z":9.148896217346191}
tick 8: position={"x":5.864352703094482,"y":8.871452331542969,"z":8.912514686584473}
tick 9: position={"x":5.963792324066162,"y":8.634544372558594,"z":8.676133155822754}
tick 10: position={"x":6.063231945037842,"y":8.394859313964844,"z":8.439751625061035}
tick 11: position={"x":6.1626715660095215,"y":8.152397155761719,"z":8.203370094299316}
tick 12: position={"x":6.262111186981201,"y":7.907155990600586,"z":7.966989040374756}
tick 13: collision start event
tick 13: position={"x":6.361550807952881,"y":7.65913724899292,"z":7.73060941696167}
tick 14: position={"x":6.4609904289245605,"y":7.408339977264404,"z":7.494229793548584}
tick 15: position={"x":6.56043004989624,"y":7.154765605926514,"z":7.257850170135498}
tick 16: position={"x":6.65986967086792,"y":6.898413181304932,"z":7.021470546722412}
tick 17: position={"x":6.7593092918396,"y":6.639282703399658,"z":6.785090923309326}
tick 18: position={"x":6.858748912811279,"y":6.37737512588501,"z":6.54871129989624}
tick 19: position={"x":6.958188533782959,"y":6.112689018249512,"z":6.312331676483154}
tick 20: position={"x":7.057628154754639,"y":5.8452253341674805,"z":6.075952053070068}
Rapier voxel-ball repro result {
  position: fA {
    x: 7.057628154754639,
    y: 5.8452253341674805,
    z: 6.075952053070068
  },
  velocity: fA {
    x: 5.966327667236328,
    y: -16.11031723022461,
    z: -14.18281364440918
  },
  collisionStartCount: 1
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions