🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

用 AI 做了幾個超炫酷的 Flutter 動畫,同時又差點被 AI 氣死

AI 時代之後,對於開發者來說最缺乏的其實是想像力,而隨著 AI 成熟之後,很多以前需要「費勁巴拉」才能實現的效果,現在只需要幾句話搭配對應的資料就可以複刻,特別是在數學公式到 UI 的轉換實現上。

而本次介紹的幾種動畫效果,沒用任何 OpenGL 相關實現,都是純 Dart 代碼完成,整體流暢程度還過得去,都是基於已有的數學公式和資料複刻到 Flutter 的效果:

奇異粒子動畫(Strange Attractors) 斐波那契球體動畫(Fibonacci Sphere) 星雲動畫(Galaxy Scene)

奇異粒子動畫

首先是奇異粒子動畫,這是一個非常酷的視覺場景,概念是它們一般被被稱為奇異吸引子(Strange Attractors),它們是混沌理論中的經典模型,而當我看到它們還搭配了公式時,我就知道 AI 的價值來了。

要在一個 Flutter 頁面中實現這四種粒子的 3D 運動軌跡渲染,需要解決以下幾個關鍵點:

  • 數學模型:將圖片中的微分方程轉化為代碼(歐拉積分法更新粒子位置)
  • 3D 到 2D 的投影:Flutter 的 Canvas 是 2D 的,所以需要一個簡單的投影算法將 (x, y, z) 坐標轉換為螢幕上的 (u, v) 坐標,最好加上一點旋轉讓 3D 感更強

然後再基於數學公式,就可以直觀的呈現數學的美麗一面。

核心公式實現

這個實現中,核心就是模擬物理運動規律,所以 AI 實現了一個 _updatePhysics 方法,在方法裡使用歐拉積分法 (Euler Integration) 將微分方程轉化為代碼,即:

新位置 = 舊位置 + 變化率(dx, dy, dz) * 時間步長(dt)。

具體到每個動畫效果就是:

A. Halvorsen Attractor

Halvorsen 是一個具有循環對稱性的混沌系統,相應公式代碼實現公式為:

const a = 1.4;
dx = -a * p.x - 4 * p.y - 4 * p.z - (p.y * p.y);
dy = -a * p.y - 4 * p.z - 4 * p.x - (p.z * p.z);
dz = -a * p.z - 4 * p.x - 4 * p.y - (p.x * p.x);
B. Lorenz Attractor

這是經典的混沌模型,呈現「蝴蝶效應」形狀,相應公式的代碼實現:

dx = sigma * (p.y - p.x);
dy = p.x * (rho - p.z) - p.y;
dz = p.x * p.y - beta * p.z;
C. Aizawa Attractor

其實從結構上看,這個實現會更複雜,因為中間有一個明顯的管狀結構,需要複雜的縮放和位移,相應公式的代碼實現:

dx = (p.z - b) * p.x - d * p.y;
dy = d * p.x + (p.z - b) * p.y;
dz = c + a * p.z - (p.z * p.z * p.z) / 3 - (p.x * p.x + p.y * p.y) * (1 + e * p.z) + f * p.z * (p.x * p.x * p.x);
D. Sprott B Attractor

這個公式主要呈現出類似環状或星系的結構,相應的代碼實現為:

dx = a * p.y * p.z;
dy = p.x - p.y;
dz = b - p.x * p.y;
結論

而在實際上,這些公式為什麼能產生這些奇異的圖形,簡單來說就是需要從「微分方程」 (Differential Equations) 的本質看起,其實在代碼中,我們看到的公式計算的並不是粒子的「位置」,而是粒子的「速度」(或者說是趨勢)

意思就是:「在這一瞬間,x 坐標應該變化多少?」,當我們把 x, y, z 三個維度的變化率組合在一起,就形成了一个「向量場」 (Vector Field),你可以把整個空間想像成一條充滿暗流的河流,而這些公式就是告訴水流在每一個具體的點上應該往哪個方向流,流多快

舉個例子, Halvorsen Attractor 的視覺效果是粒子在三個對稱的圓環之間穿梭,像一個糾纏在一起的三葉結,對應的公式邏輯大致為:

Halvorsen 的公式是循環對稱的,因為 x 的變化取決於 y,y 取決於 z,z 又取決於 x,這種「你推我,我推他,他推你」的結構,導致粒子無法在一個平面停留,必須在三個維度間不斷輪轉。

而減去 y^2,可以讓線性部分的 ax-4y-4z 粒子產生旋轉,這裡的非線性部分 y^2 就是「折疊力」,粒子跑遠時,平方項會迅速變大,產生一股巨大的力量把粒子原本的軌道「掰彎」。

而為什麼用 Halvorsen 做例子呢?因為 AI 在做出來第一版的時候,在運行 Halvorsen 效果時,粒子運動一段時間後,螢幕變空,所有粒子消失。

而對應的根本原因是數值發散 (Numerical Divergence),具體原因有:

  1. 數學不穩定性:Halvorsen 方程包含平方項 (y^2, z^2, x^2),當粒子距離中心稍遠時,平方項會讓數值瞬間變得極大
  2. 積分誤差:公式使用的是簡單的離散算法(每幀加一次 dx * dt),一旦某個粒子因為計算誤差稍微偏離軌道,平方項會迅速放大這個誤差,導致坐標值在幾幀內變成 Infinity(無窮大)或 NaN(非數字),從而出現繪製錯誤

而 AI 最終通過「檢測 + 重置」的機制解決了這個問題:

  • 設置電子圍欄 (Boundary Check):在每一幀計算前,檢查粒子的坐標是否超過安全範圍(代碼中設定為 50)或是否變成 NaN
if (p.x.abs() > 50 || ... || p.x.isNaN) { ... }
  • 自動重生機制 (Respawn):一旦檢測到粒子「逃逸」或「計算崩潰」,立即調用 _resetSingleParticle(p) 之類的方法將它重置回原點附近的隨機位置,這不僅修復了 BUG,還讓粒子看起來像源源不斷地從中心噴湧而出。
  • 微調步長 (Step Size),針對 Halvorsen,將時間步長 dt 從預設的 0.01 降低到了 0.004,步長越小計算越精確,發生「逃逸」的概率也就越低。

最後

其實可以看到 Flutter 複刻出現的效果和原圖還是有點差別,其中最核心之一還是粒子的數量和計算的精細度,不過可以看出來,已經是非常不錯的效果了。

斐波那契球體(Fibonacci Sphere)

斐波那契球體的特點是點在球面上分布極其均勻,普通的方法(如經緯度劃分)會在球的兩極產生點的密集堆積,而斐波那契球體算法能讓點在球面上極度均勻地分布,每個點佔據的面積幾乎相等。

而針對這個效果,需要解決的點在於:

  • 數學算法:如何根據斐波那契螺旋算法在 3D 球面上生成點
  • 渲染與投影:如何將 3D 坐標投影到 2D 螢幕,並處理透視關係(近大遠小)

所以 AI 首先需要的是實現一個球體算法,核心思想是將原本在 2D 平面上畫向日葵種子的「黃金螺旋」算法,投影到了 3D 球面上,具體是利用黃金角 (Golden Angle) 來決定每個點的角度偏移:

對應的代碼實現為:

final double goldenAngle = pi * (3 - sqrt(5)); // 約 137.5 度
for (int i = 0; i < numPoints; i++) {
  double y = 1 - (i / (numPoints - 1)) * 2; // y 軸均勻分布
  double radiusAtY = sqrt(1 - y * y);       // 球體公式 x^2 + z^2 = r^2
  double theta = goldenAngle * i;           // 黃金角遞增

  double x = cos(theta) * radiusAtY;
  double z = sin(theta) * radiusAtY;
  // ...
}

之後就是 3D 旋轉 (Rotation Matrix),因為生成的點是靜止的,為了讓球轉動,還需要應用旋轉矩陣,這裡使用了簡化的歐拉角旋轉(繞 Y 軸自轉,繞 X 軸傾斜):

對應的代碼為:

// rotationY 是隨時間變化的量
double x1 = x * cos(rotationY) - z * sin(rotationY);
double z1 = x * sin(rotationY) + z * cos(rotationY);

然後就是透視投影 (Perspective Projection),這是讓 2D 螢幕產生 3D 縱深感的關鍵,物體離相機越遠(Z 越小/負值),在螢幕上看起來就越小,位置越靠近中心:

具體代碼為:

double focalLength = 800.0; // 焦距,決定透視強弱
double perspective = focalLength / (focalLength - pz);

// 螢幕坐標
double screenX = center.dx + px * perspective;
double screenY = center.dy + py * perspective;

// 點的大小也隨透視縮放
double pointSize = basePointSize * perspective;

之後我們還需要增加一些效果,比如它讓球體看起來像果凍一樣的動畫:

其實原理就是,在旋轉之人为地修改了點的 x, z 坐標:

  • sin(time...) 引入了週期性的波浪
  • + y * 4 讓波浪在球體上下不同高度處於不同相位(產生像蛇一樣的扭動感)
  • 透過 wobble 系數決定了波浪的振幅,如果為 0,offset 為 0,球體就是完美的圓形

最後還有 TRAILS(拖尾) -> 改變繪製樣式,實際上這不是物理模擬,而是視覺欺騙

具體是纖位:

  • trails == 0 時,使用 canvas.drawCircle 畫圓點
  • trails > 0 時,使用 canvas.drawLine 畫線,線的長度由 trails 參數乘以透視系數決定,因為球在水平旋轉,我們在水平方向畫一條短線,大腦就會自動腦補成「這是因為轉太快留下的殘影」

實際上我還在自己的個人首頁上將上面了兩種動畫,讓 AI 轉化為 js+css,讓個人首頁看起來更加花里胡哨:

星雲動畫

這是一個模擬星雲旋轉的動畫效果,實際效果其實比動圖裡更加好看,它可以認為是在前面 Sprott B Attractor 的基礎上更加複雜的實現,為了實現「粒子形成雙核團塊」的物理感,還需要模擬了星系動力學。

引力場模型

這裡的粒子不再是簡單的畫圓,而是一種「受力」運動的效果,所以需要:

  • 中心引力:提供基礎的向心力
  • 旋轉棒引力:模擬雙極引力場

另外還需要吸積盤模擬 (Accretion),讓粒子看起來圍繞「黑洞」中心運動:

  • 粒子不從中心噴出,而是生成在的外圍圓盤
  • 引入微小的摩擦力(velocity *= 0.9995)作為阻尼

讓粒子因能量損耗,軌道逐漸衰減,從外圍螺旋落入中心,並在途中被棒的引力捕獲,這就產生了真實的「拖尾」和「聚攏」效果。

最後核心使用了多重正弦波疊加的湍流算法 (getTurbulence),讓粒子運動看起來像沸騰的岩漿,具有很強的生命力:

Offset getTurbulence(int i, double phase, double t) {
  double speed = 2.0;
  double dx = 0.06 * sin(t * speed + phase) + 0.03 * cos(t * speed * 2.3 + i * 0.1);
  double dy = 0.06 * cos(t * speed * 1.5 + phase) + 0.03 * sin(t * speed * 1.9 + i * 0.1);
  double rotAngle = t * 0.5 * (i % 2 == 0 ? 1 : -1);
  double rx = dx * cos(rotAngle) - dy * sin(rotAngle);
  double ry = dx * sin(rotAngle) + dy * cos(rotAngle);
  return Offset(rx, ry);
}

最後是讓 30,000 個粒子配合 BlendMode.plus,在重疊處產生了高亮的能量感,模擬了星系的高密度區域,主要有:

  • 使用 Float32List 存儲數據,內存緊湊,訪問極快
  • 使用 canvas.drawRawPoints 批量繪製,這是 Flutter 中利用 GPU 渲染粒子的最高效方式

問題

當然, AI 在實現時其實遇到了個比較麻煩的問題:顏色的「插值陷阱」,有時也稱為 Uninitialized Tile Artifacts(未初始化瓦片伪影),具體為:

在 tile-based GPU(大多數移動 GPU 與 WebGL framebuffer)中,當某些 tile 區域在進入 blending/compositing 階段之前 沒有被正確清理(未寫入有效像素),它們會以默認值(通常為黑色透明)參與加法混合,從而顯示為黑塊。

也就是出現了下方圖片的幾個透明黑塊,黑塊隨著運動變化,時而明顯,時而消失:

所以這裡看到的「黑塊」,實際上是 Canvas 繪製圓形時的 Bounding Box,雖然代碼命令計算機畫一個圓,但計算機在底層是先畫一個正方形區域,然後計算像素距離圓心的距離來填充顏色,當這個正方形區域邊緣的像素沒有處理乾淨時,就會看到一個淡黑色的方框。

而在 Flutter中,代碼裡主要有:

  • Colors.orange 的數據是:R=255, G=165, B=0, A=255
  • Colors.transparent 的數據實際上是透明的黑色:R=0, G=0, B=0, A=0

猜測是,定義一個漸變從 橙色 -> 透明 時,計算機需要計算中間的過渡色,一般來說數學計算過程是這樣的:

  • 起點:亮橙色 (255, 165, 0, 255)
  • 中間:半透明的深褐色 (128, 82, 0, 128) <- 可能出問題的地方
  • 終點:透明黑色 (0, 0, 0, 0)

整個情況下,如果用默認的混合模式(SourceOver),Alpha 通道會把這個「深褐色」隱藏掉,因為它越來越透明。

而在實際實現上,在增加了中間區域的發光特效,為了實現要求的「發光、高亮、高溫」效果, AI 使用了 BlendMode.screen(濾色)BlendMode.plus(相加)

  • 在這些混合模式下,RGB 值的亮度決定了最終畫面
  • 那個中間狀態的「深褐色」,雖然 Alpha 很低,但它的 RGB 不是 0
  • Screen 模式下,這層淡淡的褐色會像一層薄紗一樣疊在背景上,導致正方形區域顯形

而 AI 在幾次嘗試修復裡,一直沒能修復問題:

  • 嘗試 A:使用 TileMode.decal(剪切模式)

    • 原理:告訴計算機「超出圓半徑的像素直接扔掉,不要畫」,避免漸變邊緣在透明像素上產生 undefined 行為
    • 結果:沒有效果
  • 嘗試 B:調整 Colors.transparent

    • 原理:試圖讓漸變變得更淡。
    • 結果:方塊變淡了,但依然存在,因為只要終點是 Colors.transparent,中間插值就一定包含「黑色成分」。
  • 嘗試 C:BlendMode 層面的修復

    • 原理:試圖去掉 Plus,Plus 混合會創建 offscreen buffer,透明像素的初始值要麼是黑,要麼是不確定;如果深層次變換後參與合成,就會以「髒塊」形式出現,所以替換為在某些層用 srcOver,單獨隔離 blob / halo / blackhole 的加法混合,調整合成順序等
    • 結果: 黑塊依舊出現
  • 嘗試 D:Canvas 層面的修復

    • 原理saveLayer + clear 強制透明背景,用真實透明 offscreen 替代 GPU 依賴背景,去掉 RadialGradient transform,目的是完全控制合成區域,阻止髒像素泄漏
    • 結果: 黑塊依舊出現

這一切的問題看起來都是 GPU tile buffer 沒有正確清除,以至於換個 AI,從 Gemini 3 Pro 換到 GPT 5,它都覺得:「不是你,不是我,不是實現錯誤」:


原文出處:https://juejin.cn/post/7578215424677003327


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝15   💬3   ❤️3
315
🥈
我愛JS
📝1   💬3   ❤️2
45
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付