项目概览及开发设计
这次尝鲜的业务伙伴是食品部门,最终落地项目是“探味奇遇记”:用户使用左边“joystick”操作 IP 人物,前往自己感兴趣的美食馆、调整当前视角,以 3D 的形式虚拟线下场馆购物体验。食品的数字人形象的第一视角在“元宇宙”虚拟美食馆中的沉浸式体验片段如下:
想要体验的同学可用 APP 扫码直达(打开 APP 首页,访问“美食馆”,点击右下角浮层也可体验):
“探味奇遇记“的 2 个业务指标分别是:页面停留时长、页面复访率。因此在和设计讨论完方案后,功能上以任务和商品两个维度展开,加上任务反馈的奖品列表以及新手教程,整体项目开发拆分为如下:
3D 沉浸式体验最终目的是保障业务目标的达成,原本 2D 场域中的购物流程仍然必须考虑进来。因此在前端架构上设计了过渡方案,将渲染分为了 2 部分。一部分是 3D 渲染的处理,一部分是普通的 DOM 节点渲染。其中 3D 渲染采用的技术库是 Babylon ,实际的前端设计如下图所示:
渲染实现
-
3D 场景渲染
场景包括街道氛围、各个特色美食场馆、IP 人物在 HTML 中的渲染,在
早期Demo中
对于 3D 基础渲染已经实现,在这次项目中主要优化了以下两点:
-
强制界面横屏翻转
相较于竖屏 90 度以下的视野范围,横屏更符合人类生理上的视觉范围( 114 度夹角左右)。前期技术方案中前端采用了自适应手机横竖屏展示。由于 APP 中不支持横屏,需要人为将竖屏内容整体翻转为横屏展示,调整后不论横屏或竖屏,其界面呈现如下:
涉及的主要代码如下:
// 全局容器在竖屏情况下,宽为屏幕高度,高为屏幕宽度,旋转90deg
.wrapper{
position: fixed;
width: 100vh;
height: 100vw;
top: 0;
left: 0;
transform-origin: left top;
transform: rotate(90deg) translateY(-100%);
transform-style: preserve-3d;
}
// 横屏情况不做旋转
@media only screen and (orientation: landscape) {
.wrapper{
width: 100vw;
height: 100vh;
transform: none;
}
}
- 封装资源管理中心
3D 页面需要渲染的文件比较多,且质量不小,因此对所需资源封装了资源管理中心来集中处理加载。包括:3D 模型、贴图图片、纹理等类型,以及相同资源文件的去重处理。
以模型加载为例涉及的主要代码如下:
async appendMeshAsync (tasks: Tasks, withLoading = true) {
// loading处理
...
const promiseList: Promise<MeshAssetTask>[] = []
// 过滤同名模型
const unqiTasks = _.uniqWith(tasks, (a, b) => a.name === b.name)
for (const item of unqiTasks) {
const { name, rootUrl, fileName, modelRoot = '' } = item
// 避免重复加载
if (this.modelAssets.has(name)) {
console.log(`${name}模型已加载过`)
continue
}
const promise = new Promise<MeshAssetTask>((res, reject) => {
const task = this.assertManager.addMeshTask(
`${Tools.RandomId}_task`,
modelRoot,
rootUrl,
fileName)
task.onSuccess = result => {
this.savemodelAssets(name, result)
res(result)
}
task.onError = () => {
reject(null)
}
})
promiseList.push(promise)
}
// load
this.assertManager.loadAsync()
const ret = await Promise.all(promiseList)
return ret
}
资源管理中心本质仍然是使用 Babylon 的 addMeshTask 、addTextureTask 等来加载模型、纹理,在项目应用过程中,发现在 APP 中批量加载多个模型时,对内存会造成不小的压力,因此实际模型的加载过程,采用的是单个加载模式(需要根据当前环境决策)。
-
DOM 组件
DOM 组件主要覆盖日常电商活动中业务相关流程界面,例如下图中的商品列表展示、商品详情内容、奖品发放弹窗等:
- 商品列表页
列表页实现拉取运营配置商品组素材,展示商品名、商品图片、价格、促销信息等等。
其问题表现为:在 App 仅支持竖屏的前提下,Demo 的横屏方案是样式层面的 90 度旋转,需要考虑横屏翻转对触控操作的影响。横屏模式下的横滑操作,Webview 会识别为竖屏下的竖滑操作,造成滑动方向与预期不符。
解决方案:舍弃日常使用的 CSS 方案( overflow: scroll ),重写横滑组件。
具体实现:首先将展示区域外的部分隐藏;根据设计的滑动方向,获取用户在与该方向垂直的方向上的触控距离,例如这次项目中商品列表为横向滑动,则需要获取用户在 Y 轴的触控距离,再根据这个触控距离决定商品列表的横向偏移以及虚拟滚动条的滑动距离,以此纠正因旋转画面造成的滑动方向不对的问题。
- 商详页
在列表页操作后展示更多的商品细节信息,以及覆盖“加购”这一业务链路,是当前活动中关联订单成交的重要一环。这里复用普通会场中的商品组件即可。
- 3D 模型展示弹窗
项目接入了京豆、优惠券等奖品的发放,同时为了达成复访率指标加入了累积签到的奖励。因此也涉及大量的弹窗提示,普通弹窗复用会场中的 Toast 组件来实现,开发量不大。
其中较为特殊的是收集物展示界面,和商品列表与商详关系类似,点击收集物后出现对应物品的 3D 模型。由于层级关系,收集物的 3D 模型不能在展示场景的画板渲染,所以另起一个 WebGL 画板作渲染,为减少渲染量,在显示收集物模型画板时,暂停场景画板的渲染。其效果和关键代码如下所示:
componentDidUpdate (prevProps: TRootStore) {
const prevIsStampShow = prevProps.stampDetailModal.isShow
const isStampShow = this.props.stampDetailModal.isShow
if (prevIsStampShow !== isStampShow) {
this.engine.stopRenderLoop()
if (isStampShow) {
this.engineRunningStatus = false
} else if (!this.engineRunningStatus) {
this.engine.runRenderLoop(() => {
this.mainScene.render()
})
}
}
}
- 混合模式
3D 渲染以及 DOM 组件的迁移复用,覆盖了大部分渲染的场景,但仍然存在部分例外:
左边是在上一节出现过的商品列表页( DOM 组件),右边则是当前这类商品的 3D 模型。所以在渲染除了独立的3D模型渲染、普通的 DOM 渲染外,还需要考虑混合在一起的情况,以及互相之间的通讯联动。
Babylon 是一个优秀的渲染框架,除了 3D 模型的渲染,完成模型交互,也可以在 3D 场景中混合地渲染 2D 界面。但是对比原生的 DOM 渲染,使用 Babylon 呈现 2D 画面的开发成本和成品效果都莫可企及。因此,在需要渲染 2D 的场景,我们都尽可能采用 DOM 的渲染方式。
在这样的页面结构下,3D 模型渲染逻辑由 Babylon 框架处理,普通的 DOM 渲染逻辑由 React 处理,两者之间的状态和行为由一个事件管理中心来处理。两者之间的交互就可以像两个组件之间一样。
具体如品类商品列表页,用户触发商品列表页展示之后,Babylon 对当前用户的场景帧进行截图保存,并对其进行暗化,模糊的处理之后设置成整个画布的背景,然后相机只渲染右侧模型部分,当右侧模型触发一些交互之后,由于事件管理通知左侧的 DOM 层去请求相应的商品信息并切换展示。
设置页面背景:
Tools.CreateScreenshot(this.scene.getEngine(), activeCamera, { width, height }, data => {
...
setProductBg(data)
...
})
private setProductBg (pic: string) {
const productUI = this.UIList.get('productUI')
if (productUI && productUI.layer) {
const image = new Image('bg', pic)
image.width = '100%'
image.height = '100%'
productUI.addControl(image)
this.mask = new Rectangle('mask')
this.mask.width = '100%'
this.mask.height = '100%'
this.mask.thickness = 0
this.mask.background = 'rgba(0, 0, 0, 0.5)'
productUI.addControl(this.mask)
productUI.layer.layerMask = detailLaymask
}
}
Babylon 通过事件同步 DOM 层:
private showSlider = (slider: Slider):void => {
slider.saveActive()
...
const index = slider.getCurrentIndex()
const dataItem = this.currentDataList[index]
const groupId = dataItem?.comments?.[0] || ''
this.eventCenter.trigger(EVENT_TYPES.SHOWDETSILS, {
name: dataItem.name,
groupId,
index
})
if (actCamera && slider) {
(slider as Slider).expand()
slider.layerMask = detailLaymask
}
...
}
最终效果如下图所示:
相机处理之空气墙
IP 主角在美食街中的探索是需要寻路的,整个街景中哪些道路可以路过,哪些无法通行,由视觉同学在设计时各个建筑的坐标和空隙决定。但我们必须要处理当主角行走至比较狭窄的道路中视角的切换和位移可能导致的穿模、不应该出现的视角等情况。
人物和建筑之间的穿模可以通过设置空气墙(包括地面)和人物模型的碰撞属性,使两种模型不会发生穿插来解决。对应的主要代码如下:
// 设置场景全局的碰撞属性
this.scene.collisionsEnabled = true
...
// 遍历模型中的空气墙节点,设置为检查碰撞
if (mesh.id.toLowerCase().indexOf('wall') === 0) {
mesh.visibility = 0
mesh.checkCollisions = true
obstacle.push(mesh as Mesh)
}
镜头避免进入到建筑、地面下也可使用设置碰撞属性来实现,但在障碍物位置在镜头和人物之间时,像拐角这种情况,会导致镜头被卡住,人物继续走的现象。
所以思路是求人物位置往镜头方向的射线,与空气墙相交的最近点,然后移动镜头到结果点的位置,实现镜头与障碍物不穿插。
实际开发中,在镜头(下图中圆球示意)和人物中间插入 3 个六面体,当六面体与空气墙发生穿插,则镜头位置移动到发生穿插的离人物最近的六面体靠近人物一端的端点,最近会移到人物头上的点。结合项目实际画面示例如下:
默认镜头处于默认镜头半径的位置如下图所示:
当其中一个六面体与空气墙交错时,移动镜头到碰撞六面体的端点时位置如图所示:
在镜头移动过程中,加上位置过渡动画,实现无级缩放的效果
模型之间精确判断穿插参考的是
Babylonjs讨论区里的一个帖子
,Babylon 内置的 intersecMesh 方法使用的是 AABB 盒子判断。
地图功能
“探味奇遇记”中设计的街景范围比预期的稍微大一些,同时增加了“寻宝箱”的游戏趣味性,对于第一次参加活动的用户来说,对当前所在位置是没有预期的。针对这一情况,设计增加了地图功能,在左上角显示入口来查看当前所在位置,具体示意如下:
地图功能的实现依赖于视觉设计,以及当前位置的计算。涉及的主要环节如下:
- 在展示地图弹窗的时候,事件分发器触发一个事件,使场景把人物当前位置和方向写到地图弹窗 store,把人物标识展示到地图上对应位置;
- 处理地图与场景的位置对应关系。先找到地图对应场景 0 点位置,再根据场景尺寸与图片大小计算大致缩放值,最后微调得到准确的对应缩放值。
export function toggleShow (isShow?: boolean) {
// 打开地图弹窗时触发获取人物位置信息事件
eventCenter.getInstance().trigger(EVENT_TYPES.GET_CHARACTER_POSITION)
store.dispatch({
type: EActionTypes.TOGGLE_SHOW,
payload: {
isShow
}
})
}
// 场景捕捉时间把人物位置和方向写入store
this.appEventCenter.on(EVENT_TYPES.GET_CHARACTER_POSITION, () => {
const pos = this.character?.position ?? { x: 0, z: 0 }
const rotation = this.character?.mesh.rotation.y ?? 0
if (pos) {
updatePos({
x: -pos.x,
y: pos.z,
rotation: rotation - Math.PI
})
}
})
新手引导
新手指引除了日常的功能引导,增加了类似赛车游戏中对具体建筑地点的路线指引,其思路是通过用户已经熟悉的游戏引导功能,来降低新玩家的认知成本。
在开发实现上,则拆解为数学问题。在引导线的方向、结束点固定的情形下,引导线展示的长度为人物位置到结束点向量在引导线方向的投影长度,如下坐标示例图所示:
// 使用向量点乘计算投影长度
setProgress (val: number | Vector3) {
if (typeof val === 'number') {
this.progress = val
} else if (this.startPoint && this.endPoint) {
const startToEnd = this.endPoint.add(this.startPoint.negate())
const startToEndNormal = Vector3.Normalize(startToEnd)
this.progress = 1 - Vector3.Dot(startToEndNormal, (this.endPoint.add(val.negate()))) / startToEnd.length()
}
}
引导线为一个平面,两端的渐变效果和动画使用定制的着色器实现
# 顶点着色器
uniform mat4 worldViewProjection;
uniform vec2 uScale;# [1, 引导线长度/引导线宽度] 用于计算纹理的uv座标
attribute vec4 position;
attribute vec2 uv;
varying vec2 st;
void main(void) {
gl_Position = worldViewProjection * position;
st = vec2(uv.x * uScale.x, uv.y * uScale.y);
}
# 片元着色器
uniform sampler2D textureSampler;
uniform vec2 uScale;# 同顶点着色器
uniform float uOffset;# 纹理偏移量,改变这个值实现动画
uniform float uAlphaTransStart;# 引导线开始端透明渐变长度/引导线宽度
uniform float uAlphaTransEnd;# 引导线结束端透明渐变长度/引导线宽度
varying vec2 st;
void main(void) {
vec2 rst = vec2(st.x, -st.y + uOffset);
float alphaEnd = smoothstep(0., uAlphaTransEnd, st.y);
float alphaStart = smoothstep(uScale.y, uScale.y - uAlphaTransStart, st.y);
float alpha = alphaStart * alphaEnd;
gl_FragColor = vec4(texture2D(textureSampler, rst).rgb, alpha);
}
性能优化
在 HTML 中,通过第三方库实现对 3D 模型的渲染,并且搭建一个街景以及多个场馆,在内存方面确实是一笔庞大的开支。在开发过程中,由于和视觉设计并行,早期并没有发现异常,随着视觉逐步提供模型文件,慢慢呈现整体氛围,游戏过程中的卡顿、闪退现象也出现了。
通过 PC 端开发环境下对内存的监控,以及和 App Webview 大佬的沟通,提前进入了性能优化讨论环节。目前整个项目中模型共有 30 个,单个文件大小平均在 2M 左右(最大的街景是 7M)。因此在性能优化上,主要采取了以下方法:
-
控制贴图精度
在优化过程中发现占模型文件大部分体积的是贴图数据,在和视觉沟通和尝试后,把绝大部分的贴图控制在 1Kx1K 以下,有部分贴图尺寸是 512×512。 -
使用压缩纹理
使用传统的 jpg/png 格式作为纹理文件,会使图片文件在浏览器图片缓存和 GPU 存储都占一份空间,增大页面内存占用量。内存占用到一定大小,会在 IOS 设备下出现闪退、Android 设备下出现卡顿、掉帧等现象。而使用压缩纹理格式,可以使图片缓存只在 GPU 存储保留一份,大大减少了内存占用。具体操作实现:在项目中使用纹理压缩工具,把原 jpg/png 图片文件转换成 pvrtc/astc 等压缩纹理格式文件,并把纹理文件和模型其他信息进行分拆,在不同的设备上按支持度加载不同格式的纹理文件。 -
高模细节烘焙到低模:将高精度模型的细节烘焙到贴图上,然后把贴图应用到低精度模型中,保证贴图精度的同时,尽量隐藏模型精度的缺陷。
其他踩过的坑
- “光”处理
在视觉提供的“白模”初稿时,由于色彩和整体街景是过渡版本,大家对渲染效果并没有提出异议,但当整体色彩、贴图同时导出后,前端的渲染结果,和视觉的烘焙导出预览差异较大。通过仔细对比,以及我们自己开发的 3D 素材管理平台上的预览对比,发现“光”的影响非常大。下图左右分别是增加“光”和去除“光”的情况:
在和视觉进行沟通,多次实验尝试后,最终解决方案如下:
- 环境光加入 HDR 贴图,使场景获得更明亮的表现和反射信息;
- 在摄像机往前的方向加入一个方向光,提升整体亮度。
对应的延展优化:增加地面光反射,实现倒影从而提升街景氛围。
-
GUI 渲染清晰度
在项目中,使用 Babylon 内置的 GUI 层展示图片,展示的效果清晰度太差,达不到还原设计稿的要求。例如下图中的文字、返回箭头的锯齿:
因为在默认的配置下,3D 画布的大小为屏幕的显示分辨率作为大小,如 iPhone13 为 390×844,Babylon 官方提供的方法是使用屏幕点物理分辨率作为画布的大小,不仅可以点对点渲染 GUI,并且场景的分辨率也更加清晰;但这种方案增加了整体项目的 GPU 渲染压力,对原来已经紧张的资源来说再增加计算量这个方案不好使用。
于是修改把明显渲染质量不好的 GUI 组件,移出 3D 画板,使用 DOM 组件来渲染,可以在使用同样资源的情况下把按钮图片渲染达到设计稿要求的效果,处理后效果如下:
3. 发光材质的处理
在视觉设计的过程中,会在一些模型上使用自发光的材质,让这个模型影响周围的模型,以呈现细腻的光影效果,如下图视觉稿所示:
但是开发使用到的框架并没有发光材质的应用级实现,当我们把模型放入页面时,没有设置灯光的时候,模型上只有发光材质的部分可以被渲染出来,其他部分都是黑色的(下图左所示);而在设置了灯光之后,模型整个被最亮,没有任何光影效果(下图右所示):
解决方案:与设计师配合,将受局部灯光影响产生的光影效果烘培到材质贴图中,而页面还原上只需要设置合理的灯光位置,就可以还原效果,如下图所示:
结语
感谢休食水饮部营销运营组、平台营销设计部创新营销设计组大佬们的探索精神和支持,全力投入使得项目在 5 月吃货节上线。《探味奇遇记》是对未来购物的一种尝试与探索,满足顾客对未来美好新奇的一个需求。将购物场景化、趣味化,给顾客带来美好的购物感受。在复盘项目数据时发现,点击率和转化率数据都略高于同期会场。
在这次 3D 技术落地的过程中,虽然踩了不少坑,但也是收获满满,作为开荒团确实是离“元宇宙”的目标更进一步了,相信我们的技术和产品会越来越成熟,也请大家期待团队的可视化 3D 编辑工具!
参考文章及链接