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

相关推荐
陶甜也3 小时前
无需服务器,免费、快捷的一键部署前端 vue React代码--PinMe
服务器·前端·vue.js
烛阴3 小时前
TypeScript 进阶必修课:解锁强大的内置工具类型(二)
前端·javascript·typescript
一笑的小酒馆3 小时前
Android使用Flow+协程封装一个FlowBus
android
竹苓3 小时前
前端性能优化:用虚拟列表轻松渲染 100000 条数据
前端·性能优化
用户47949283569153 小时前
🚀 面试官:什么是强缓存与协商缓存
前端·网络协议·面试
你单排吧3 小时前
Uniapp之ios真机调试篇
前端·mac
用户22152044278003 小时前
JavaScript事件循环
前端
JarvanMo3 小时前
5 个连 Remi 都不会告诉你的实用 Flutter Riverpod 技巧
前端
万少3 小时前
可可图片编辑 HarmonyOS(4)图片裁剪-canvas
前端·harmonyos