一个初学者的Three.js踩坑分享 (1)——色彩空间、buffer、camera.up、导入模型的position..........

4,403 阅读10分钟

web3D技术(说的就是webGl)作为一个带web前缀的“鸡肋”前端技术, 一直不温不火(希望作为实验特性的webGPU对GPU利用效率翻天覆地的优化能改变这一现状), 因此, 网路上的相关资源、教程也比较有局限性, 作为一个初学者,在这里与大伙分享一些网路上难以搜到/大部分教程不会深入的坑/奇淫巧技.

大伙轻点喷,友善讨论😋

1.一点点点性能优化

网上大部分教程重心都放在对three本身的学习上,讲实际应用的很少/要付费,而当你真正想写一个项目的时候(例如可视化),很多在学习时没有遇到过的问题就出来了。比如在实际SPA开发中使用路由(vue-router)时,路由到其他组件后,render、scene等对象是不会被自动销毁的,为保证切换到其他页面后浏览器的性能不受影响,我们需要手动释放性能

1.cancelAnimationFrame()

requestAnimationFrame()是H5的新API, 与setInterval()有些许类似, 具体细节各种面经里都有, 此处不再赘述, 总之就是能比用定时器更流畅、均衡地执行周期性操作, 执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,其每秒执行次数通常与 浏览器屏幕刷新次数 相匹配

众所周知, 想要让3d场景动起来,我们需要调用requestAnimationFrame()函数,要求浏览器在下次重绘之前调用指定的回调函数更新动画,但当进行spa开发时, 离开页面的时候,我们已经不需要更新动画了,但requestAnimationFrame并不会在生命周期钩子中自动停止,这个动作仍然在无情地吞噬着你的系统健康资源,这时,就需要调用cancelAnimationFrame()来取消递归。

setInterval/clearInterval的用法类似, cancelAimationFrame(id)函数接收的参数id为requestAnimationFrame()的返回值,我们需要用一个参数来不断接受并更新这个值

let id;
animate(){
    //..............
    //一堆函数,如update(),render(),resize()之类
    //..............
    id=window.requestAnimationFrame(animate);
    console.log(id);  //便于观察
},	    
    

这时,可以通过控制台观察到浏览器在以一个性能-流畅度都最优的时间间隔调用requestAnimationFrame(animate), id一直在被requestAnimationFrame(animate)更新:

截屏2021-12-24 下午11.57.12.png 这样,在离开页面时(以vue为例),在beforeDestorye()钩子里调用cancelAnimationFrame(id)并把id传进去,就能停止场景的刷新了了, 聪明的性能又占领高地了.

P.S.

另外,用vue+three时还需要注意的一点是不要把THREE的对象放到data(){return{}}里,Object3D对象本身就不小,vue还疯狂监听它的变化,直接一帧不卡,两帧流畅,三帧电竞了。反正写THREE基本用不到和DOM间的数据监听、绑定,就写在外面呗。

2.浅谈(很浅很浅很浅)visible/remove()/dispose()

每个three.js 实例的创建都会消耗大量的内存, three.js为3D对象(如几何体或材质)创建的渲染所必需的WebGL 实体,如缓冲区或着色器, 这些对象都是不会被自动gc的. 因为three.js自己也不知道这些实体的生命周期, 所以, 在写three的时候, 我们需要对内存进行手动管理, 我们也可以通过WebGLRenderer.info来获取webglrender所占用的资源:

Screen Shot 2022-02-18 at 4.24.53 PM.png

想将模型从场景中删除/隐藏, 通常有三种方法, visible = false, remove(), dispose(). 其中, visible = false, remove()表面看上去虽然有很大区别, 但从渲染、编译过程来看, 两者基本并无差别, 唯一区别就是当你使用Raycaster类来投影时, visible = false的模型由于还存在于父级的Object3D类中, 所以会被投影投到, 这是一个需要注意的点.

除此之外, 由于大部分图形api的管线粗略来讲都是将显存中的定点缓冲数据/内存中的材质与顶点数据送到GPU中进行运算、着色, 在three提供的webGlRenderer在选择渲染目标时, 会遍历scene中的Object3D, 取其中visible=true的对象进行渲染, 所以, 对于渲染器来说, visible = false, remove()的对象都不会被renderer遍历到, 也就都不会被渲染, 而remove()也只不过是在对象父级的Object3D中删除了该对象的索引, 对象本身所占内存空间并没有改变, 两种方法对显示性能、内存性能的影响也基本没有区别. 我们可以在remove()操作后再次打印WebGLRenderer.info, memory是不会有变化的.

dispose()是geometry/material/texture的方法, 可以直接将内存/缓冲区的数据释放, 在beforeDestroy钩子中点用下面函数:

  freeUp(obj) { 
    obj.children.forEach((data) => { 

      if (data.children) this.freeUp(data);

      data.geometry?.dispose();
      if(data.material?.type ){
        data.material.dispose();
        data.material.map?.dispose();
        data.material.envMap?.dispose();
      }
    })
  }
  //.....一堆其他代码
    //在vue组件的生命周期钩子中调用
    beforeDestoy(){
      freeUp(scene);
      console.log(renderer.info)
    }

控制台中可以观察到:

Screen Shot 2022-02-22 at 10.42.53 PM.png 可以看到, geometry, texture, material占用的内存基本被释放干净了(还剩几个没有释放的, 正在debug, 看下哪里遗漏了), 由上, dispose()才真正的做到了内存的释放, 是数一数二的gc👍

3.bufferGeometry与普通的geometry

两者都可以用来表示geometry, 并且生成mesh, 但却有天差地别的性能差距, 这是为什么捏?

bufferGeometry使用bufferAttribute来存储所有的顶点、面、颜色数据, 这是一个长度自定的数组, 数组的使用使得几何体的数据可以被更高效地传到CPU中.

geometryBufferGeometry 的用户友好型替代方案。geometry使用 Vector3Color 等对象存储物体的属性(顶点位置、面、颜色等), 对于用户来说, 使用对象来存储属性是更易读、方便的, 但是对性能的消耗(消耗将其转化为bufferGeometry的性能)会增加. 在想更方便地自定义一些小模型的各种属性时, 也可以考虑牺牲性能来使用geometry.

说白了就是把geometry喂给GPU, 他还得嚼一会儿, 而bufferGeometry相当于直接给GPU喂流食了. 不过在three.js的r125版本中, 直接把geometry从core中移除了, 毕竟它除了对开发者稍微友好那么一点, 其他方面可以说是一无是处了:

Source

2.色彩空间

设计/建模:这模型视觉效果老好老真实了,我牛不牛,啊?结果前端把模型导入,webGL一渲染,一眼顶真,总感觉没内味,这里的罪魁祸首一般就是色彩空间,责任全在gamma方!当然,作为一个并没有系统学过计算机图形学的电气跑路人,具体原理感觉我也讲不清。那就只能从网路上捡点原理:

sRGB 标准中的 Gamma 校正的幂函数曲线来自于心理学上的韦伯-费希纳定律(Weber-Fechner Law)或斯蒂文思幂定律(stevens'power law)。同时认为这条幂函数曲线和 CRT 显示器的物理属性毫无关系.Gamma 曲线就是把物理光强和美术灰度做了一个幂函数映射
链接:www.zhihu.com/question/27… image.png

总之就是: 人眼对光照强度的感知并不是线性的, 因此, 为了更符合人眼的视觉效果, 人们提出了sRGB色彩空间, sRGB是当今一般电子设备及互联网图像上的标准颜色空间。较适应人眼的感光。sRGB的gamma与2.2的标准gamma非常相似,所以在从linear转换为sRGB时可通过转换为gamma2.2替代.

因此, 为了保证颜色的一致、 真实, 我们在导入材质文件的时候,就需要对材质的色彩空间进行转换, threejs在渲染时判断贴图为sRGB后,会自动将贴图转换为Linear再进行渲染计算.

const textureLoader = new THREE.TextureLoader();
textureLoader.load( path, (textture) => {
    texture.encoding = THREE.sRGBEncoding;
});

此时, 渲染计算后的模型仍在linear空间,展示到屏幕时需要通过gamma校正,将linear转换回sRGB空间,也就是进行gamma校正,threejs中可通过对renderer进行设置,进行gamma校正,校正后的gamma2.2颜色空间与sRGB相似.

renderer = new THREE.WebGLRenderer(); 
renderer.outputEncoding = THREE.sRGBEncoding;

此时, 我们就能得到视觉效果更好, 更符合设计稿的材质颜色了.

注⚠️:在three.js最新版本r137中(github.com/mrdoob/thre…), WebGLRenderer 已经支持了自动检测材质的色彩空间, 如果正在使用r137版本, 就可以只对材质进行更改, 不需要使用上面两行代码对renderer进行设置了.

WebGLRenderer

需要注意的是:

  1. 若采用 GLTFLoader 导入模型,GLTFLoader 将在渲染前自动把贴图设置为·sRGBEncoding·,故不需要手动设置.

  2. 使用MeshBasicMaterial这种不受光照影响的材质时,着色器不需要做复杂的计算,故不需要进行色彩空间转换.

3.相机的使用问题

通常在解析几何这些课程中, 我们学习到的右手系通常是Z轴正方向为垂直向上, 但是webGl中的右手系是Y轴垂直向上的

常规的右手系     

           Z
           |
           |
           | ________Y
          /
         /
        X
three中perspectiveCamera的默认方向是这样的:
          Y
          |
          |
        O |________ X
         /
        /
       Z

(其实也就是绕x轴转了90度), 但是当配合OrbitController使用时( OrbitController中, 鼠标的水平方向上的移动在世界中被转化为相机绕up轴运动 ), 如果不适应, 就可以通过camera.up = (0, 0, 1)设置相机的方向(camera.up 只能为(1, 0 ,0), (0, 1, 0), (0, 0, 1),分别对应x, y, z 轴为垂直轴).

4.外部模型导入后的位置问题

使用各种loader对外部的各种模型(obj/gltf/glb/fbx等...)进行导入时, 模型的相对位置是能够从导出软件继承的, 但虽然能够继承实际的位置, 但是导入后, 模型的position默认为(0, 0, 0)并不会与几何体的实际位置相对应(类似CSS的position: relative, 导入three.js后生成的mesh为父盒子,其位置为(0, 0, 0),而geometry的位置信息存储于子盒子,也就是geometry中, 那么此时, 模型的实际位置再怎么变化, 也并不会影响父盒子的位置属性). 通常可以通过Box3 这个类对模型的实际位置进行计算:

const model = new Loader.load.....一堆代码// 总之通过loader加载了一个管他什么模型

const box = new Box3().setFromObject(model);
//此时, 可以获得一个包围盒模型,一个长方体将model包起来

const center = box.getCenter(new Vector3()) //通过这个函数就可以获得盒中心位置
const size = box.getSize(new Vector3()) //可以得到盒的尺寸
//上面函数中参数 new Vector3()不可缺少,

//包围盒的(x, y, z)的最大, 最小值可以通过box.max 与 box.min得到, 其类型均为Vector3

戛然而止!

暂时想到这么多, 如果有人看可能就攒够一波写个(2)😭😭😭