Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 81 additions & 70 deletions packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<ShapeHitbox>? rayIntersection(
Ray2 ray, {
RaycastResult<ShapeHitbox>? 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;
Comment thread
spydon marked this conversation as resolved.

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;
Comment thread
spydon marked this conversation as resolved.
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;
}
}
16 changes: 13 additions & 3 deletions packages/flame/lib/src/geometry/polygon_ray_intersection.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
Expand All @@ -20,12 +22,20 @@ mixin PolygonRayIntersection<T extends ShapeHitbox> 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;
Expand Down
87 changes: 87 additions & 0 deletions packages/flame/test/collisions/collision_detection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
},
});
});

Expand Down
Loading