Skip to content

fix: heightfield ray-casts missing vertical rays near grid lines - #462

Open
MinimalCpp wants to merge 2 commits into
dimforge:masterfrom
MinimalCpp:fix/heightfield-vertical-ray-grid-lines
Open

MinimalCpp wants to merge 2 commits into
dimforge:masterfrom
MinimalCpp:fix/heightfield-vertical-ray-grid-lines

Conversation

@MinimalCpp

Copy link
Copy Markdown

Motivation

A ray cast straight down onto a HeightField misses when it lies within a few rounding steps of
a grid line. A flat heightfield is enough to show it:

let heightfield = HeightField::new(Array2::zeros(41, 41), Vector::new(40.0, 1.0, 40.0));
let ray = Ray::new(Vector::new(4.0, 1.0, 0.5), -Vector::Y);
assert!(heightfield.cast_local_ray(&ray, Real::MAX, true).is_some()); // fails on master

On master, 11 of the 41 whole-metre points along that line miss: x = -11, -8, -4, -3, -2, 2, 4,
7, 9, 14 and 16.

We hit this in a kinematic character controller: it finds the ground under a capsule with a
vertical ray, and that ray sometimes found none while the capsule stood on flat terrain. In the
game (parry3d 0.31.1, f32), the widest miss we measured from a grid line was:

Heightfield Widest miss
40 m, 41 samples 2e-6 m
1000 m, 1001 samples 5e-5 m
1000 m, 513 samples 2.5e-5 m
1000 m, 101 samples 1e-4 m

These are the widest misses among the points the game itself sampled; the scans under Results
visit every float near each line, so their widest miss can be larger.

The game works around it by casting the ray again from two points a few millimetres to the side
of the axis, 45° apart. That leaves the last 3 mm inside the heightfield's outer border
uncovered, and every other ray cast down on the terrain, such as the one at a capsule's edge or a
later foot-placement probe, needs the same guard.

Cause

3D, three causes:

  • The ray cast takes its start cell from cell_at_point, which computes
    floor((x / scale.x + 0.5) / cell_width). The cell's triangles take their vertices from x_at
    and z_at, which compute (-0.5 + cell_width * j) * scale.x. Dividing and multiplying round
    differently, so within a few rounding steps of a grid line the two can put the point on
    opposite sides of that line. The ray then starts in a cell whose triangles stop just short of
    it. A vertical ray has toi_x = toi_z = Real::MAX, so the walk ends after that first cell and
    the ray misses. A ray tilted along the grid line only, e.g. by (0, -1, -1e-4), never crosses
    the line and misses the same way.
  • A ray exactly on a grid line meets the cells on both of its sides on their shared edge, and
    the triangles' rounding decides which of the two reports the hit. When it is the one beside
    the ray's start cell, or the start cell is removed (CELL_REMOVED), the ray misses.
  • The last grid line, -0.5 + cell_width * ncols, rounds a step short of 0.5, the edge of the
    Aabb, for some cell counts (41 among them), which leaves a sliver along that edge without
    triangles.

2D, the same three, at grid points and segments, and two more:

  • segment_at and map_elements_in_local_aabb end a segment at x0 + seg_length, not where
    the next one starts, which leaves gaps of a few rounding steps between segments. On the 40 m
    heightfield, segment 19 ends at -2.2e-7 and segment 20 starts at 0.
  • For a vertical ray exactly on a sloped segment's end, closest_points_line_line_parameters
    can return a parameter outside of [0, 1] on the segment, e.g. t = -5e-7.

The change

  • HeightField::cell_at_point (2D and 3D) moves the quantized cell by one when the point lies
    strictly beyond one of the cell's grid lines as x_at/z_at (2D: the segment's ends) place
    them. With a positive scale, the cell it returns then spans the point, edges included, unless
    the cells are so narrow that rounding the point's coordinate spans more than one (we saw that
    only near 20 million cells). An axis whose scale is not positive (a mirrored heightfield) keeps
    the old cell.
  • A ray that runs exactly along a grid line without crossing it (its direction is 0 across the
    line) also tests the cells on the line's other side, at each step of the walk; the first hit
    wins, since both cells meet the ray only on their shared edge. In 2D, a vertical ray exactly
    on a grid point tests the segment beside its own after a miss.
  • The last grid line lies at exactly 0.5 before scaling, for x_at/z_at, the triangles, the
    segments and map_elements_in_local_aabb alike.
  • 2D: segment_at and map_elements_in_local_aabb compute a segment's end like the next
    segment's start, so neighbouring segments share their end exactly. This moves segment ends by
    a few rounding steps.
  • 2D: for a vertical ray, the first-segment test takes the parameter on the segment from the
    ray's x-coordinate, which cell_at_point now puts within the segment's span.

Not changed: closest_cell_at_point (used by triangles_around_point), and signed_x_at/
signed_z_at with the shape-cast walk that uses them. Negative (mirrored) scales are not
covered: every ray we cast down at a mirrored heightfield missed, on master and with this PR.

Results

Scratch scans, not part of the PR, all f32:

  • Flat: vertical rays at every grid line and up to 4096 rounding steps to each side, on one
    line, the other, or both.
  • Rough: heights within ±20, both cell subdivisions in a checkerboard; vertical rays up to 64
    rounding steps from each line, compared against the triangles of the 3 × 3 cells around the
    ray.
  • Diagonal: vertical rays near each cell's diagonal on a flat heightfield.
Heightfield Flat 3D, master (widest) Flat 2D, master (widest) Rough 3D, master Diagonal 3D, master
40 m, 41 samples 8 312 of 655 442 (3.8e-6 m) 8 318 of 655 442 (3.8e-6 m) 992 of 41 288 0 of 20 160
1000 m, 1001 samples 10 410 of 16 386 002 (9.2e-5 m) 6 892 of 16 386 002 (9.2e-5 m) 21 024 of 1 032 008 0 of 504 000
1000 m, 513 samples 11 868 of 8 389 634 (3.05e-5 m) 11 868 of 8 389 634 (3.05e-5 m) 12 752 of 528 392 0 of 258 048
1000 m, 101 samples 8 604 of 1 638 602 (9.2e-5 m) 8 616 of 1 638 602 (9.2e-5 m) 2 160 of 103 208 0 of 50 400
1000 m, 42 samples 226 of 671 832 (9.2e-5 m) 224 of 671 830 (9.2e-5 m) 888 of 42 320 6 of 20 664
40 m, 210 samples 1 562 of 3 424 678 (3.8e-6 m) 1 416 of 3 424 677 (3.8e-6 m) 5 352 of 215 696 0 of 105 336

With this PR, no flat or rough ray missed, and every rough time of impact matched the nearest
triangle. The flat scans cast up to four rays fewer at 42 and 210 samples: the last line moved to
the Aabb's edge, and the scans skip coordinates beyond it. The six diagonal misses at 42 samples
remain, the same as on master: each lies in the right cell, and both of its triangles miss the
ray on their shared diagonal, so the Triangle ray cast itself is not watertight there. This PR
leaves that.

Tests

New tests in crates/parry{2,3}d/tests/heightfield_ray_cast.rs. All but the mirrored ones fail
on master:

  • a_vertical_ray_on_a_grid_{line,point}_hits_a_flat_heightfield: vertical rays on flat
    heightfields of six sizes (the four above, 1000 m with 42 samples and 40 m with 210), at each
    grid line and 16 rounding steps to each side. Each line is taken as x_at places it (2D: as
    the segments on either side end), as a caller would compute it, and as the nearest float to
    the exact value. In 3D the rays lie on one line, the other, or both.
  • a_ray_near_a_grid_{line,point}_gives_the_nearest_{triangle,segment}_hit: a rough 10 × 10
    heightfield (3D: both subdivisions in a checkerboard), with rays at the same positions cast
    straight down, tilted by 1e-4 along each axis, and tilted across several cells. Each hit must
    have the time of impact of the nearest triangle (segment) cast on its own.
  • a_vertical_ray_on_a_grid_line_hits_beside_a_removed_cell (3D): for each interior grid line
    and each side of it, the row and column of cells on that side are removed; rays on the line,
    straight down or tilted along it, and straight down where two lines cross with three of the
    four cells removed, must still hit.
  • a_ray_along_a_grid_line_hits_beside_removed_cells_after_walking (3D): on a slope, rays that
    run along a grid line, in z and in x, meet the surface several rows (columns) in, with the
    column (row) they start in removed; each must give the nearest triangle's hit, on the triangle
    its subshape names. A ray on whose line no triangle left reports a hit is skipped (one of the
    18).
  • a_vertical_ray_on_a_grid_point_hits_beside_a_removed_segment (2D): the same at each interior
    grid point.
  • the_cell_at_a_point_of_a_mirrored_heightfield_lies_under_the_point: cell_at_point at each
    cell's middle with a negative scale. These pass on master too; they guard the mirrored case.

Each of these changes makes at least one test fail: dropping the move to the lower or to the
upper cell, or the mirrored guard, in 2D or 3D; putting the last line back at
-0.5 + cell_width * cells, in 2D or 3D; dropping the column or the row beside the ray, or
testing them only in the walk's first cell (3D); dropping the segment beside the ray (2D);
restoring x0 + seg_length; restoring the line-line parameters for a vertical ray in 2D; and
picking the farther of a cell's two triangle hits. Two survive, as
equivalent: >=/<= in place of >/< in the snap (a point on a line lies in both cells, and
the cast tests both), and picking the later of the beside cells' hits (both meet the ray only on
their shared edge).

Checks

Run locally on Windows with Rust 1.99 (MSVC):

  • Pass, with -D warnings: cargo clippy; the builds of parry2d, parry3d and simd8; the
    serialization and enhanced-determinism checks; the wasm32 builds of parry2d and parry3d.
  • Pass: the no-std builds (x86_64, thumbv7em, thumbv7em with alloc) and the nightly bench
    check.
  • cargo test --features wavefront (2 562 passed) and --features parallel (2 556 passed), with
    -D warnings -A deprecated: Rust 1.99 deprecates std::f32::EPSILON, which an existing test
    in parry3d's library uses, so with -D warnings alone the tests do not compile, on master
    as well.
  • Fail the same way on master: cargo fmt --check (rustfmt 1.99 reformats the
    chain_method! calls in src/query/query_dispatcher.rs; the files of this PR are clean) and
    cargo doc with -D warnings (two redundant explicit link targets).
  • Not run: publish.sh --dry-run.

LLM use

This PR was written with Claude Code (Claude Opus 5.5). It diagnosed the misses, wrote the fix and the tests, ran the checks, the scans and the mutation checks, and drafted this description. The change was then reviewed by Claude Code's own review (Claude Sonnet 5.5 against coding standards, Claude Opus 5.5 against the problem statement) and in two rounds by GPT-6.1-Sol (through Codex); their findings were either fixed before opening this PR. I reviewed the diff and the test results myself, and I am happy to answer questions or rework anything you would like done differently.

MinimalCpp and others added 2 commits November 5, 2026 11:51
A ray cast straight down onto a `HeightField`, or tilted along a grid
line only, missed within a few rounding steps of that line, three ways:

- Its start cell came from `cell_at_point`, which divides by the cell
  size, while the cell's triangles take their vertices from `x_at`/
  `z_at`, which multiply by it: near a line the two disagree, the ray
  lands in the cell whose triangles stop just short of it, and a ray
  that does not move across the line never visits the other one.
  `cell_at_point` now moves a point that lies beyond one of its cell's
  lines, as the triangles place them, to the neighbouring cell. A
  mirrored axis (a negative scale) keeps the old cell.
- A ray exactly on a grid line touches the cells on both sides; which
  of the two reports a hit on their shared edge is up to the triangles'
  rounding, and the one under the ray may be removed. Such a ray now
  also tests the cells on the line's other side, and the nearest hit
  wins.
- The last grid line, `-0.5 + cell_size * cells`, rounds short of
  `0.5`, the edge of the Aabb, for some cell counts (41 among them). It
  now lies at exactly `0.5`, for `x_at`/`z_at`, the triangles and the
  local-Aabb walk alike.

The 2D heightfield missed the same ways, and two more: a segment ended
at `x0 + width`, not where the next one starts, which left gaps of a
few rounding steps, and the line-line parameters of a vertical ray on a
sloped segment's end could fall outside of `[0, 1]`. Neighbouring
segments now share their end, and a vertical ray takes its parameter
on the segment from its x-coordinate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant