Skip to content

Commit ed452dd

Browse files
authored
feat!: Raycasting and raytracing (#1785)
This PR implements raytracing and raycasting for the built-in hitboxes. If you pass in your own collision detection system to the HasCollisionDetection mixin you have to change the signature of that to: CollisionDetection<ShapeHitbox>instead of CollisionDetection<Hitbox>.
1 parent e2de70c commit ed452dd

34 files changed

+1893
-157
lines changed

doc/flame/collision_detection.md

+135
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,141 @@ class MyGame extends FlameGame with HasCollisionDetection {
223223
```
224224

225225

226+
## Ray casting and Ray tracing
227+
228+
Ray casting and ray tracing are methods for sending out rays from a point in your game and
229+
being able to see what these rays collide with and how they reflect after hitting
230+
something.
231+
232+
233+
### Ray casting
234+
235+
Ray casting is the operation of casting out one or more rays from a point and see if they hit
236+
anything, in Flame's case, hitboxes.
237+
238+
We provide two methods for doing so, `raycast` and `raycastAll`. The first one just casts out
239+
a single ray and gets back a result with information about what and where the ray hit, and some
240+
extra information like the distance, the normal and the reflection ray. The second one, `raycastAll`,
241+
works similarly but sends out multiple rays uniformly around the origin, or within an angle
242+
centered at the origin.
243+
244+
To use the ray casting functionality you have to have the `HasCollisionDetection` mixin on your
245+
game. After you have added that you can call `collisionDetection.raycast(...)` on your game class.
246+
247+
Example:
248+
249+
```dart
250+
class MyGame extends FlameGame with HasCollisionDetection {
251+
@override
252+
void update(double dt) {
253+
super.update(dt);
254+
final ray = Ray2(
255+
origin: Vector2(0, 100),
256+
direction: Vector2(1, 0),
257+
);
258+
final result = collisionDetection.raycast(ray);
259+
}
260+
}
261+
```
262+
263+
In this example one can see that the `Ray2` class is being used, this class defines a ray from an
264+
origin position and a direction (which are both defined by `Vector2`s). This particular ray starts
265+
from `0, 100` and shoots a ray straight to the right.
266+
267+
The result from this operation will either be `null` if the ray didn't hit anything, or a
268+
`RaycastResult` which contains:
269+
- Which hitbox the ray hit
270+
- The intersection point of the collision
271+
- The reflection ray, i.e. how the ray would reflect on the hitbox that it hix
272+
- The normal of the collision, i.e. a vector perpendicular to the face of the hitbox that it hits
273+
274+
If you are concerned about performance you can pre create a `RaycastResult` object that you send in
275+
to the method with the `out` argument, this will make it possible for the method to reuse this
276+
object instead of creating a new one for each iteration. This can be good if you do a lot of
277+
ray casting in your `update` methods.
278+
279+
280+
#### raycastAll
281+
282+
Sometimes you want to send out rays in all, or a limited range, of directions from an origin. This
283+
can have a lot of applications, for example you could calculate the field of view of a player or
284+
enemy, or it can also be used to create light sources.
285+
286+
Example:
287+
288+
```dart
289+
class MyGame extends FlameGame with HasCollisionDetection {
290+
@override
291+
void update(double dt) {
292+
super.update(dt);
293+
final origin = Vector2(200, 200);
294+
final result = collisionDetection.raycastAll(
295+
origin,
296+
numberOfRays: 100,
297+
);
298+
}
299+
}
300+
```
301+
302+
In this example we would send out 100 rays from (200, 200) uniformingly spread in all directions.
303+
304+
If you want to limit the directions you can use the `startAngle` and the `sweepAngle` arguments.
305+
Where the `startAngle` (counting from straight up) is where the rays will start and then the rays
306+
will end at `startAngle + sweepAngle`.
307+
308+
If you are concerned about performance you can re-use the `RaycastResult` objects that are created
309+
by the function by sending them in as a list with the `out` argument.
310+
311+
312+
### Ray tracing
313+
314+
Ray tracing is similar to ray casting, but instead of just checking what the ray hits you can
315+
continue to trace the ray and see what its reflection ray (the ray bouncing off the hitbox) will
316+
hit and then what that casted reflection ray's reflection ray will hit and so on, until you decide
317+
that you have traced the ray for long enough. If you imagine how a pool ball would bounce on a pool
318+
table for example, that information could be retrieved with the help of ray tracing.
319+
320+
Example:
321+
322+
```dart
323+
class MyGame extends FlameGame with HasCollisionDetection {
324+
@override
325+
void update(double dt) {
326+
super.update(dt);
327+
final ray = Ray2(
328+
origin: Vector2(0, 100),
329+
direction: Vector2(1, 1)..normalize()
330+
);
331+
final results = collisionDetection.raytrace(
332+
ray,
333+
maxDepth: 100,
334+
);
335+
for (final result in results) {
336+
if (result.intersectionPoint.distanceTo(ray.origin) > 300) {
337+
break;
338+
}
339+
}
340+
}
341+
}
342+
```
343+
344+
In the example above we send out a ray from (0, 100) diagonally down to the right and we say that we
345+
want it the bounce on at most 100 hitboxes, it doesn't necessarily have to get 100 results since at
346+
some point one of the reflection rays might not hit a hitbox and then the method is done.
347+
348+
The method is lazy, which means that it will only do the calculations that you ask for, so you have
349+
to loop through the iterable that it returns to get the results, or do `toList()` to directly
350+
calculate all the results.
351+
352+
In the for-loop it can be seen how this can be used, in that loop we check whether the current
353+
reflection rays intersection point (where the previous ray hit the hitbox) is further away than 300
354+
pixels from the origin of the starting ray, and if it is we don't care about the rest of the results
355+
(and then they don't have to be calculated either).
356+
357+
If you are concerned about performance you can re-use the `RaycastResult` objects that are created
358+
by the function by sending them in as a list with the `out` argument.
359+
360+
226361
## Comparison to Forge2D
227362

228363
If you want to have a full-blown physics engine in your game we recommend that you use

examples/.metadata

+38-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,45 @@
11
# This file tracks properties of this Flutter project.
22
# Used by Flutter tool to assess capabilities and perform upgrades etc.
33
#
4-
# This file should be version controlled and should not be manually edited.
4+
# This file should be version controlled.
55

66
version:
7-
revision: f30b7f4db93ee747cd727df747941a28ead25ff5
8-
channel: beta
7+
revision: 85684f9300908116a78138ea4c6036c35c9a1236
8+
channel: stable
99

1010
project_type: app
11+
12+
# Tracks metadata for the flutter migrate command
13+
migration:
14+
platforms:
15+
- platform: root
16+
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
17+
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
18+
- platform: android
19+
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
20+
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
21+
- platform: ios
22+
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
23+
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
24+
- platform: linux
25+
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
26+
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
27+
- platform: macos
28+
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
29+
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
30+
- platform: web
31+
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
32+
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
33+
- platform: windows
34+
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
35+
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
36+
37+
# User provided section
38+
39+
# List of Local paths (relative to this file) that should be
40+
# ignored by the migrate tool.
41+
#
42+
# Files that are not part of the templates will be ignored by default.
43+
unmanaged_files:
44+
- 'lib/main.dart'
45+
- 'ios/Runner.xcodeproj/project.pbxproj'

examples/lib/stories/collision_detection/collision_detection.dart

+21
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import 'package:examples/commons/commons.dart';
33
import 'package:examples/stories/collision_detection/circles_example.dart';
44
import 'package:examples/stories/collision_detection/collidable_animation_example.dart';
55
import 'package:examples/stories/collision_detection/multiple_shapes_example.dart';
6+
import 'package:examples/stories/collision_detection/raycast_example.dart';
7+
import 'package:examples/stories/collision_detection/raycast_light_example.dart';
8+
import 'package:examples/stories/collision_detection/raytrace_example.dart';
69
import 'package:flame/game.dart';
710

811
void addCollisionDetectionStories(Dashbook dashbook) {
@@ -25,5 +28,23 @@ void addCollisionDetectionStories(Dashbook dashbook) {
2528
(_) => GameWidget(game: MultipleShapesExample()),
2629
codeLink: baseLink('collision_detection/multiple_shapes_example.dart'),
2730
info: MultipleShapesExample.description,
31+
)
32+
..add(
33+
'Raycasting (light)',
34+
(_) => GameWidget(game: RaycastLightExample()),
35+
codeLink: baseLink('collision_detection/raycast_light_example.dart'),
36+
info: RaycastLightExample.description,
37+
)
38+
..add(
39+
'Raycasting',
40+
(_) => GameWidget(game: RaycastExample()),
41+
codeLink: baseLink('collision_detection/raycast_example.dart'),
42+
info: RaycastExample.description,
43+
)
44+
..add(
45+
'Raytracing',
46+
(_) => GameWidget(game: RaytraceExample()),
47+
codeLink: baseLink('collision_detection/raytrace.dart'),
48+
info: RaytraceExample.description,
2849
);
2950
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import 'dart:math';
2+
3+
import 'package:flame/collisions.dart';
4+
import 'package:flame/components.dart';
5+
import 'package:flame/game.dart';
6+
import 'package:flame/geometry.dart';
7+
import 'package:flame/palette.dart';
8+
import 'package:flutter/material.dart';
9+
10+
class RaycastExample extends FlameGame with HasCollisionDetection {
11+
static const description = '''
12+
In this example the raycast functionality is showcased. The circle moves around
13+
and casts 10 rays and checks how far the nearest hitboxes are and naively moves
14+
around trying not to hit them.
15+
''';
16+
17+
Ray2? ray;
18+
Ray2? reflection;
19+
Vector2 origin = Vector2(250, 100);
20+
Paint paint = Paint()..color = Colors.amber.withOpacity(0.6);
21+
final speed = 100;
22+
final inertia = 3.0;
23+
final safetyDistance = 50;
24+
final direction = Vector2(0, 1);
25+
final velocity = Vector2.zero();
26+
final random = Random();
27+
28+
static const numberOfRays = 10;
29+
final List<Ray2> rays = [];
30+
final List<RaycastResult<ShapeHitbox>> results = [];
31+
32+
late Path path;
33+
@override
34+
Future<void> onLoad() async {
35+
final paint = BasicPalette.gray.paint()
36+
..style = PaintingStyle.stroke
37+
..strokeWidth = 2.0;
38+
add(ScreenHitbox());
39+
add(
40+
CircleComponent(
41+
position: Vector2(100, 100),
42+
radius: 50,
43+
paint: paint,
44+
children: [CircleHitbox()],
45+
),
46+
);
47+
add(
48+
CircleComponent(
49+
position: Vector2(150, 500),
50+
radius: 50,
51+
paint: paint,
52+
children: [CircleHitbox()],
53+
),
54+
);
55+
add(
56+
RectangleComponent(
57+
position: Vector2.all(300),
58+
size: Vector2.all(100),
59+
paint: paint,
60+
children: [RectangleHitbox()],
61+
),
62+
);
63+
add(
64+
RectangleComponent(
65+
position: Vector2.all(500),
66+
size: Vector2(100, 200),
67+
paint: paint,
68+
children: [RectangleHitbox()],
69+
),
70+
);
71+
add(
72+
RectangleComponent(
73+
position: Vector2(550, 200),
74+
size: Vector2(200, 150),
75+
paint: paint,
76+
children: [RectangleHitbox()],
77+
),
78+
);
79+
}
80+
81+
final _velocityModifier = Vector2.zero();
82+
83+
@override
84+
void update(double dt) {
85+
super.update(dt);
86+
collisionDetection.raycastAll(
87+
origin,
88+
numberOfRays: numberOfRays,
89+
rays: rays,
90+
out: results,
91+
);
92+
velocity.scale(inertia);
93+
for (final result in results) {
94+
_velocityModifier
95+
..setFrom(result.intersectionPoint!)
96+
..sub(origin)
97+
..normalize();
98+
if (result.distance! < safetyDistance) {
99+
_velocityModifier.negate();
100+
} else if (random.nextDouble() < 0.2) {
101+
velocity.add(_velocityModifier);
102+
}
103+
velocity.add(_velocityModifier);
104+
}
105+
velocity
106+
..normalize()
107+
..scale(speed * dt);
108+
origin.add(velocity);
109+
}
110+
111+
@override
112+
void render(Canvas canvas) {
113+
super.render(canvas);
114+
renderResult(canvas, origin, results, paint);
115+
}
116+
117+
void renderResult(
118+
Canvas canvas,
119+
Vector2 origin,
120+
List<RaycastResult<ShapeHitbox>> results,
121+
Paint paint,
122+
) {
123+
final originOffset = origin.toOffset();
124+
for (final result in results) {
125+
if (!result.isActive) {
126+
continue;
127+
}
128+
final intersectionPoint = result.intersectionPoint!.toOffset();
129+
canvas.drawLine(
130+
originOffset,
131+
intersectionPoint,
132+
paint,
133+
);
134+
}
135+
canvas.drawCircle(originOffset, 5, paint);
136+
}
137+
}

0 commit comments

Comments
 (0)