Skip to content

Commit ec21f96

Browse files
committed
feat: (#3851) add HueEffect and HueDecorator for color hue manipulation
1 parent 98d94a6 commit ec21f96

23 files changed

Lines changed: 746 additions & 7 deletions

doc/flame/effects/color_effects.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,44 @@ final effect = GlowEffect(
136136
```
137137

138138
Currently this effect can only be applied to components that have a `HasPaint` mixin.
139+
140+
141+
## `HueToEffect`
142+
143+
This effect will change the hue of the target over time to the specified angle in radians.
144+
It can only be applied to components that implement the `HueProvider`.
145+
146+
```dart
147+
final effect = HueEffect.to(
148+
pi / 2,
149+
EffectController(duration: 3),
150+
);
151+
```
152+
153+
## `HueByEffect`
154+
155+
This effect will rotate the hue of the target relative by the specified angle in radians.
156+
It can only be applied to components that implement the `HueProvider`.
157+
158+
```{flutter-app}
159+
:sources: ../flame/examples
160+
:page: hue_effect
161+
:show: widget code infobox
162+
:width: 180
163+
:height: 160
164+
```
165+
166+
```dart
167+
final effect = HueEffect.by(
168+
2 * tau,
169+
EffectController(duration: 3),
170+
);
171+
```
172+
173+
Both effects can target any component implementing `HueProvider`. The `HasPaint` mixin
174+
implements `HueProvider` and handles the necessary `ColorFilter` updates automatically.
175+
176+
> [!TIP]
177+
> **Performance Note**: `HueEffect` is extremely efficient because it modifies the `Paint`'s
178+
> `colorFilter` directly. If you have many components, prefer this effect over the `HueDecorator`,
179+
> which uses `saveLayer()` and has much higher overhead.

doc/flame/effects/effects.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ that property to a fixed value. This way multiple effects would be able to act o
105105
without interfering with each other.
106106

107107

108+
## Effects vs Decorators
109+
110+
While effects and decorators can sometimes achieve similar visual results (like changing opacity
111+
or color), they have different performance and visual characteristics:
112+
113+
- **Effects** are fast and generally change a property on a single component. When applied to
114+
a group, they affect each child individually.
115+
- **Decorators** are more powerful but slower. They use `saveLayer` to flatten a whole
116+
component subtree into a single layer before applying an effect. This is essential for
117+
correctly rendering composite objects with transparency or complex filters.
118+
119+
See the [Decorators documentation](../rendering/decorators.md) for a more detailed comparison.
120+
121+
108122
## See also
109123

110124
- [Examples of various effects](https://examples.flame-engine.org/).
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import 'dart:math';
2+
3+
import 'package:doc_flame_examples/flower.dart';
4+
import 'package:flame/game.dart';
5+
import 'package:flame/rendering.dart';
6+
7+
class HueDecoratorGame extends FlameGame {
8+
@override
9+
Future<void> onLoad() async {
10+
var step = 0;
11+
add(
12+
Flower(
13+
size: 100,
14+
position: canvasSize / 2,
15+
onTap: (flower) {
16+
final decorator = flower.decorator;
17+
step++;
18+
if (step == 1) {
19+
decorator.addLast(HueDecorator(hue: pi / 4));
20+
} else if (step == 2) {
21+
decorator.replaceLast(HueDecorator(hue: pi / 2));
22+
} else if (step == 3) {
23+
decorator.replaceLast(HueDecorator(hue: pi));
24+
} else {
25+
decorator.replaceLast(null);
26+
step = 0;
27+
}
28+
},
29+
)..onTapUp(),
30+
);
31+
}
32+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import 'dart:math';
2+
3+
import 'package:doc_flame_examples/ember.dart';
4+
import 'package:flame/components.dart';
5+
import 'package:flame/effects.dart';
6+
import 'package:flame/game.dart';
7+
8+
class HueEffectExample extends FlameGame {
9+
@override
10+
Future<void> onLoad() async {
11+
final ember = EmberPlayer(
12+
position: size / 2,
13+
size: size / 4,
14+
onTap: (ember) {
15+
ember.add(
16+
HueEffect.by(
17+
2 * pi,
18+
EffectController(duration: 3),
19+
),
20+
);
21+
},
22+
)..anchor = Anchor.center;
23+
24+
add(ember);
25+
}
26+
}

doc/flame/examples/lib/main.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import 'package:doc_flame_examples/decorator_shadow3d.dart';
1010
import 'package:doc_flame_examples/decorator_tint.dart';
1111
import 'package:doc_flame_examples/drag_events.dart';
1212
import 'package:doc_flame_examples/glow_effect.dart';
13+
import 'package:doc_flame_examples/hue_decorator.dart';
14+
import 'package:doc_flame_examples/hue_effect.dart';
1315
import 'package:doc_flame_examples/move_along_path_effect.dart';
1416
import 'package:doc_flame_examples/move_by_effect.dart';
1517
import 'package:doc_flame_examples/move_to_effect.dart';
@@ -39,18 +41,20 @@ import 'package:flutter/widgets.dart';
3941
import 'package:web/web.dart' as web;
4042

4143
final routes = <String, Game Function()>{
44+
'anchor': AnchorGame.new,
4245
'anchor_by_effect': AnchorByEffectGame.new,
4346
'anchor_to_effect': AnchorToEffectGame.new,
44-
'anchor': AnchorGame.new,
4547
'collision_detection': CollisionDetectionGame.new,
4648
'color_effect': ColorEffectExample.new,
4749
'decorator_blur': DecoratorBlurGame.new,
4850
'decorator_grayscale': DecoratorGrayscaleGame.new,
51+
'decorator_hue': HueDecoratorGame.new,
4952
'decorator_rotate3d': DecoratorRotate3DGame.new,
5053
'decorator_shadow3d': DecoratorShadowGame.new,
5154
'decorator_tint': DecoratorTintGame.new,
5255
'drag_events': DragEventsGame.new,
5356
'glow_effect': GlowEffectExample.new,
57+
'hue_effect': HueEffectExample.new,
5458
'move_along_path_effect': MoveAlongPathEffectGame.new,
5559
'move_by_effect': MoveByEffectGame.new,
5660
'move_to_effect': MoveToEffectGame.new,

doc/flame/rendering/decorators.md

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,38 @@ necessary. We are planning to add shader-based decorators once Flutter fully sup
1010
web.
1111

1212

13+
## Performance considerations
14+
15+
Applying a Decorator to a component can have a significant performance overhead, especially when
16+
it involves `canvas.saveLayer()`.
17+
18+
- **Decorators**: Use `canvas.saveLayer()` by default to isolate rendering and apply
19+
filters. This requires off-screen buffer allocation and GPU context switches. This is
20+
computationally expensive but essential for correct visual composition of complex
21+
objects (see below).
22+
- **Effects** (e.g., `OpacityEffect`, `ColorEffect`): Modify the component's properties or `Paint` directly. These are extremely fast and hardware-accelerated, but they apply to each child individually.
23+
24+
### Decorators vs Effects: Visual Composition
25+
26+
The key difference lies in how they handle composite objects (components with multiple
27+
overlapping children):
28+
29+
1. **Effects (Individual Blend)**: If you apply an `OpacityEffect` to a parent component,
30+
Flame will render each child with that opacity. If children overlap, you will see
31+
through them to the background and to other children, creating a "double-exposure"
32+
look.
33+
2. **Decorators (Group Blend)**: Because decorators use `saveLayer`, they render the
34+
entire subtree into a flat buffer first, and then apply the effect to that
35+
buffer. This results in a uniform appearance where overlaps are not visible,
36+
making the group look like a single solid object.
37+
38+
**Recommendation**:
39+
- Use **Effects** for simple property animations and high-performance color shifts on
40+
large numbers of units.
41+
- Use **Decorators** for advanced post-processing (blurs, tints) and when you need
42+
to treat a group of components as a single visual unit.
43+
44+
1345
## Flame built-in decorators
1446

1547

@@ -157,6 +189,29 @@ limitation is that the shadows are flat and cannot interact with the environment
157189
decorator cannot handle shadows that fall onto walls or other vertical structures.
158190

159191

192+
### HueDecorator
193+
194+
```{flutter-app}
195+
:sources: ../flame/examples
196+
:page: decorator_hue
197+
:show: widget code infobox
198+
:width: 180
199+
:height: 160
200+
```
201+
202+
This decorator shifts the hue of the underlying component by the specified angle in radians.
203+
204+
```dart
205+
final decorator = HueDecorator(hue: tau / 4);
206+
```
207+
208+
Possible uses:
209+
210+
- alternative color schemes for enemies ("palette swapping");
211+
- environmental changes (e.g., world turning purple/surreal);
212+
- power-up indicators.
213+
214+
160215
## Using decorators
161216

162217

@@ -175,7 +230,7 @@ components the `HasDecorator` mixin is not needed.
175230

176231
In fact, the `PositionComponent` uses its decorator in order to properly position the component on
177232
the screen. Thus, any new decorators that you'd want to apply to the `PositionComponent` will need
178-
to be chained (see the [](#multiple-decorators) section below).
233+
to be chained (see the [Multiple decorators](#multiple-decorators) section below).
179234

180235
It is also possible to replace the root decorator of the `PositionComponent`, if you want to create
181236
an alternative logic for how the component shall be positioned on the screen.
@@ -196,5 +251,5 @@ from its root, which usually is `component.decorator`.
196251

197252

198253
[Component]: ../components/components.md#component
199-
[Effect]: ../../flame/effects.md
254+
[Effect]: ../effects/effects.md
200255
[HasDecorator]: #hasdecorator-mixin

examples/lib/main.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import 'package:examples/stories/image/image.dart';
2929
import 'package:examples/stories/input/input.dart';
3030
import 'package:examples/stories/layout/layout.dart';
3131
import 'package:examples/stories/parallax/parallax.dart';
32+
import 'package:examples/stories/rendering/decorators.dart';
3233
import 'package:examples/stories/rendering/rendering.dart';
3334
import 'package:examples/stories/router/router.dart';
3435
import 'package:examples/stories/sprites/sprites.dart';
@@ -83,6 +84,7 @@ void runAsDashbook() {
8384
addCameraAndViewportStories(dashbook);
8485
addCollisionDetectionStories(dashbook);
8586
addComponentsStories(dashbook);
87+
addDecoratorStories(dashbook);
8688
addEffectsStories(dashbook);
8789
addExperimentalStories(dashbook);
8890
addInputStories(dashbook);

examples/lib/stories/effects/effects.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:examples/stories/effects/combined_effect_example.dart';
55
import 'package:examples/stories/effects/dual_effect_removal_example.dart';
66
import 'package:examples/stories/effects/effect_controllers_example.dart';
77
import 'package:examples/stories/effects/function_effect_example.dart';
8+
import 'package:examples/stories/effects/hue_effect_example.dart';
89
import 'package:examples/stories/effects/move_effect_example.dart';
910
import 'package:examples/stories/effects/opacity_effect_example.dart';
1011
import 'package:examples/stories/effects/remove_effect_example.dart';
@@ -59,6 +60,12 @@ void addEffectsStories(Dashbook dashbook) {
5960
codeLink: baseLink('effects/opacity_effect_example.dart'),
6061
info: OpacityEffectExample.description,
6162
)
63+
..add(
64+
'Hue Effect',
65+
(_) => GameWidget(game: HueEffectExample()),
66+
codeLink: baseLink('effects/hue_effect_example.dart'),
67+
info: HueEffectExample.description,
68+
)
6269
..add(
6370
'Color Effect',
6471
(_) => GameWidget(game: ColorEffectExample()),
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import 'dart:math';
2+
3+
import 'package:examples/commons/ember.dart';
4+
import 'package:flame/effects.dart';
5+
import 'package:flame/game.dart';
6+
7+
class HueEffectExample extends FlameGame {
8+
static const String description = '''
9+
In this example we show how the `HueEffect` can be used.
10+
Ember will shift its hue over time.
11+
''';
12+
13+
@override
14+
Future<void> onLoad() async {
15+
add(
16+
Ember(
17+
position: size / 2,
18+
size: Vector2.all(100),
19+
)..add(
20+
HueEffect.by(
21+
2 * pi,
22+
EffectController(
23+
duration: 3,
24+
infinite: true,
25+
),
26+
),
27+
),
28+
);
29+
}
30+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import 'dart:math';
2+
3+
import 'package:examples/commons/ember.dart';
4+
import 'package:flame/components.dart';
5+
import 'package:flame/events.dart';
6+
import 'package:flame/experimental.dart';
7+
import 'package:flame/game.dart';
8+
import 'package:flame/rendering.dart';
9+
import 'package:flutter/widgets.dart';
10+
11+
class DecoratorHueExample extends FlameGame with TapCallbacks {
12+
static const String description = '''
13+
This example demonstrates the usage of `HueDecorator` to shift the
14+
colors of a component.
15+
16+
1. Top: Basic `HueDecorator` shifting the hue of an Ember component.
17+
2. Bottom: A chain of decorators (`HueDecorator` + `PaintDecorator.tint`)
18+
to show how effects can be combined.
19+
20+
Click to cycle through hue shifts.
21+
''';
22+
23+
late final List<HueDecorator> decorators = [];
24+
int step = 0;
25+
26+
@override
27+
Future<void> onLoad() async {
28+
final d1 = HueDecorator();
29+
final d2 = HueDecorator();
30+
decorators.addAll([d1, d2]);
31+
32+
world.add(
33+
ColumnComponent(
34+
gap: 120,
35+
anchor: Anchor.center,
36+
children: [
37+
_buildItem('HueDecorator', d1),
38+
_buildItem('HueDecorator + Tint', d2)
39+
..decorator.addLast(
40+
PaintDecorator.tint(const Color.fromARGB(99, 32, 54, 248)),
41+
),
42+
],
43+
),
44+
);
45+
}
46+
47+
PositionComponent _buildItem(String title, HueDecorator decorator) {
48+
return PositionComponent(
49+
size: Vector2(150, 120),
50+
children: [
51+
Ember(
52+
size: Vector2.all(80),
53+
position: Vector2(75, 40),
54+
),
55+
TextComponent(
56+
text: title,
57+
position: Vector2(75, 100),
58+
anchor: Anchor.center,
59+
),
60+
],
61+
)..decorator.addLast(decorator);
62+
}
63+
64+
@override
65+
void onTapDown(TapDownEvent event) {
66+
step++;
67+
final hues = [0.0, pi / 4, pi / 2, pi, 0.0];
68+
final hue = hues[step % hues.length];
69+
for (final d in decorators) {
70+
d.hue = hue;
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)