在剛剛結束的 FlutterNFriends 大會上,Flame 展示了它們關於 3D 遊戲的支持:flame_3d ,Flame 是一個以元件系統(Flame Component System, FCS)、遊戲循環、碰撞檢測和輸入處理為核心的 Flutter 遊戲框架,而這個架構的一個關鍵特點就是:純 Dart 和 Flutter 的開發模式,在此之前 flame 在 2D 領域已經開發了不少小遊戲。

而本次要聊的 flame_3d 屬於 Flame 生態系統的一個官方擴展包,flame_3d 是在 Flame 已有的 FCS 上進行擴展的支持,比如:
Component 的子類,例如 SpriteComponent 用於渲染圖像MeshComponent,它們也繼承自 Component 基類,但是在其之上有 Component3D 的相關實現這種繼承關係主要是為了新的 3D 物件,能夠無縫地融入已有的 Flame 遊戲循環和元件管理,例如一個 MeshComponent 可以像 2D 的 SpriteComponent 一樣被添加到 World ,並自動參與到遊戲的更新(update)和渲染(render)循環,也能讓原本熟悉 Flame 2D 的開發者更便捷進入到 3D 領域:

而在 flame_3d 裡,三維場景的根節點是還是 FlameGame 類,它負責管理整個遊戲,而所有 3D 物件都通常被添加到一個 World3D 元件:
World3D 元件作為一個邏輯容器來組織場景內容CameraComponent3D 它定義了三維世界的投影方式和視點位置而在內部,flame_3d 會攔截 Flame 的渲染循環,從而利用 Flutter GPU 的低級API來執行三維渲染任務,例如:
將三維網格、材質和燈光信息發送到 GPU 進行著色和光栅化,這個過程發生在 Flutter 的
build之外,也避免了造成 Flutter UI 層的性能瓶頸的可能。
所以目前而言,flame_3d 十分依賴於 Flutter GPU + Impeller , flutter_gpu 作為 Flutter 3.24 提供的一個實驗性功能包,它為 Dart 語言暴露了 Impeller 渲染引擎的低級接口,它可以通過編寫 Dart 代碼和 GLSL 著色器在 Flutter 中構建和集成自定義渲染器,而無需 Native 平台代碼,允許開發者直接訪問 GPU 資源和執行自定義著色器。
簡單來說就是,Flutter GPU 是 Impeller 對於 HAL 的一層很輕的包裝,並搭配了關於著色器和管道編排的自動化能力,也通過 Flutter GPU 就可以使用 Dart 直接構建自定義渲染器。
Flutter GPU 和 Impeller 一樣,它的著色器也是使用 impellerc 提前編譯,所以 Flutter GPU 也只支持 Impeller 的平台上可用。
當然,實際上直接使用 Flutter GPU 十分複雜,比如一個簡單的繪製就需要:
GpuContext.createCommandBuffer 創建一個 CommandBufferCommandBuffer.createRenderPass 創建一個 RenderPassRenderPassRenderPass.drawCommandBuffer 使用 CommandBuffer.submit (異步)提交繪製,所有 RenderPass 會按照其創建順序進行編碼而在 flame_3d 通過抽象出一系列核心三維元件來簡化開發:
MeshComponent: 這是最基本的可渲染三維元件,用於表示三維網格(Mesh),通過屬性可以加載不同類型的網格,例如圓錐體(ConeMesh)和圓柱體(CylinderMesh),甚至支持複雜的模型解析和骨骼動畫LightComponent: 負責在場景中添加光源,影響 3D 物體的著色效果Material: 材質定義了 3D 物件表面的外觀特性,例如顏色和紋理,目前默認提供了一個SpatialMaterial,開發者也可以編寫自定義材質來使用自己的著色器Vector 和 Quaternion: 主要是用於方便進行三維空間的向量運算和旋轉變換這裡需要注意的是,Flutter 目前並不原生支持著色器文件的打包,而為了解決這個問題,flame_3d 提供了一個自定義的 Dart 腳本,開發者可以將他們的頂點著色器(.vert)和片段著色器(.frag)文件存放在一個指定的shaders 目錄下,並確保文件名稱相同,然後:
通過運行命令
dart pub run flame_3d:build_shaders自動編譯並打包著色器,并放置到assets/shaders目錄中提供運行時加載。
而針對 flame_3d 官方也提供了一些 demo,例如 collect_the_donut 就是一個非常不錯的例子,它很好地展示了 flame 如何 3D 領域的開發轉變為大家熟悉的 Flutter 面向物件的開發模式:
例如在專案裡,你可以通過 ModelParser 加載對應的模型資源,對應上面動圖,在這裡:

而在實際使用上也並不複雜,比如對於我們操作的角色,在專案裡對應的是 Player 封裝,Player 類是一個繼承自 ModelComponent 的自定義元件,並且通過混入 HasGameReference 獲取對遊戲實例的引用,並實現了 KeyboardHandler 和 TapCallbacks 接口,用於處理鍵盤輸入和點擊事件。

Player 類定義了一些關鍵屬性,例如玩家的動作 _action、武器 _weapon、是否奔跑 _isRunning、死亡計時器 _deathTimer 等,而構造函數中默認將玩家的武器設置為 _knife,並通過 _updateWeapon 方法隱藏其他武器節點,僅顯示當前武器。

對於玩家動作,這裡通過 action 屬性管理,通過設置動作時啟動計時器 _actionTimer,並調用 stopAnimation 停止當前動畫,這裡的動畫對應的是 flame_3d 裡的 AnimationState 動畫狀態機:
set action(PlayerAction? value) {
if (_actionTimer != 0.0) {
return;
}
_action = value;
_actionTimer = value?.timer ?? 0.0;
stopAnimation();
}
而玩家的視角可以通過 lookAngle 屬性管理,設置時會更新模型的旋轉,lookAt 屬性返回玩家當前視角方向的向量,同時玩家位置通過 _input 和 _handleMovement 方法更新,支持基於鍵盤輸入的移動邏輯:
lookAngle += -_input.x * _rotationSpeed * dt;
final movement = lookAt.scaled(-_input.y * speed * dt);
position.add(movement);
_updateAnimation 方法根據玩家當前狀態(動作、移動、奔跑等)播放對應的動畫,例如攻擊時播放攻擊動畫,移動時播放行走或奔跑動畫,靜止時播放待機動畫:
if (action != null) {
playAnimationByIndex(0, resetClock: false);
} else if (isMoving && _isRunning) {
playAnimationByName('Running_A', resetClock: false);
}
可以看到,很多底層操作 flame_3d 都幫我們做了隔離,在上層你只需要操作熟悉的物件和 API,比如將 PlayerWeapon.knife 換成 PlayerWeapon.twoHandedCrossbow:

而對於地板,在專案裡對應的是Floor 類,它是一個自定義的地板元件,繼承了 flame.Component,用於在遊戲場景中生成一個由多個地板段組成的地板網格。
Floor 的會接收一個 Vector2 size 參數,表示地板的寬度和深度,地板的生成邏輯基於網格劃分,網格的單元大小由常量 _floorSegmentSize 定義,起始位置 start 是一個 Vector3,計算方式確保地板網格居中於場景:

網格的生成邏輯是通過嵌套的 for 循環,遍歷地板的寬度和深度,將每個網格單元的位置偏移量計算出來,並創建 _FloorSection 實例,每個實例的 position 屬性設置為計算後的位置,並添加到當前元件中:
final position = start.clone()
..x += x * _floorSegmentSize
..z += y * _floorSegmentSize;
add(_FloorSection()..position.setFrom(position));
而 _FloorSection 繼承自 ModelComponent,表示地板的單個網格單元,對應模型是通過 Loader.models.floor 加載,並設置了初始位置偏移量,使地板稍微低於默認高度:

同理,Demo 專案裡的Wall 也是繼承自 flame.Component,用於在遊戲場景中生成一段由多個牆段組成的牆體:

對於 Wall 的來說主要接收兩個參數:start 和 end,分別表示牆體的起點和終點,然後通過計算 end - start 得到方向向量 direction,並將牆體的初始位置設置為起點加上方向向量的一半長度:
final direction = end - start;
final position = start + direction.scaledTo(_wallSegmentSize / 2);
對於牆段生成邏輯,主要由多個固定大小的牆段組成,每段的大小由常量 _wallSegmentSize 定義,通過 while 循環,逐步減少剩餘距離 totalDistance,並在每次迭代中添加一個 _WallSection 實例,每個牆段的旋轉通過 Quaternion.axisAngle 計算,使其與牆體方向對齊:
final rotation = Quaternion.axisAngle(
up,
atan2(start.z - end.z, start.x - end.x),
);
add(
_WallSection(
wallIndex: randomInt(0, Loader.models.walls.length),
position: position,
rotation: rotation,
),
);
同樣道理,這裡的 _WallSection 也繼承自 ModelComponent,表示牆體的單個段,它的構造函數接收牆段的索引、位置和旋轉,並從 Loader.models.walls 中加載對應的牆體模型:

另外還有光源演示, Demo 裡的光源主要體現在幾個隨機移動的黑點,對應專案裡的 Wisp 物件,它繼承自 LightComponent,並通過 HasGameRef 混入獲取對遊戲實例的引用,它的主要功能是創建一個動態移動的光源,模擬螢火蟲的效果:

對於光源,同樣有一個內部模型物件 _VisualLight ,它同樣繼承自 MeshComponent,用於渲染光源的視覺效果,這裡主要使用了一個小型球體網格 SphereMesh,半徑為 0.05,材質為 SpatialMaterial,顏色與光源一致:

最後少不了 Camera,在 Demo 裡使用 ThirdPersonCamera 實現了一個自定義的 3D 攝像機元件,它主要是繼承自 CameraComponent3D,並通過 HasGameReference 混入獲取對遊戲實例的引用,而它的主要功能是實現第三人稱視角,跟隨玩家角色的移動和方向:

在 ThirdPersonCamera 裡它主要是設置了攝像機的視野角度(fovY)、初始位置(position)、上方向向量(up)以及目標點(target),例如position 設置為 Vector3(-18, 6, -18),表示攝像機初始位於玩家後方偏左上方的位置。
position: Vector3(-18, 6, -18),
up: Vector3(0.8, 1, 0.8),
target: Vector3(0, 0, 0),
update 方法在每幀調用,用於更新攝像機的位置和目標點,首先計算目標偏移量 targetOffset 和目標視角點 targetLookAt,分別基於玩家的位置和視角方向進行偏移:
final targetOffset = player.position + _positionOffset;
final targetLookAt = player.position + player.lookAt;
接著,使用線性插值公式更新攝像機的位置和目標點,使其平滑地跟隨玩家移動和旋轉,插值速度由 _cameraLinearSpeed 和 _cameraRotationSpeed 控制:
position += (targetOffset - position) * _cameraLinearSpeed * dt;
target += (targetLookAt - target) * _cameraRotationSpeed * dt;
另外還有個值得聊的是 HUD,實際上也就是,用於在螢幕右上角顯示當前分數,事實上其實就是使用 flame_3d 裡的 TextPaint ,它可以把你需要的文本內容直接選入到螢幕:
await camera.viewport.add(Hud());
static final textHuge = TextPaint(style: _style.copyWith(fontSize: 64));
class Hud extends Component with HasGameRef<CollectTheDonutGame> {
@override
void render(Canvas canvas) {
super.render(canvas);
Styles.textHuge.render(
canvas,
game.score.toString().padLeft(2, '0'),
Vector2(
game.size.x - _margin,
_margin,
),
anchor: Anchor.topRight,
);
}
}

可以看到,flame_3d 大大簡化了 Flutter GPU 的使用,同時也給了沉寂這麼久的 Flutter GPU 一個落地場景,由於需要 Flutter GPU 和 Impeller 支持,目前 flame_3d 只支持 Android、iOS 和 macOS ,同時由於 flame_3d 也是實驗性階段,所以 API 穩定性還沒有保證。
對於 flame 而言,在理想情況下他們甚至希望 flame_3d 的用戶完全不需要知道和理解 Flutter GPU,他們的目標是將 Flutter GPU 抽象為一個方便 3D 開發的 API,這不僅簡化了創建渲染目標、設置顏色和深度紋理以及配置深度模板等操作,還包含支持更高級的 API,例如幾何形狀、紋理/材質渲染以及創建可以使用這些形狀和材質的網格,最終把這一切和 有的 FCS 緊密結合。
另外本次 Flame 現場還在現在不是用 Flutter GPU 製作了一個小 Demo ship_game,通過覆蓋 Raymarching 、 Volumetric Raymarching 、Weight maps 和 Ordered Dithering 來展示了 Flame 原生的能力:
可以看到,在 flame 在加持下,Flutter 在遊戲領域的能力確實越來越強,也希望 Flutter GPU 可以早日發布穩定版本,把這個老餅給畫完。