前言:為什麼要做粒子模擬?
大家好,我是 Steven,一個來自台灣的獨立 Flutter 開發者。今天想跟大家聊聊我開發 SandTrace 這款粒子模擬 App 的心路歷程。
說起粒子模擬,可能很多人會覺得這是個很硬核的題目。確實,當初我也是因為在 YouTube 上看到有人用 C++ 做 Falling Sand 模擬覺得超酷,才萌生了「能不能用 Flutter 來做?」這個念頭。結果一做下去就停不下來了。
SandTrace 目前支援沙子、水、火三種粒子的即時模擬,使用者可以在螢幕上自由繪製不同材質,看它們彼此互動產生各種有趣的效果。
核心架構:二維網格與細胞自動機
粒子模擬的本質其實是一個 細胞自動機(Cellular Automaton)。整個畫面被切割成一個二維網格,每個格子代表一個粒子或空氣。每一幀更新時,我們根據規則決定每個粒子的下一步行為。
class ParticleGrid {
late List<List<ParticleType>> grid;
final int width;
final int height;
ParticleGrid({required this.width, required this.height}) {
grid = List.generate(
height,
(_) => List.filled(width, ParticleType.empty),
);
}
void update() {
// 從底部往上更新,確保沙子能正確落下
for (int y = height - 2; y >= 0; y--) {
for (int x = 0; x < width; x++) {
_updateParticle(x, y);
}
}
}
}
這裡有個小細節值得注意:更新順序很重要。沙子需要從底部往上掃描,否則同一幀內一顆沙子可能會「穿越」好幾格,看起來就不自然了。
不同粒子的物理行為
沙子:最基本的固體粒子
沙子的規則很直覺——先嘗試往正下方掉,如果下方被擋住,就嘗試往左下或右下滑動。這個簡單的規則就能產生非常自然的沙堆效果。
void _updateSand(int x, int y) {
if (_isEmpty(x, y + 1)) {
_swap(x, y, x, y + 1);
} else if (_isEmpty(x - 1, y + 1)) {
_swap(x, y, x - 1, y + 1);
} else if (_isEmpty(x + 1, y + 1)) {
_swap(x, y, x + 1, y + 1);
}
}
水:流體的模擬
水和沙子最大的差別在於,水會在無法往下移動時嘗試水平擴散。我在實作時加上了一個速度參數,讓水在水平方向上能流動多格,這樣看起來更像真正的流體。
void _updateWater(int x, int y) {
if (_isEmpty(x, y + 1)) {
_swap(x, y, x, y + 1);
} else if (_isEmpty(x - 1, y + 1)) {
_swap(x, y, x - 1, y + 1);
} else if (_isEmpty(x + 1, y + 1)) {
_swap(x, y, x + 1, y + 1);
} else {
// 水平擴散
final direction = _random.nextBool() ? 1 : -1;
for (int i = 1; i <= 4; i++) {
if (_isEmpty(x + direction * i, y)) {
_swap(x, y, x + direction * i, y);
break;
}
}
}
}
火:最具挑戰性的效果
火的模擬是三種粒子中最有趣也最困難的。火粒子會往上飄動,同時帶有隨機的水平偏移來產生搖曳的效果。每個火粒子都有一個生命週期,會隨時間從亮黃色漸變到暗紅色,最後消失。
void _updateFire(int x, int y) {
final particle = grid[y][x] as FireParticle;
particle.lifetime--;
if (particle.lifetime <= 0) {
grid[y][x] = ParticleType.empty;
return;
}
// 向上飄動並隨機偏移
final dx = _random.nextInt(3) - 1; // -1, 0, 1
final targetY = y - 1;
final targetX = x + dx;
if (_isInBounds(targetX, targetY) && _isEmpty(targetX, targetY)) {
_swap(x, y, targetX, targetY);
}
}
渲染效能:CustomPainter 的極致運用
粒子模擬最大的挑戰就是效能。以 SandTrace 來說,一個 200x400 的網格就有八萬個格子需要每幀更新和繪製。我嘗試過幾種方案:
- 用 Widget 組成網格 — 完全不可行,幾千個 Widget 就會卡到爆
- Canvas.drawRect — 逐格繪製,效能勉強可以但不理想
- 使用
Image像素操作 — 最終採用的方案
最後我選擇直接操作像素資料,透過 dart:ui 的 decodeImageFromPixels 將整個網格轉成一張圖片來繪製:
class ParticlePainter extends CustomPainter {
final ParticleGrid grid;
@override
void paint(Canvas canvas, Size size) {
final pixels = Uint8List(grid.width * grid.height * 4);
for (int y = 0; y < grid.height; y++) {
for (int x = 0; x < grid.width; x++) {
final offset = (y * grid.width + x) * 4;
final color = _getParticleColor(grid.grid[y][x]);
pixels[offset] = color.red;
pixels[offset + 1] = color.green;
pixels[offset + 2] = color.blue;
pixels[offset + 3] = color.alpha;
}
}
// 將像素資料轉為圖片並繪製到 Canvas
ui.decodeImageFromPixels(
pixels, grid.width, grid.height, ui.PixelFormat.rgba8888,
(image) => canvas.drawImage(image, Offset.zero, Paint()),
);
}
}
這個方法的好處是不管粒子數量多少,每幀都只繪製一張圖片,效能非常穩定。
使用者互動:觸控繪製粒子
為了讓使用者能流暢地在螢幕上繪製粒子,我用了 GestureDetector 搭配 onPanUpdate 來捕捉手指滑動軌跡,並在觸碰路徑上批量生成粒子。為了避免手指移動太快時產生斷點,我還加了線性插值來補齊中間的點。
void _onPanUpdate(DragUpdateDetails details) {
final current = _toGridPosition(details.localPosition);
final previous = _lastPoint ?? current;
// 線性插值補點
final steps = (current - previous).distance.ceil();
for (int i = 0; i <= steps; i++) {
final t = steps == 0 ? 0.0 : i / steps;
final point = Offset.lerp(previous, current, t)!;
_spawnParticlesAt(point.dx.round(), point.dy.round());
}
_lastPoint = current;
}
開發過程中踩過的坑
同步更新 vs 非同步更新
一開始我直接在原陣列上更新粒子狀態,結果出現了很詭異的行為——沙子會「整排一起移動」。後來才理解需要用雙緩衝的概念,先把下一幀的狀態寫到另一個陣列,更新完再整個交換。
Isolate 的嘗試
我也嘗試過把物理計算丟到 Isolate 裡執行,但因為粒子資料量大,序列化和反序列化的開銷反而超過了計算本身。最後還是留在主執行緒,靠演算法優化來保持流暢度。
結語
開發 SandTrace 讓我深刻體會到,Flutter 的 Canvas API 其實非常強大,搭配 Dart 的效能已經足以應付即時模擬這類計算密集的應用場景。如果你也對粒子模擬感興趣,歡迎到 App Store 或 Google Play 下載 SandTrace 玩玩看!
下一篇我會分享 JigsawEase 拼圖遊戲的開發經驗,敬請期待。