用 Flutter 打造粒子模擬引擎:SandTrace 開發心得

前言:為什麼要做粒子模擬?

大家好,我是 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 的網格就有八萬個格子需要每幀更新和繪製。我嘗試過幾種方案:

  1. 用 Widget 組成網格 — 完全不可行,幾千個 Widget 就會卡到爆
  2. Canvas.drawRect — 逐格繪製,效能勉強可以但不理想
  3. 使用 Image 像素操作 — 最終採用的方案

最後我選擇直接操作像素資料,透過 dart:uidecodeImageFromPixels 將整個網格轉成一張圖片來繪製:

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 拼圖遊戲的開發經驗,敬請期待。