Flutter 真 3D 游戏引擎来了,flame_3d 了解一下

在刚刚结束的 FlutterNFriends 大会上,Flame 展示了它们关于 3D 游戏的支持:flame_3d ,Flame 是一个以组件系统(Flame Component System, FCS)、游戏循环、碰撞检测和输入处理为核心的 Flutter 游戏框架,而这个架构的一个关键特点就是:纯 Dart 和 Flutter 的开发模式,在此之前 flame 在 2D 领域已经开发了不少小游戏。

而本次要聊的 flame_3d 属于 Flame 生态系统的一个官方扩展包,flame_3d 是在 Flame 已有的 FCS 上进行扩展的支持,比如:

  • 在Flame 2D中,游戏对象通常是 Component 的子类 ,例如 SpriteComponent 用于渲染图像
  • 在 flame_3d 则是引入了新的三维组件类型,比如 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
  • GpuContext.createCommandBuffer 创建一个 CommandBuffer
  • CommandBuffer.createRenderPass 创建一个 RenderPass
  • 使用各种方法设置状态/管道并绑定资源 RenderPass
  • 附加绘图命令 RenderPass.draw
  • CommandBuffer 使用 CommandBuffer.submit (异步)提交绘制,所有 RenderPass 会按照其创建顺序进行编码

而在 flame_3d 通过抽象出一系列核心三维组件来简化开发:

  • MeshComponent : 这是最基本的可渲染三维组件,用于表示三维网格(Mesh),通过属性可以加载不同类型的网格,例如圆锥体(ConeMesh)和圆柱体(CylinderMesh),甚至支持复杂的模型解析和骨骼动画
  • LightComponent: 负责在场景中添加光源,影响 3D 物体的着色效果
  • Material : 材质定义了 3D 对象表面的外观特性,例如颜色和纹理,目前默认提供了一个SpatialMaterial,开发者也可以编写自定义材质来使用自己的着色器
  • VectorQuaternion: 主要是用于方便进行三维空间的向量运算和旋转变换

这里需要注意的是,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 加载对应的模型资源,对应上面动图,在这里:

  • rogue 就是我们操作的角色模型
  • floor 是地板模型
  • donut 是甜甜圈模型
  • skeleton 是小兵模型
  • walls 是墙体模型

而在实际使用上也并不复杂,比如对于我们操作的角色,在项目里对应的是 Player 封装,Player 类是一个继承自 ModelComponent 的自定义组件,并且通过混入 HasGameReference 获取对游戏实例的引用,并实现了 KeyboardHandlerTapCallbacks 接口,用于处理键盘输入和点击事件。

Player 类定义了一些关键属性,例如玩家的动作 _action、武器 _weapon、是否奔跑 _isRunning、死亡计时器 _deathTimer 等,而构造函数中默认将玩家的武器设置为 knife,并通过 _updateWeapon 方法隐藏其他武器节点,仅显示当前武器。

对于玩家动作,这里通过 action 属性管理,通过设置动作时启动计时器 _actionTimer,并调用 stopAnimation 停止当前动画,这里的动画对应的是 flame_3d 里的 AnimationState 动画状态机

dart 复制代码
set action(PlayerAction? value) {
  if (_actionTimer != 0.0) {
    return;
  }
  _action = value;
  _actionTimer = value?.timer ?? 0.0;
  stopAnimation();
}

而玩家的视角可以通过 lookAngle 属性管理,设置时会更新模型的旋转,lookAt 属性返回玩家当前视角方向的向量,同时玩家位置通过 _input_handleMovement 方法更新,支持基于键盘输入的移动逻辑:

dart 复制代码
lookAngle += -_input.x * _rotationSpeed * dt;
final movement = lookAt.scaled(-_input.y * speed * dt);
position.add(movement);

_updateAnimation 方法根据玩家当前状态(动作、移动、奔跑等)播放对应的动画,例如攻击时播放攻击动画,移动时播放行走或奔跑动画,静止时播放待机动画:

dart 复制代码
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 属性设置为计算后的位置,并添加到当前组件中:

dart 复制代码
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 的来说主要接收两个参数:startend,分别表示墙体的起点和终点,然后通过计算 end - start 得到方向向量 direction,并将墙体的初始位置设置为起点加上方向向量的一半长度:

dart 复制代码
final direction = end - start;
final position = start + direction.scaledTo(_wallSegmentSize / 2);

对于墙段生成逻辑,主要由多个固定大小的墙段组成,每段的大小由常量 _wallSegmentSize 定义,通过 while 循环,逐步减少剩余距离 totalDistance,并在每次迭代中添加一个 _WallSection 实例,每个墙段的旋转通过 Quaternion.axisAngle 计算,使其与墙体方向对齐:

dart 复制代码
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),表示摄像机初始位于玩家后方偏左上方的位置。

dart 复制代码
position: Vector3(-18, 6, -18),
up: Vector3(0.8, 1, 0.8),
target: Vector3(0, 0, 0),

update 方法在每帧调用,用于更新摄像机的位置和目标点,首先计算目标偏移量 targetOffset 和目标视角点 targetLookAt,分别基于玩家的位置和视角方向进行偏移:

dart 复制代码
final targetOffset = player.position + _positionOffset;
final targetLookAt = player.position + player.lookAt;

接着,使用线性插值公式更新摄像机的位置和目标点,使其平滑地跟随玩家移动和旋转,插值速度由 _cameraLinearSpeed_cameraRotationSpeed 控制:

dart 复制代码
position += (targetOffset - position) * _cameraLinearSpeed * dt;
target += (targetLookAt - target) * _cameraRotationSpeed * dt;

另外还有个值得聊的是 HUD,实际上也就是,用于在屏幕右上角显示当前分数,事实上其实就是使用 flame_3d 里的 TextPaint ,它可以把你需要的文本内容直接选入到屏幕:

dart 复制代码
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 可以早日发布稳定版本,把这个老饼给画完。

相关推荐
小月鸭8 分钟前
如何理解HTML语义化
前端·html
jump68032 分钟前
url输入到网页展示会发生什么?
前端
诸葛韩信35 分钟前
我们需要了解的Web Workers
前端
啦啦91171439 分钟前
Niagara Launcher 全新Android桌面启动器!给手机换个门面!
android·智能手机
brzhang40 分钟前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
游戏开发爱好者841 分钟前
iOS 上架要求全解析,App Store 审核标准、开发者准备事项与开心上架(Appuploader)跨平台免 Mac 实战指南
android·macos·ios·小程序·uni-app·iphone·webview
xrkhy43 分钟前
canal1.1.8+mysql8.0+jdk17+redis的使用
android·redis·adb
yivifu1 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花1 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
十二春秋1 小时前
场景模拟:基础路由配置
前端