3D 互动项目入门 · 梦享之车

avatar
花呗借呗前端团队 @蚂蚁集团

前言

本篇文章来自支付宝技术部一线开发者扶嗨的实践经验总结。作为Galacean引擎的重要生态合作伙伴,扶嗨在开发互动项目中积累了丰富的经验。以下内容记录了他们在一个创新的互动项目中的实战心得与技术探索。

正文

今年上半年诗应找到我说要不要一起做互动,原本就对互动感兴趣的我,“偷偷”学习了几个月,巧了,碰到一个全新的互动小项目,不断给汽车解锁配件更换配件的玩法,还蛮有意思。有点小小经验的我准备给自己争取一次机会,让学习到的知识能够落地,也算是对自己的一个挑战吧。

1. 项目介绍

依托于小荷包的能力,在出行行业做的另一种攒钱玩法,用户在小荷包内转入资金攒钱,随着金额的递增,逐步解锁 “数字虚拟配件”,同时支持 3D 汽车模型改装、升级配件、更换涂装等互动玩法。

2. 项目框架

整个项目采用了 H5 和 galacean 编辑器混合研发,通过编辑器完成场景搭建以及美术资源的导入,编辑器项目导出project.json资源文件通过 H5 引入使用。

互动 H5 应用基础框架仍是 Smallfish,Galacean Framework向开发者提供了在 Smallfish 环境下接入互动内容的能力,互动内容部分以 React 组件<GameView />的形式接入到页面组件树中,其中的 canvas DOM 作为互动内容载体,由 Galacean 引擎负责图像渲染。

  • => react <-- SmallFish H5

  • => <GameView /> <-- GalaceanFrameWork

  • => <canvas /> <-- GalaceanEngine

2.1. 数据流

数据流统一采用zustand进行互动与2D逻辑的通信。互动模块采用面向对象的形式编写逻辑,通过subsribe监听store的值进行逻辑处理,涉及到多环境下的状态管理,通过useStore可以随时拿到最终值,让人十分心安。

2.2. 页面层级结构

3. 互动工作流

如果让一个视觉做这样的3D模型及出场动画

那它大概率会告诉你

3.1. 资源差异

在3D场景中不只是需要“切图”,往往还要引入“美术制作”完成模型交付。

标准前端项目

3D项目

资源

图片+动画

图片+动画+3D模型

放一张这次项目的模型,模型通常是由3D建模师通过三维建模软件Maya、C4D、Blender等DCC工具创建的三维模型,通常是一个fbx或者是glTF后缀的资源文件。

3.2. 角色差异

同时我们也还会额外增加多个角色的参与。

传统的产研业务需求一般会涉及到以下角色:

项目经理 PM、运营、产品 PD、视觉设计、交互设计、前后端开发、客户端开发、质量等

而在支付宝内的3D互动项目中,则需要额外增加如下角色:

角色

职责

美术

美术供应商,产出模型动画等素材

技术美术

有图形互动专业背景的美术,知道效果需要采用什么技术来实现,会使用各种美术工具,跟视觉设计和上面的美术是不同的角色

3.3. 合作流程差异

在传统的业务支撑模式中,我们的流程可以简单归总为:

而涉及3D互动场景,整个流程会多引入一倍多数量的角色

角色多了,沟通也变得困难,需要更清楚上述每个角色的分工,这样我们在沟通的时候才能够更加顺畅。

3.4. 研发流程差异

红色标记差异内容

4. 沟通

作为互动小白,虽然前期做了很多准备但还是缺少了实践经验,整体项目做完感觉和不少大佬有过沟通hhh👀

因为投入项目的时间点比较尴尬,前期评审完全没参加,前端业务逻辑系分完了,互动这块只定了mars的技术方案,模型好像也已经开始制作了,接到手里感觉还是一头雾水🥶

再加上前期没有技美这个角色投入(排期没排上,美术暂先顶替),对接项目动画的技术方案发现开发和美术存在一定的gap,有点互相听不懂,只能通过一边拉大佬询问技术方案可行性,一边给出demo进行测试。

突然闪来一道曙光!

技美的到来解决了美术和开发上的很多技术上的问题,经常出现三人会议。

技美在编辑器资产文件区将项目中大量资源有序放置,规范好文件目录结构及命名规范,仅模型资源就有45个,不做好规范很容易出现问题,面向美术也会输出一套规范,后续美术也只需要往文件里塞资源,开发做好映射拿资源。

技美作为开发与美术之间的催化剂(提效)和粘合剂(协作),给美术提供技术支持,从而提升美术资源的品质和制作效率,也能从技术角度给开发提供思路,既懂技术又懂美术的角色🎭。

5. 实现

5.1. 资源通信

项目的主要玩法是换配件,那获取资源很重要。技美在编辑器资产文件区已经定义好的规范和结构,我们需要匹配到对应目录下的资源,通过相对路径加载。

const gltf = GameGlobal.engine.resourceManager.load({
  type: AssetType.GLTF,
  url: '/Car_01/Car_Body/Body_01/Mod/MS_Body_01.fbx',
});

5.2. 换积木

大家看这个gif图,是不是感觉像个某个东西被替换掉了?打个比喻,如在搭积木过程中,将三角形积木换成正方形积木,这个积木我们称“模型”。所以,显而易见我们把换配件的游戏简单看成换积木。

我们不仅可以“换车身”、“换尾翼”、“换轮毂”,所以我们需要先做个拆分。

这样看上去像是多个小积木组合而成的一个大积木,大积木也可以由任意几个不同的小积木一起搭配,比如车身A无缝替换成车身B,只要保证在设计时这俩积木的结构位置信息是一致即可。

在编辑器的场景里是一个树状结构,场景下可以挂载多个节点,我们会提前定义好节点名称,通过查找到对应节点名称后放置我们的小积木,展现出来的就是一整辆完整车了(不同小积木位置信息已内置)。

category定义具体配件code,version定义具体版本code。

const entityList = [
  {
    category: 'Body',
    version: '01',
  },
  {
    category: 'Wheel',
    version: '01',
  },
  {
    category: 'Prop',
    version: '01',
  },
];

/** 获取所有资源相对路径 */
const urls = entityList.map((subItem) => {
  const { category, version } = subItem || {};
  return {
    type: AssetType.GLTF,
    url: `/Car_${CarTypeMap[carType]}/Car_${category}/${category}_${version}/Mod/MS_${category}_${version}.fbx`,
  };
});

/** 并行加载所有模型资源 */
const gltfResources = await GameGlobal.engine.resourceManager.load(urls);

/** 找到每个实体的父节点 */
const parentModels = entityList.map((_, index) => {
  return GameGlobal.scene.findEntityByName(
    entityList[index].category as string,
  ) as Entity;
});

/** 往父节点塞入模型 */
parentModels.forEach((item, index) => {
  item?.addChild(gltfResources[index]._defaultSceneRoot);
});

换积木直接将当前节点下的模型移除,添加新的模型。

/** 移除当前的模型 */
parentEntity?.removeChild(parentEntity?.children?.[0]);
/** 添加新的模型  */
parentEntity?.addChild(gltf._defaultSceneRoot);

5.3. 换车衣

车渲染出来了,这个颜色好像不太美观。我们看看如何更换车的“衣服”。

先上个效果

简单来看就是给每个积木换上了不同好看的“衣服”。积木本身是很复杂的,自带很多结构和属性,我们需要在积木所对应的结构上去替换衣服属性,这样就会产生不一样的效果。这个“结构”我们称网格模型,“衣服”我们称材质。

一个积木可能有多个不同的网格模型,我们要实现换车衣就是给车身模型、尾翼模型、轮毂模型的多个网格模型更换材质属性,这样一个换装基本上就实现了。

不过积木的网格模型结构可能是不一样的,那对应每个网格所对应的材质也会不一样,导致每个积木都会有自己的N款车衣,那N个积木对应的资源就是N*M。

这么多资源我们该如何去匹配?

const entityList = [
  {
    category: 'Body',
    version: '01',
  },
  {
    category: 'Wheel',
    version: '01',
  },
  {
    category: 'Prop',
    version: '01',
  },
];

/** 找到所有模型的父节点 */
const parentModels = entityList.map((_, index) => {
  return GameGlobal.scene.findEntityByName(
    entityList[index].category as string,
  ) as Entity;
});

/** 暂存所有实体网格模型 - 用于后续材质替换 */
const entityRenderers: MeshRenderer[][] = [];
/** 获取所有模型的材质 */
const entityRendererPromise = entityList.map((_, index) => {
  /** 获取单个模型 */
  const entity = parentModels?.[index]?.children?.[0];
  /** 获取单个模型的材质 */
  const entityRenderer: MeshRenderer[] = entity.getComponents(MeshRenderer, []);
  /** 暂存单个模型材质 */
  entityRenderers.push(entityRenderer);
  /** 获取单个模型所有材质相对路径并加载 */
  const entityRendererUrl = entityRenderer.map((item) => {
    const { category, version } = entityList?.[index];
    return {
      type: AssetType.Material,
      url: `/Car_${CarTypeMap[carType]}/Car_${category}/${category}_${version}/Mat/Mat_${materialVersion}/${item?.getMaterial()?.name}`,
    };
  });
  return GameGlobal.engine.resourceManager.load(entityRendererUrl);
});

/** 所有模型的材质结果 */
const entityRenderersResult = await Promise.all(entityRendererPromise);

/** 设置所有模型的新材质 */
entityRenderers.forEach((item, index) => {
  item.forEach((subItem, materialIndex) => {
    subItem.setMaterial(
      (entityRenderersResult as [])?.[index]?.[materialIndex] as Material,
    );
  });
});

5.4. 舞台转动

看着还是挺丝滑的,用户转动车模型,同时舞台也跟着转动,舞台分为三个层次,使用三个lottie动画将跟着模型正向转、静止、反向转组合成一个舞台。

其实模型没有动,我们转动的是一个相机📷。模型静止,相机绕着物体转动一圈,我们观看到了不同视角的模型,从而实现转动。

5.4.1. 舞台三个层级

正向转

lottie动画与模型保持一致,不作任何处理。

静止

我们需要把lottie动画跟随着相机放一起,相对相机就能达到静止的效果,可以在编辑器项目里将lottie动画挂载到相机的节点下。

反向转

反向转会稍微复杂一点,需要通过计算得出正向转动的角度,目前视角是固定上下不动,只能横向360度旋转,所以转动相机横向360度观看模型是绕着Y轴转动。那我们需要计算出相机在XoZ平面旋转了角度rotate。

以车为参照物,反向角度则是rotate + rotate,再赋值给lottie动画。

class CameraScript extends Script {
  onLateUpdate(_deltaTime: number): void {
    const { position } = this.entity.transform;
    const rotate = Math.atan2(position.x, position.z);
    GameGlobal.reverseEntity.transform.rotation.set(
      -90,
      ((2 * rotate) / Math.PI) * 180,
      0,
    );
  }
}

6. 优化

互动项目相对标准前端项目有更复杂的动画效果,用户手机的内存占用更大,无论是从资源上还是代码优化上都会有较高的要求,尽可能避免用户出现卡帧。

6.1. 监听资源加载完成渲染页面

**问题:**项目使用了smallfish的预请求能力提前加载主接口,接口数据返回当前的配件数据,渲染3D的react组件才去加载相对的模型资源,会出现2D先渲染3D滞后的现象。

**解决:**galacean引擎目前还未提供初始化引擎前单独加载资源的能力,通过useStore手动记录所有资源加载完成后展示页面,保证用户体验效果。

6.2. 预加载资源

项目有大量的图片及模型资源使用了预加载能力,在用户切换tab后,提前加载tab下的所有资源内容。

7. 降级

降级解决了应用程序在低端设备或性能较差的设备上使用时也能够运行稳定,无论是2D还是3D都需要有对应的降级处理方案,项目中整个玩法是3D换配件游戏,对不支持webgl机型及webgl加载失败使用了一刀切的方式进行页面降级。

7.1. 分级降级

galacean framework框架对机型降级进行分级,开发可以前置获取用户机型进行降级处理。支持业务定制,调整各档的内存、CPU、机型等内容,按自身需求进行在线配置调整。

7.2. 刷新率

应对用户发热采取降帧预案,引擎默认开启垂直同步与屏幕刷新率保持一致,如果用户手机存在较为严重发热,可调整为每刷新2帧,引擎更新一次。

8. 总结

从0到1上手一个互动项目还是有不小的挑战,在这过程中也学习到了不少。前端所见即所得也变得更加生动有趣。想起刚开始接手这个项目跑demo的时候,换一个模型的效果也让人感到兴奋。

加入Galacean 社区

Galacean 开源社区群 (钉钉):

Galacean 开源社区群 (微信):

添加群管理员微信:Namidairo001**,**并备注 “galacean 加群”

**Galacean官网:**galacean.antgroup.com/engine