TRUE SPACE with Cocos 技术分享

项目背景

TRUE大会是美的楼宇科技每年举办的高规格的行业大会,TRUE SPACE是由美的楼宇科技研究院TEAM x.y.z.和IBUX团队共同打造的一个TRUE大会线上数字空间。

它打破了传统单一的参会方式,为现场和线上的观众提供了全新的线上虚拟逛展体验,其中玩家可以生成自己的虚拟形象,畅游本届大会的八大主题,解锁隐藏在场景中的游戏互动和惊喜彩蛋;玩家之间可以自由对话、交换名片、连麦交流、更便捷、更趣味地进行社交活动,是目前市面上标杆级的类元宇宙在线应用。

大家可以从 meta.midea.com 直接访问体验。

为什么选择Cocos

先来说说为什么选择Cocos来开发。TRUE SPACE我们希望能通过网页直接访问,并且和TRUE官网无缝衔接,所以Cocos的网页发布能力以及相对完善的编辑器能力成为我选择它的主要原因,另外Cocos的引擎源码是开源的,方便我们做一些定制化和问题的定位,再一个我们与Cocos有非常好的感情,这也让Cocos成为我的首选。

主要内容

TRUE SPCACE里我们实现了不少有趣的效果,我们做了花朵生长,失重、穿梭、水体、地球导航、拍照分享等等,有很多小心思,这篇文章我将挑一些比较通用的功能分享给大家。内容如下

  1. 反射探针捕捉
  2. 反射探针加载
  3. 反射探针插值
  4. 盒子投影(boxProjection)
  5. PBR优化
  6. 动态生成海报
  7. 动态UI适配
  8. 更换形象
  9. 场景加载动画
  10. 用到的插件

1. 反射探针捕捉

对于一个场景来说,反射是营造质感非常重要的手段,由于我们使用Cocos的版本是3.5.2那个时候Cocos还不支持反射探针,为了能让场景有更好的视觉体验,我写了一个反射探针的插件。反射探针的原理其实很简单,就是将相机的fov设置成90度,对场景的6个方向进行拍摄,并存储为图片。为了让图片能更好的的预览和节约存储空间,我将图片转成了全景图,另外在保存的过程当中,我对图片做了基于粗糙度的预卷积计算。所以最终输出的图片如下。

2、反射探针加载

将图片加载到cubemap的多级mipmap中,也遇到了坑,在一些低版本的ios的浏览器中,不支持设置framebuffer的mipmap等级,最后是通过直接读取纹理数据的方式绕过了使用framebuffer,伪代码如下

for (let i = 0; i < 6; i++) {
    bakeMaterial.setProperty("face", i);
    blit(tempRT, bakeMaterial.pass[2]);
    cubemap.uploadData(tempRT.readPixels(), level, i);
}

3、反射探针插值

反射探针的插值,是通过物体包围盒与反射探针box相交的体积比例,作为权重实现的。
计算包围盒相交体积的代码

Vec3.subtract(__boxMin, worldBounds.center, worldBounds.halfExtents);
Vec3.add(__boxMax, worldBounds.center, worldBounds.halfExtents);
Vec3.min(__interMax, __boxMax, probe.boxMax);
Vec3.max(__interMin, __boxMin, probe.boxMin);
let volume = max(__interMax.x - __interMin.x, 0.001) * max(__interMax.y - __interMin.y, 0.001) * max(__interMax.z - __interMin.z, 0.001);

探针插值部分Shader代码

vec3 getIBLSpecularRadiance(vec3 nrdir, float roughness, vec3 worldPos) {
    vec3 env0 = SAMPLE_REFLECTION_PROBE(cc_reflectionProbe0, worldPos, nrdir, roughness);
#if CC_REFLECTION_PROBE_BLENDING
    float t = cc_reflectionProbe0_boxMax.w;
    if (t < 0.999) {
        vec3 env1 = SAMPLE_REFLECTION_PROBE(cc_reflectionProbe1, worldPos, nrdir, roughness);
        env0 = mix(env1, env0, t);
    }  
#endif
    return env0;
}

4、盒子投影(boxProjection)

如果简单的对捕捉的环境做反射你会发现空间上是有问题的,如下


未开启boxProjection,地面反射的场景距离用户非常远

开启boxProjection,地面可以在正确的范围内反射场景

那么如何解决呢?方案是开启boxProjection。将贴图的采样投影到一个box内,这样会有相对正确的空间感(InteriorCubeMap也是类似的原理,InteriorCubeMap可以用于做假室内效果)。但是这样又出现了个新问题,超出box的地方,会出现严重的采样错误,并且交界处会出现边界线。


有边界线
解决方案就是将box的最小尺寸设置成物体的包围盒的大小,这样可以减少很大一部分的视觉问题。那它又会带来什么问题呢?留给大家思考了

边界线消失

而在写这个插件的过程当中,其实也遇到不少问题,比如gfx不支持cubemap作为framebuffer的输出,这里我用了比较hack的手段,直接使用webgl的原生方法来绕过gfx,但是这样会造成平台兼容性的问题,不过这个插件只是用于离线生成,所以问题也不大。代码片段如下

for (let i = 0; i < 6; i++) {
    gl.bindFramebuffer(gl.FRAMEBUFFER, glFramebuffer);
    gl.framebufferTexture2D(
        gl.FRAMEBUFFER,
        gl.COLOR_ATTACHMENT0,
        gl.TEXTURE_CUBE_MAP_POSITIVE_X + i,
        glTexture,
        0
    )
    gl.bindFramebuffer(gl.FRAMEBUFFER, cache.glFramebuffer);
    camera.node.worldRotation = CameraForwards[i];
    camera.camera.update(true);
    renderPipeline.render(cameras);
}

5、PBR优化

常规的PBR渲染会包含两张预计算的环境贴图分别对应了specular和diffuse,移动端对贴图带宽是很敏感的,所以能省则省,这里我做了两个优化策略,一个是用SH替代diffuse卷积图,另一个是直接用粗糙度为1的specuar卷积图(看起来和diffuse卷积图视觉上很接近,索性直接用了),第二个方案对资源的需求更少,只要一张卷积图就够了,TRUE SPACE用了第二种,插件里我全做了支持。PBR的计算都是在线性空间,最后输出要做一个tonemap(将HDR转成LDR)和gamma矫正,这里我将两个合在一起用了一个近似

x = x/(x+0.187) * 1.035;

6、动态生成海报

TRUE SPACE做了一个拍照分享功能,用户可以将自己的游玩画面生成海报分享给微信好友。这背后有两个问题1、如何生成这张图片2、微信如何能识别这张图片。

如何生成这张图片

生成海报主要原理是利用相机的targetTexture功能,用户可以将相机拍到的内容输出到一张renderTxture上,然后将这张图给到Spite的spriteFrame即可。所以处理流程是这样的,先用场景相机渲染三维场景到一张renderTxture上,然后再用UI相机将UI渲染到这张renderTxture上,就能得到一张完整的海报。那么海报生成好了可以直接拿去分享吗?答案是不行,微信没法识别这张图片,因为它本质上不是图片。所以我们要把它转变成一张真正的图片。

微信如何能识别这张图片

经过测试,我们发现微信可以识别img标签,并且还能用于好友之间的分享,所以上面的问题就变成了如何将renderTexture转换成img标签,幸运的是cocos的renderTexture内置了一个readPixel方法,可以直接读取图像数据,所以问题就变成了如何将图像数据生成dataURL喂给img标签,最终的伪代码大致是这样的

function ToObjectURL(RT, x, y, width, height) {
    let pixels = RT.readPixels(x, y, width, height);
    if (pixels) {
        let canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        let context = canvas.height.getContext('2d')!;
        let imageData = context.createImageData(width, height);
        context.putImageData(imageData, 0, 0);
        return canvas.toDataURL("image/png");
    }
}
let img = new Image(width, height);
img.src = ToObjectURL(RT, x, y, width, height);
game.container!.appendChild(img);

7、动态UI适配

默认情况下当浏览器的分辨率与设计分辨率不一致时,UI会出现下面的情况,比例严重失调


TRUE SPACE做了全平台的适配,UI比例会根据宽高比自适应,这样折叠屏也都适配了

下面是设计分辨率的适配代码

function onVisibleSizeChanged() {
    let size = view.getVisibleSize();
    let ratio = size.width / size.height;
    if (ratio > 1) {
        ratio = ratio / 1.8 * 1.5;
        view.setDesignResolutionSize(1920 * ratio, 1080 * ratio, ResolutionPolicy.FIXED_WIDTH);
    }
    else {
        ratio = lerp(1, ratio / 0.48, ratio >= 0.6 ? 1 : 0);
        view.setDesignResolutionSize(750 * ratio, 1334 * ratio, ResolutionPolicy.FIXED_WIDTH);
    }
}

8、更换形象

TRUE SPACE更换形象的操作挺有意思的,镜头会往角色推进,背景会被虚化,并且角色不会被任何物体遮挡,这里是怎么做到的呢?

其实这里用了两台相机,当点击个人头像时,一台负责渲染场景,另外一台相机负责渲染角色,清除深度(clearFlags设置为DepthOnly),并将角色渲染到画面的最前端。

9、场景切换动画

我们做了一个比较有趣的场景切换效果,这个原理其实是把UI渲染到一张renderTexture上,然后将这个renderTexture赋值给Sprite,最后通过自定义SpriteMaterial实现。


这里遇到一个坑,当浏览器尺寸发生变化时,复用renderTexture会让Sprite会丢失画面,经过大量尝试,我最后通过new RenderTextue()解决,代码如下

let size = view.getVisibleSize();
if (this._loadingTexture.width != size.width || this._loadingTexture.height != size.height) {
    this._loadingTexture.destroy();
    this._loadingTexture = new RenderTexture();
    this._loadingTexture.reset({ width: size.width, height: size.height });
    let spriteFrame = new SpriteFrame();
    spriteFrame.texture = this._loadingTexture;
    this.loadingSprite.spriteFrame = spriteFrame;
    this.camera.targetTexture = this._loadingTexture;
}

10、用到的插件

总结

限于篇幅,本文没有对更多具体功能做更细节的分享,大家可以对自己感兴趣的点在评论区留言,后续我们将针对大家的兴趣点推出一些教程。另外我们也在招人,欢迎志同道合的小伙伴投递简历给我 chenxy312@midea.com

14赞

赞 

可以可以,牛的

up up

真的厉害!

太厉害了,学习了

大佬,可以来个小白教程吗?这种交互体验太令人惊喜了

模糊背景赞。

这个项目从立项到第一个版本的完成一共要用多长的时间呀?