flutter3d
Showcase Changelog 38 packages API reference

Sprite-sheet animation: the source

since 0.2.0 or earlier Physics and particles

Regions the guide quotes: atlas (line 37), flipbook (line 72), check (line 121).

1/// A sprite sheet played across a particle's life instead of a single
2/// static sprite, so a spark can burn out frame by frame rather than only
3/// fade.
4///
5/// Quoted by `flipbook.md` and shown whole in the Source tab.
6library;
7
8import 'dart:typed_data';
9
10import 'package:flutter3d/flutter3d.dart';
11import 'package:flutter3d_particles/flutter3d_particles.dart';
12import 'package:flutter3d_showcase/src/demo/demo.dart';
13import 'package:vector_math/vector_math.dart';
15final class FlipbookDemo extends ShowcaseDemo {
16 late final ParticleSystem _particles;
17 late final ParticleEffect _sparksEffect;
18 late final Flipbook _flipbook;
20 static const int _cellSize = 16;
21 static const int _frames = 4;
22 static const int _count = 40;
24 // The sheet only plays across a particle's own life, so a burst that never
25 // repeated would leave the sheet on its last, darkest frame forever.
26 static const double _pause = 1.0;
27 double _cooldown = 0.0;
29 @override
30 void configureView(DemoContext context) {
31 context.orbit
32 ..distance = 4.0
33 ..pitch = 0.2
34 ..yaw = 0.4;
35 }
37 /// Four cells in a row, each a solid colour: a stand-in for four drawn
38 /// frames of a burning-out spark, from bright to dark.
39 ByteData _atlas() {
40 const List<List<int>> colours = <List<int>>[
41 <int>[255, 255, 220],
42 <int>[255, 190, 90],
43 <int>[220, 90, 40],
44 <int>[60, 20, 10],
45 ];
46 final Uint8List bytes = Uint8List(_cellSize * _frames * _cellSize * 4);
47 final int width = _cellSize * _frames;
48 for (var y = 0; y < _cellSize; y++) {
49 for (var frame = 0; frame < _frames; frame++) {
50 final List<int> colour = colours[frame];
51 for (var x = 0; x < _cellSize; x++) {
52 final int at = (y * width + frame * _cellSize + x) * 4;
53 bytes[at] = colour[0];
54 bytes[at + 1] = colour[1];
55 bytes[at + 2] = colour[2];
56 bytes[at + 3] = 255;
57 }
58 }
59 }
60 return bytes.buffer.asByteData();
61 }
63 @override
64 Scene build(DemoContext context) {
65 final TextureHandle atlas = context.device.createTextureFromPixels(
66 width: _cellSize * _frames,
67 height: _cellSize,
68 format: TextureFormat.r8g8b8a8UNormInt,
69 pixels: _atlas(),
70 )!;
72 _flipbook = Flipbook(columns: _frames, rows: 1);
74 _particles = ParticleSystem(capacity: _count, seed: 606);
75 _sparksEffect = ParticleEffect(
76 count: _count,
77 emitter: const ConeEmitter(speed: Range(1.0, 2.2), halfAngleDegrees: 20),
78 lifetime: const Range(0.8, 1.2),
79 size: const Range(0.09, 0.13),
80 color: Vector4(1.0, 1.0, 1.0, 1.0),
81 );
82 _particles.burst(
83 _sparksEffect,
84 Vector3.zero(),
85 direction: Vector3(0.0, 1.0, 0.0),
86 );
88 context.renderer.addContributor(
89 ParticleContributor(_particles, texture: atlas, flipbook: _flipbook),
90 );
92 return Scene()..add(
93 LightNode(name: 'sun', intensity: 2.0)
94 ..setLocalForward(Vector3(-0.3, -0.6, -0.4)),
95 );
96 }
98 @override
99 void update(DemoContext context, double dt) {
100 _particles.advance(dt);
102 if (_particles.aliveCount == 0) {
103 _cooldown -= dt;
104 if (_cooldown <= 0.0) {
105 _particles.burst(
106 _sparksEffect,
107 Vector3.zero(),
108 direction: Vector3(0.0, 1.0, 0.0),
109 );
110 _cooldown = _pause;
111 }
112 }
113 }
115 @override
116 void verify(Scene scene, FrameResult frame) {
117 if (_particles.aliveCount != _count || frame.drawCalls < 1) {
118 throw StateError('the sparks did not reach the frame');
119 }
121 // A particle just born and one about to die should be showing different
122 // cells of the sheet: the whole point of a flipbook over a static sprite.
123 final Particle newborn = Particle()
124 ..age = 0.0
125 ..lifetime = 1.0;
126 final Particle dying = Particle()
127 ..age = 0.99
128 ..lifetime = 1.0;
129 if (_flipbook.cellFor(newborn).left == _flipbook.cellFor(dying).left) {
130 throw StateError('the flipbook should play across a particle\'s life');
131 }
132 }