Skip to content

Commit b8e2bab

Browse files
ufrshubhamspydon
andauthored
fix: Ray direction normalization drift issues (#3841)
`raytrace()` reuses the same `RaycastResult` objects across bounces, so the reflected direction from one bounce becomes the incident direction for the next. `Vector2.reflect()` is not bit-exact, so each bounce introduces a tiny floating-point rounding error. After enough bounces the direction's length drifts just past the `1e-6` tolerance that `Ray2` enforces, causing a `direction must be normalized` assertion crash. Two fixes: - **`CircleHitbox`** and **`PolygonRayIntersection`** (used by `RectangleHitbox` / `PolygonHitbox`): call `normalize()` on the reflected direction before passing it to `Ray2`, so drift is corrected on every bounce. - **`raytrace_example`**: the initial ray direction was set via a cascade on the `Vector2` getter (`ray.direction..setValues(...)`), which bypasses the `direction=` setter and leaves the cached inverse values stale. Fixed to assign through the setter. Regression tests are included that inject a direction whose length² is already above the threshold (by writing directly to the underlying `Vector2`, bypassing the setter), then assert that `raycast()` does not throw. --------- Co-authored-by: Lukas Klingsbo <me@lukas.fyi>
1 parent 8b24518 commit b8e2bab

4 files changed

Lines changed: 99 additions & 3 deletions

File tree

examples/lib/stories/collision_detection/raytrace_example.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ bounce on will appear.
131131
}
132132

133133
final Ray2 _ray = Ray2.zero();
134+
final _rayDirection = Vector2(1, 1)..normalize();
134135
var _timePassed = 0.0;
135136

136137
@override
@@ -142,9 +143,7 @@ bounce on will appear.
142143
rayPaint.color = _colorTween.transform(0.5 + (sin(_timePassed) / 2))!;
143144
if (origin != null) {
144145
_ray.origin.setFrom(origin!);
145-
_ray.direction
146-
..setValues(1, 1)
147-
..normalize();
146+
_ray.direction = _rayDirection;
148147
collisionDetection
149148
.raytrace(
150149
_ray,

packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ class CircleHitbox extends CircleComponent with ShapeHitbox {
108108
(out?.reflectionRay?.direction ?? Vector2.zero())
109109
..setFrom(ray.direction)
110110
..reflect(_temporaryNormal);
111+
// Reflect() can introduce sub-epsilon drift. Normalize to keep Ray2's
112+
// unit-length assertion satisfied.
113+
reflectionDirection.normalize();
111114

112115
final reflectionRay =
113116
(out?.reflectionRay?..setWith(

packages/flame/lib/src/geometry/polygon_ray_intersection.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ mixin PolygonRayIntersection<T extends ShapeHitbox> on PolygonComponent {
5858
(out?.reflectionRay?.direction ?? Vector2.zero())
5959
..setFrom(ray.direction)
6060
..reflect(_temporaryNormal);
61+
// Reflect() can introduce sub-epsilon drift. Normalize to keep Ray2's
62+
// unit-length assertion satisfied.
63+
reflectionDirection.normalize();
6164

6265
final reflectionRay =
6366
(out?.reflectionRay?..setWith(

packages/flame/test/collisions/collision_detection_test.dart

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2063,6 +2063,97 @@ void main() {
20632063
expect(reflectionRay?.direction, Vector2(-1, 0)..normalize());
20642064
expect(results.first.normal, Vector2(-1, 0));
20652065
},
2066+
2067+
// Regression test for a crash in CircleHitbox.rayIntersection.
2068+
//
2069+
// After each bounce, raytrace() reuses the same RaycastResult object
2070+
// from the `out` list. The reflected direction from one bounce becomes
2071+
// the incident direction for the next. Each call to reflect() introduces
2072+
// a tiny floating-point rounding error, so after many bounces the
2073+
// direction vector's length drifts slightly away from 1.0.
2074+
//
2075+
// Ray2 requires directions to have length² within 1e-6 of 1.0. Once the
2076+
// drift exceeds that threshold, the next call to Ray2.setWith (which
2077+
// stores the reflected direction into the next ray) fires an assertion:
2078+
// 'direction must be normalized'
2079+
//
2080+
// Concrete values from a live failure:
2081+
// incidentDir.length2 = 1.0000009335 (just under the threshold)
2082+
// reflectionDir.length2 = 1.0000010253 (just over — assertion fires)
2083+
//
2084+
// To reproduce this deterministically in a test, we inject a direction
2085+
// that is already above the threshold by writing directly to the
2086+
// direction vector (bypassing the Ray2 setter, which would reject it).
2087+
// reflect() then preserves the drift and passes it to setWith, which
2088+
// triggers the assertion.
2089+
//
2090+
// The fix is to call normalize() on the reflected direction before
2091+
// passing it to Ray2, so the drift is corrected on every bounce.
2092+
'CircleHitbox raycast does not throw when incident direction has drift':
2093+
(game) async {
2094+
final world = (game as FlameGame).world;
2095+
// Circle at (0, 20) r=10, anchor center — a ray from the
2096+
// origin pointing up will hit it at (0, 10).
2097+
final circle = CircleComponent(
2098+
position: Vector2(0, 20),
2099+
radius: 10,
2100+
anchor: Anchor.center,
2101+
)..add(CircleHitbox());
2102+
await world.ensureAdd(circle);
2103+
2104+
// Start with a valid unit direction so Ray2 accepts it.
2105+
final ray = Ray2(
2106+
origin: Vector2.zero(),
2107+
direction: Vector2(0, 1),
2108+
);
2109+
// Inject a direction whose length² exceeds the 1e-6 threshold
2110+
// by writing to the direction vector directly, bypassing the
2111+
// Ray2 setter (which would reject it). This simulates the drift
2112+
// that builds up across many bounces in a real raytrace session.
2113+
// length² = (sqrt(1+2e-6))² = 1+2e-6, just above 1+1e-6.
2114+
ray.direction.setValues(0.0, sqrt(1.0 + 2e-6));
2115+
2116+
// Without the fix, reflect() passes the drifted direction to
2117+
// Ray2.setWith, which fires the assertion. With the fix,
2118+
// normalize() corrects the length before it reaches Ray2.
2119+
expect(
2120+
() => game.collisionDetection.raycast(ray),
2121+
returnsNormally,
2122+
reason:
2123+
'raycast on a CircleHitbox must not throw when the ray '
2124+
'direction has accumulated normalization drift',
2125+
);
2126+
},
2127+
2128+
// Same regression test for RectangleHitbox. RectangleHitbox uses the
2129+
// PolygonRayIntersection mixin which has the same reflect() pattern and
2130+
// therefore the same bug.
2131+
'RectangleHitbox raycast does not throw when '
2132+
'incident direction has drift': (game) async {
2133+
final world = (game as FlameGame).world;
2134+
// Rectangle positioned so a ray from the origin pointing
2135+
// right will hit it.
2136+
final rect = RectangleComponent(
2137+
position: Vector2(20, -5),
2138+
size: Vector2(10, 10),
2139+
)..add(RectangleHitbox());
2140+
await world.ensureAdd(rect);
2141+
2142+
final ray = Ray2(
2143+
origin: Vector2.zero(),
2144+
direction: Vector2(1, 0),
2145+
);
2146+
// Same drift injection as the CircleHitbox test above.
2147+
ray.direction.setValues(sqrt(1.0 + 2e-6), 0.0);
2148+
2149+
expect(
2150+
() => game.collisionDetection.raycast(ray),
2151+
returnsNormally,
2152+
reason:
2153+
'raycast on a RectangleHitbox must not throw when the ray '
2154+
'direction has accumulated normalization drift',
2155+
);
2156+
},
20662157
});
20672158
});
20682159

0 commit comments

Comments
 (0)