From df3cd5a3b7a026c93e5e99ce60e468fb00ff2e07 Mon Sep 17 00:00:00 2001 From: Luan Nico Date: Sun, 19 Apr 2026 19:44:00 -0400 Subject: [PATCH] fix: Fix raycasting "pass-through" collision inaccuracies with better modeling --- .../collisions/hitboxes/circle_hitbox.dart | 151 ++++++++++-------- .../geometry/polygon_ray_intersection.dart | 16 +- .../collisions/collision_detection_test.dart | 87 ++++++++++ 3 files changed, 181 insertions(+), 73 deletions(-) diff --git a/packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart index b0b3cae3051..c384efdc2f1 100644 --- a/packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart @@ -1,5 +1,7 @@ // ignore_for_file: comment_references +import 'dart:math'; + import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/geometry.dart'; @@ -44,92 +46,101 @@ class CircleHitbox extends CircleComponent with ShapeHitbox { // the parent size and the radius is defined from the shortest side. } - static final _temporaryLineSegment = LineSegment.zero(); static final _temporaryNormal = Vector2.zero(); static final _temporaryCenter = Vector2.zero(); static final _temporaryAbsoluteCenter = Vector2.zero(); - static final _temporaryOrigin = Vector2.zero(); @override RaycastResult? rayIntersection( Ray2 ray, { RaycastResult? out, }) { - var isInsideHitbox = false; - _temporaryLineSegment.from.setFrom(ray.origin); - // Adding a small value to the origin to avoid the ray to be on the edge - // of the circle and then directly intersecting and causing the reflecting - // ray to go in the wrong direction. - _temporaryOrigin.setValues( - ray.origin.x + ray.direction.x * 0.00001, - ray.origin.y + ray.direction.y * 0.00001, - ); + final effectiveRadius = scaledRadius; _temporaryAbsoluteCenter.setFrom(absoluteCenter); + + // Solve ray-circle intersection analytically. + // Ray: P + t*D where |D| = 1, Circle: |X - C|² = r² + // Substituting: t² + bt + c = 0 _temporaryCenter - ..setFrom(_temporaryAbsoluteCenter) - ..sub(ray.origin); - - if (_temporaryCenter.isZero()) { - // If _temporaryCenter is zero, it's projection onto ray.direction - // will be zero. In that case, directly use ray.direction as temp - // end point of line segment. - _temporaryLineSegment.to.setFrom(ray.direction); - } else { - _temporaryCenter.projection(ray.direction, out: _temporaryLineSegment.to); - _temporaryLineSegment.to - ..x *= (ray.direction.x.sign * _temporaryLineSegment.to.x.sign) - ..y *= (ray.direction.y.sign * _temporaryLineSegment.to.y.sign); + ..setFrom(ray.origin) + ..sub(_temporaryAbsoluteCenter); // P - C + final b = 2 * _temporaryCenter.dot(ray.direction); + final c = _temporaryCenter.length2 - effectiveRadius * effectiveRadius; + + final discriminant = b * b - 4 * c; + if (discriminant < 0) { + out?.reset(); + return null; } - final effectiveRadius = scaledRadius; - if (_temporaryOrigin.distanceToSquared(_temporaryAbsoluteCenter) < - effectiveRadius * effectiveRadius) { - _temporaryLineSegment.to.scaleTo(2 * effectiveRadius); + final sqrtDiscriminant = sqrt(discriminant); + final t1 = (-b - sqrtDiscriminant) / 2; + final t2 = (-b + sqrtDiscriminant) / 2; + + // Use a radius-relative epsilon. Vector2 stores components in Float32List, + // so coordinates near the boundary carry ~6 digits of precision. After + // reflecting, the stored origin can be off by up to r*1e-4, producing a + // spurious near-zero t from recomputing against the same circle. + final epsilon = effectiveRadius * 1e-4; + final double t; + final bool isInsideHitbox; + if (t1 > epsilon) { + t = t1; + isInsideHitbox = false; + } else if (t2 > epsilon) { + t = t2; isInsideHitbox = true; - } - _temporaryLineSegment.to.add(ray.origin); - final intersections = lineSegmentIntersections(_temporaryLineSegment).where( - (i) => i.distanceToSquared(ray.origin) > 0.0000001, - ); - if (intersections.isEmpty) { + } else { out?.reset(); return null; - } else { - final result = out ?? RaycastResult(); - final intersectionPoint = intersections.first; - _temporaryNormal - ..setFrom(intersectionPoint) - ..sub(_temporaryAbsoluteCenter) - ..normalize(); - if (isInsideHitbox) { - _temporaryNormal.invert(); - } - final reflectionDirection = - (out?.reflectionRay?.direction ?? Vector2.zero()) - ..setFrom(ray.direction) - ..reflect(_temporaryNormal); - // Reflect() can introduce sub-epsilon drift. Normalize to keep Ray2's - // unit-length assertion satisfied. - reflectionDirection.normalize(); - - final reflectionRay = - (out?.reflectionRay?..setWith( - origin: intersectionPoint, - direction: reflectionDirection, - )) ?? - Ray2( - origin: intersectionPoint, - direction: reflectionDirection, - ); - - result.setWith( - hitbox: this, - reflectionRay: reflectionRay, - normal: _temporaryNormal, - distance: ray.origin.distanceTo(intersectionPoint), - isInsideHitbox: isInsideHitbox, - ); - return result; } + + // Intersection point = origin + t * direction. + _temporaryCenter + ..setFrom(ray.direction) + ..scale(t) + ..add(ray.origin); + + // Normal at intersection: direction from center to hit point. + _temporaryNormal + ..setFrom(_temporaryCenter) + ..sub(_temporaryAbsoluteCenter) + ..normalize(); + + // Snap intersection to exact boundary to prevent numerical drift. + _temporaryCenter + ..setFrom(_temporaryNormal) + ..scale(effectiveRadius) + ..add(_temporaryAbsoluteCenter); + + if (isInsideHitbox) { + _temporaryNormal.invert(); + } + + final result = out ?? RaycastResult(); + final reflectionDirection = + (out?.reflectionRay?.direction ?? Vector2.zero()) + ..setFrom(ray.direction) + ..reflect(_temporaryNormal); + reflectionDirection.normalize(); + + final reflectionRay = + (out?.reflectionRay?..setWith( + origin: _temporaryCenter, + direction: reflectionDirection, + )) ?? + Ray2( + origin: _temporaryCenter, + direction: reflectionDirection, + ); + + result.setWith( + hitbox: this, + reflectionRay: reflectionRay, + normal: _temporaryNormal, + distance: t, // |D| = 1, so parametric t equals Euclidean distance + isInsideHitbox: isInsideHitbox, + ); + return result; } } diff --git a/packages/flame/lib/src/geometry/polygon_ray_intersection.dart b/packages/flame/lib/src/geometry/polygon_ray_intersection.dart index 3212dc5f506..45088fc4bb2 100644 --- a/packages/flame/lib/src/geometry/polygon_ray_intersection.dart +++ b/packages/flame/lib/src/geometry/polygon_ray_intersection.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/geometry.dart'; @@ -20,12 +22,20 @@ mixin PolygonRayIntersection on PolygonComponent { LineSegment? closestSegment; var crossings = 0; var isOverlappingPoint = false; + // Float32List (used by Vector2) carries ~7 significant digits. After + // reflecting, the stored origin can drift by up to |coord| * 2^-23. + // Scale epsilon to the origin's magnitude so we skip self-intersections + // without missing real hits. + final epsilon = + max( + 1.0, + max(ray.origin.x.abs(), ray.origin.y.abs()), + ) * + 1e-4; for (var i = 0; i < vertices.length; i++) { final lineSegment = getEdge(i, vertices: vertices); final distance = ray.lineSegmentIntersection(lineSegment); - // Using a small value above 0 just because of rounding errors later that - // might cause a ray to go in the wrong direction. - if (distance != null && distance > 0.0000000001) { + if (distance != null && distance > epsilon) { crossings++; if (distance < closestDistance) { isOverlappingPoint = false; diff --git a/packages/flame/test/collisions/collision_detection_test.dart b/packages/flame/test/collisions/collision_detection_test.dart index 2892174a7e4..9de494659ac 100644 --- a/packages/flame/test/collisions/collision_detection_test.dart +++ b/packages/flame/test/collisions/collision_detection_test.dart @@ -2154,6 +2154,93 @@ void main() { 'direction has accumulated normalization drift', ); }, + + 'ray does not escape circle at high depth from center': (game) async { + final world = (game as FlameGame).world; + final circle = CircleComponent( + position: Vector2.all(100), + radius: 50, + anchor: Anchor.center, + )..add(CircleHitbox()); + await world.ensureAdd(circle); + + final ray = Ray2( + origin: Vector2.all(100), + direction: Vector2.all(1)..normalize(), + ); + final results = game.collisionDetection + .raytrace(ray, maxDepth: 1000) + .toList(); + expect(results.length, 1000); + + final center = Vector2.all(100); + for (final result in results) { + final dist = result.intersectionPoint!.distanceTo(center); + expect(dist, closeTo(50, 0.1)); + expect(result.isInsideHitbox, isTrue); + } + }, + + 'ray does not escape circle at various angles': (game) async { + final world = (game as FlameGame).world; + final circle = CircleComponent( + position: Vector2.zero(), + radius: 100, + anchor: Anchor.center, + )..add(CircleHitbox()); + await world.ensureAdd(circle); + + for (var angle = 0.1; angle < pi; angle += 0.3) { + final ray = Ray2( + origin: Vector2.all(10), + direction: Vector2(cos(angle), sin(angle)), + ); + final results = game.collisionDetection + .raytrace(ray, maxDepth: 500) + .toList(); + expect( + results.length, + 500, + reason: + 'ray escaped at angle $angle after ${results.length} ' + 'bounces', + ); + } + }, + + 'ray bouncing between circle and rotated rectangle does not escape': + (game) async { + final world = (game as FlameGame).world; + // Reproduce the example app layout: a large circle with a rotated + // rectangle inside, forcing the ray to bounce between both shapes. + final circle = CircleComponent( + position: Vector2.all(300), + radius: 200, + anchor: Anchor.center, + )..add(CircleHitbox()); + final rect = RectangleComponent( + position: Vector2(350, 280), + size: Vector2(150, 80), + angle: tau / 10, + anchor: Anchor.center, + )..add(RectangleHitbox()); + await world.ensureAddAll([circle, rect]); + + final ray = Ray2( + origin: Vector2(200, 250), + direction: Vector2(1, 0.3)..normalize(), + ); + final results = game.collisionDetection + .raytrace(ray, maxDepth: 500) + .toList(); + expect( + results.length, + 500, + reason: + 'ray escaped after ${results.length} bounces between circle ' + 'and rotated rectangle', + ); + }, }); });