开发背景

得益于 “元宇宙” 概念在前段时间的爆火,各家公司都推出了使用 3D 场景的活动或频道。

3D 场景相比传统的 2D 页面优点是多一个维度,同屏展示的内容可以更多,能完整的展示物体、商品的信息。

相应带来的缺点是用户使用方式改变,用户需要额外的学习成本。另外初期需要的开发量、美术资源和生成 3D 模型的设备也是增加的成本。

在这样的背景下,我们团队接到了食品频道的一个互动项目的开发需求,希望通过 3D 场景的展示和互动方式,作为对未来购物的一种尝试与探索,满足用户对未来美好新奇的一个需求。将购物场景化、娱乐化,给用户带来美好的购物感受。

前端框架选择

3D 项目相比之前的 2D 项目改变的主要是客户端的表现。在希望不依赖 app 客户端支持和在尽量多的环境下能运行,我们首先采用的方案是在 Web 端实现 3D 项目实现。

开发套件

首先我们考虑的是成熟的开发套件,如 unity/egret 等,但这些开发套件都有一些我们不能绕过的问题,例如:

引擎名称/对比维度 使用价格 (权重 50% 脚本上手 (权重 30% 场景搭建 (权重 20% 支持模型格式 (权重 10% 社区资料丰富程度 (权重 30% 支持 web 端发布 (一票否决
Unity 3d 3 7 10 8 10 Y
Laya 4 9 7 7 7 Y
Egret 10 8 7 7 6 Y
Cocos2d-js      N
Godot 10 7 7 8 7 Y

由于以上的原因,开发套件里没有令团队很满意的选择,我们从其他方向寻找开发工具。

开源渲染库

另外也比较了 Web 前端使用量较多的两个 3D 渲染库:

◦three.js 提供的组件粒度较小,较基础,能做很高程度的定制化二次开发,但如果需要开发一个互动项目,需要开发的组件比较多

◦babylon.js 既提供了粒度小的基础组件,也封装了接近开箱即用的组件。并自带了性能测量工具,提供了方便的 debug 方法和优化策略

经过团队内对各个开发套件/渲染库的试用,最后选择了 babylon.js 作为项目的渲染层库,在其提供的组件上二次开发业务逻辑。

项目场景搭建

渲染分层结构

项目渲染层级总体分为两层:3D 场景层和 HUD 层

1.3D 场景层顾名思义渲染 3D 场景,由 人物模型、建筑模型和宝箱这些互动模型组成

2.HUD 层渲染互动按钮、弹窗、业务需要的商品列表等 2D UI 内容

本来 babylonjs 是支持 3D 和 2D 内容混合渲染的,但是如果都使用 babylonjs 渲染,在设置两种内容需要使用统一的分辨率,而在现在的移动端设备上,能支持像素分辨率(如 iPhone 14 的像素分辨率为 1170x2532)渲染不卡顿的只占一小部分。在大部分的设备上,最多只能支持在逻辑分辨率(如 iPhone 14 逻辑分辨率为 390x844)下流畅运行,但设置这样的分辨率会使 2D 层渲染模糊,所以使用分层的方法渲染。

由 babylonjs 渲染 3D 场景层,而 HUD 层则通过 react 框架使用传统 DOM 方式渲染。

第二个 3D 渲染层

渲染层分为 3D 场景层和 HUD 层带来了一个问题,,需要在 HUD 层上再渲染 3D 内容时,例如展示 3D 模型,则不得不再增加一层 3D 渲染层,而 3D 渲染层不停地在调用渲染方法,以响应用户操作和播放动画,这耗费了大量 CPU 和 GPU 的计算资源,还占用了存储模型顶点信息和贴图纹理的内存空间。因此在多个 3D 渲染层共存的情况下,需进行一定的管理以优化性能。我们采用以下策略管理多个 3D 渲染层:

◦在展示另外的 3D 渲染层时再实例化,并暂停原来 3D 渲染层的渲染

◦在不需要展示的时候销毁,恢复原 3D 渲染层的渲染方法调用

以尽量减少资源的占用,提高项目的渲染性能。

交互组件开发

碰撞检测

babylonjs 自带检测模型间是否碰撞的方法,但使用设计师提供的高精度模型直接去调用碰撞检测方法的话,计算量会很大,虽然未在测试设备上出现较严重的卡顿现象,但是已经使设备发热。

因此需要使用一个包围模型的不可见的、精简面的 “空气墙” 模型来做碰撞检测。在项目初期,这个 “空气墙” 模型需要设计师提供,在建模软件里根据原模型制作低精度包围模型。在后续迭代开发中,我们团队开发了 “一键生成空气墙” 的工具,自动生成低精度模型,减少设计师交付的资源数量,也减少更新模型时出错的机会。

镜头避障

因为项目用的是第三人称的镜头,镜头离开人物模型有一定的距离,在人物走动或用户控制角度的时候,镜头有可能和建筑模型或场景模型碰撞,造成 “镜头穿模” 的现象。

babylonjs 自带的镜头没有避开模型的功能,在产品也没有处理经验的时候,我们做了如下两个方案:

  1. 镜头外围用一个不可见模型包围,跟人物一样与建筑、场景模型做碰撞检测,使镜头不会进入到模型中去。

这种方法的优点是可以使用内置的碰撞检测方法,不需要额外的开发量。但是缺点也很明显,用户对镜头和模型的碰撞导致停止没有预期,总会觉得镜头不自然的不受控制。

  1. 镜头和人物之间用棒状的模型连接,同样在棒状模型上调用与建筑、场景模型的碰撞检测,当棒状模型的某个位置发生碰撞时,镜头将移动到人物与碰撞点之间的位置,避免镜头进入模型的同时,也避免模型穿插在人物与镜头中间,造成导致用户找不到人物的问题。

这种方法实现的效果符合一些同样是第三人称视角的 3D 游戏的镜头运动逻辑,用户感受更自然,不会出现失控的现象。而引入的额外开发量也在可控的范围内。

与设计团队的资源交接

模型格式

在众多的 3D 模型格式中,我们选择了 .gltf 格式。相对于其他模型格式,.gltf 可以减少 3D 格式中与渲染无关的的冗余数据,从而确保文件体积更小。

目前 3D 素材相对来说都比较大,这对于移动端加载体验来说,无疑是致命的。因此拥有更小体积的格式,也拥有了更高的优先选择权重。

除此之外,.gltf 是对近二十年来各种 3D 格式的总结,使用最优的数据结构,从而保证最大的兼容性以及可伸缩性,在拥有大容量的同时,支持更多的拓展,比如支持多贴图、多动画等。

所以 .gltf 成为了我们与视觉约定好的唯一素材格式。

模型输出流程

本来设计师工作流使用的建模软件是 C4D ,但是在资源交接的过程中,我们发现了几个问题:

1.缺少导出 gltf 文件功能。 在某些版本的 C4D 不能导出 gltf 格式的模型;某些版本能导出,但是导出有问题。而又因为设计师使用的一些渲染器支持问题,不能轻易更新 C4D 版本。

2.导出模型大小不统一。 可能因为某些版本的 C4D 导出的问题,或是 C4D 里的一些设置没能导出到 gltf 文件,设计师几次导出的模型大小并不统一,例如人物模型比建筑模型还要大上好几倍。

3.导出材质信息丢失。 设计师在建模时,因为模型可能会在多个渠道使用,例如渲染宣传图片,大部分情况会使用第三方的渲染器做渲染,这时候可能模型里会使用这些渲染器独有的材质。而这些材质导出到 gltf 文件时,会丢失这些独有材质的信息。再导入到页面的场景中时,设计师会发现展示的效果跟他们在建模软件里看到的相差甚远。

在和设计师多次沟通后,我们之间定立了一个导出模型的工作流:

在 C4D 建模完成后,导出 FBX 格式的文件,再导入到对 gltf 支持较好的 blender 软件中,设计师可以预览他们的材质在中转过程中有没有丢失效果,blender 导出的 gltf 文件中的模型也能保持一致的大小。

预设光影

在默认的渲染设置中,我们把设计侧输出的模型放进场景中,加上光源,也只有明暗的变化,没有影子,缺少了一些立体感。

在我们尝试加入影子的过程中,发现性能受到严重影响。在查阅了渲染原理后,发现当每在一个平面上增加影子,相当于多渲染一次场景,渲染的压力成倍增加。

跟设计侧交流后,决定在地板的贴图纹理上预先加上建筑的投影。这种方法对大部分是固定模型的场景能有较好的效果,而人物的阴影可以用静态图片跟随模型移动模拟。

渲染优化

压缩纹理

在开发期间发现在型号旧一点的 iPhone 设备上很容易出现闪退的现象,应该是页面使用的内存超过了上限。

在项目中使用的资源体积最大的是模型 gltf 文件,检查文件的内容,占体积很大一部分的是纹理贴图,解析资源发现很多贴图的大小是 3K(3072x3072 的图片),根据 WebGL 渲染原理,无论贴图的资源原来是什么格式,最后在渲染前需要解压,相当于一张贴图需要在内存中占 3072 x 3072 x 3Byte = 27MB,解压后还需要传到 GPU,在多张贴图同时渲染时很可能占用大量的内存。

经过和设计侧的沟通,同意在一些展示距离不可能很近的模型上替换较低分辨率的贴图。

另外通常 2D 项目中使用的 png/jpg 格式图片,并不适合 3D 渲染,他们需要经过上述的解压过程,才能被 GPU 读取。

在 3D 渲染领域,有其他适合 GPU 读取的格式,如安卓支持的 ETC ,iOS 支持的 PVRTC,新一代的标准压缩纹理格式 ASTC ,他们都不需要解压就可以被 GPU 读取,可以大大减少中间解压占用的内存容量。

在项目中,我们使用 gltf-transform 工具做缩小贴图分辨率,和转换格式的工作。

模型减面

模型在 WebGL 中渲染的流程是先用模型的顶点信息确定三角面,再在每个三角面上计算需要展示的颜色。所以如果能减少模型面的数量,能减少每次渲染的计算量,减少每帧需要的渲染时间。

而如上面所说的,设计师建模的时候,可能面对的需求是输出渲染图,而不会对实时渲染做优化,所以在某些地方可能使用了过多的面。

参考了团队内其他同学的优化经验 1,使用 gltf-transform 工具对模型进行自动化减面。在和设计测反复沟通后,我们确定了减面的参数 ratio = 0, error = 0.0001

合批渲染

在 3D 渲染中有一个 draw call 的概念,一次 draw call 就是 CPU 向 GPU 下的一次画图指令。在一次指令中,CPU 会向 GPU 传递需要画的三角形信息,和三角形上颜色怎么计算的方法,这个方法用人类明白的语言称作材质。所以一次 draw call 只能画相同材质的面。

因为每次 draw call 有这些准备的动作,所以通常两次 draw call 会比一次花的时间多。

在模型文件中,相同材质的面,可能不是定义在同一个模型中,这样 CPU 会把这些面拆分成不同的画图指令,令 draw call 数量增加。

有一种对这种情况的优化方法叫合批,可以对这些相同材质的面合并,使他们可以在一次 draw call 中完成绘制。

这工作没有工具帮助我们处理模型文件,但是在前端加载模型文件时,可以遍历模型中的网格 mesh ,把使用相同材质的做合并。

需要注意的是带动画的网格不能这样处理,因为合并后的物体中心会变化,例如两个自转的球合并之后会围绕两个球的中点公转。

后续迭代

模型懒加载和分级加载

虽然暂时的项目展示的场景还不是很大,同时加载和渲染对设备的压力不算很大,但在场景增长到一定程度的时候,需要引入模型的懒加载和分级加载。

◦懒加载策略:在镜头移动到足够靠近时再加载并插入模型到场景,销毁离镜头足够远的模型。

◦分级加载策略:在镜头较远时,加载较低精度的模型,较近时再切换成精度高的模型。

以上两个策略都是现在较大型的 3D 游戏会使用的加载策略,能减少同一屏幕中绘制的面数量,减轻渲染压力。

分级渲染

现时访问 3D 项目的设备性能差距非常大,有加上特效也能流畅运行的,也有只能在设备分辨率下基本运行的。

babylonjs 自带一个分级渲染的功能,能实时检测运行帧率决定是否降级,在之后的迭代中,可以增加从像素分辨率加上特效到设备分辨率基本渲染的分级渲染策略。

实时光影

在使用以上的分级渲染策略后,可以在性能较好的设备上加上实时光影的特效,动态替换预烘焙贴图

场景搭建工具

在之前的项目开发过程中,设计师和产品、运营都需要通过前端输出 demo 才能大概体验到 3D 场景的效果,决定下一步如何调整。为解决这个痛点,我们团队开发了一个 3D 场景的搭建工具,用户可通过上传 gltf 文件搭建 3D 场景,实时预览渲染效果。

并加入了在项目中沉淀的互动组件,快速生成 3D 场景项目。

参考来源:

  1. 说一说 glTF 文件压缩 https://jelly.jd.com/article/61057292df18aa019e8a2967

作者:京东零售 胡俊文

来源:京东云开发者社区


↙↙↙阅读原文可查看相关链接,并与作者交流