乐趣区

关于前端:元宇宙-3D-开荒场-探味奇遇记

我的项目概览及开发设计

这次尝鲜的业务搭档是食品部门,最终落地我的项目是“探味奇遇记”:用户应用右边“joystick”操作 IP 人物,返回本人感兴趣的美食馆、调整以后视角,以 3D 的模式虚构线下场馆购物体验。食品的数字人形象的第一视角在“元宇宙”虚构美食馆中的沉迷式体验片段如下:

想要体验的同学可用 APP 扫码中转(关上 APP 首页,拜访“美食馆”,点击右下角浮层也可体验):

“探味奇遇记“的 2 个业务指标别离是:页面停留时长、页面复访率。因而在和设计探讨完计划后,性能上以工作和商品两个维度开展,加上工作反馈的奖品列表以及老手教程,整体我的项目开发拆分为如下:

3D 沉迷式体验最终目标是保障业务指标的达成,本来 2D 场域中的购物流程依然必须思考进来。因而在前端架构上设计了过渡计划,将渲染分为了 2 局部。一部分是 3D 渲染的解决,一部分是一般的 DOM 节点渲染。其中 3D 渲染采纳的技术库是 Babylon,理论的前端设计如下图所示:

渲染实现

  1. 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 中批量加载多个模型时,对内存会造成不小的压力,因而理论模型的加载过程,采纳的是单个加载模式(须要依据以后环境决策)。

  1. 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()
      })
    }
  }
}
  1. 混合模式

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 盒子判断。

地图功能

“探味奇遇记”中设计的街景范畴比预期的略微大一些,同时减少了“寻宝箱”的游戏趣味性,对于第一次加入流动的用户来说,对以后所在位置是没有预期的。针对这一状况,设计减少了地图功能,在左上角显示入口来查看以后所在位置,具体示意如下:

地图功能的实现依赖于视觉设计,以及以后地位的计算。波及的次要环节如下:

  1. 在展现地图弹窗的时候,事件散发器触发一个事件,使场景把人物以后地位和方向写到地图弹窗 store,把人物标识展现到地图上对应地位;
  2. 解决地图与场景的地位对应关系。先找到地图对应场景 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 等压缩纹理格式文件,并把纹理文件和模型其余信息进行分拆,在不同的设施上按反对度加载不同格局的纹理文件。
  • 高模细节烘焙到低模:将高精度模型的细节烘焙到贴图上,而后把贴图利用到低精度模型中,保障贴图精度的同时,尽量暗藏模型精度的缺点。

其余踩过的坑

  1. “光”解决

在视觉提供的“白模”初稿时,因为色调和整体街景是过渡版本,大家对渲染成果并没有提出异议,但当整体色调、贴图同时导出后,前端的渲染后果,和视觉的烘焙导出预览差别较大。通过认真比照,以及咱们本人开发的 3D 素材治理平台上的预览比照,发现“光”的影响十分大。下图左右别离是减少“光”和去除“光”的状况:

在和视觉进行沟通,屡次试验尝试后,最终解决方案如下:

  • 环境光退出 HDR 贴图,使场景取得更亮堂的体现和反射信息;
  • 在摄像机往前的方向退出一个方向光,晋升整体亮度。

对应的延展优化:减少高空光反射,实现倒影从而晋升街景气氛。

  1. GUI 渲染清晰度
    在我的项目中,应用 Babylon 内置的 GUI 层展现图片,展现的成果清晰度太差,达不到还原设计稿的要求。例如下图中的文字、返回箭头的锯齿:



因为在默认的配置下,3D 画布的大小为屏幕的显示分辨率作为大小,如 iPhone13 为 390×844,Babylon 官网提供的办法是应用屏幕点物理分辨率作为画布的大小,不仅能够点对点渲染 GUI,并且场景的分辨率也更加清晰;但这种计划减少了整体我的项目的 GPU 渲染压力,对原来曾经缓和的资源来说再减少计算量这个计划不好应用。

于是批改把显著渲染品质不好的 GUI 组件,移出 3D 画板,应用 DOM 组件来渲染,能够在应用同样资源的状况下把按钮图片渲染达到设计稿要求的成果,解决后成果如下:


  1. 发光材质的解决

在视觉设计的过程中,会在一些模型上应用自发光的材质,让这个模型影响四周的模型,以出现细腻的光影成果,如下图视觉稿所示:

然而开发应用到的框架并没有发光材质的利用级实现,当咱们把模型放入页面时,没有设置灯光的时候,模型上只有发光材质的局部能够被渲染进去,其余局部都是彩色的(下图左所示);而在设置了灯光之后,模型整个被最亮,没有任何光影成果(下图右所示):

解决方案:与设计师配合,将受部分灯光影响产生的光影成果烘培到材质贴图中,而页面还原上只须要设置正当的灯光地位,就能够还原成果,如下图所示:

结语

感激休食水饮部营销经营组、平台营销设计部翻新营销设计组大佬们的摸索精力和反对,全力投入使得我的项目在 5 月吃货节上线。《探味奇遇记》是对将来购物的一种尝试与摸索,满足顾客对将来美妙离奇的一个需要。将购物场景化、趣味化,给顾客带来美妙的购物感触。在复盘我的项目数据时发现,点击率和转化率数据都略高于同期会场。

在这次 3D 技术落地的过程中,尽管踩了不少坑,但也是播种满满,作为垦荒团的确是离“元宇宙”的指标更进一步了,置信咱们的技术和产品会越来越成熟,也请大家期待团队的可视化 3D 编辑工具!

参考文章及链接

  • 摸索篇链接
  • 横屏 VS 竖屏
  • Babylonjs 探讨帖
退出移动版