企业网站制作公司有哪些,做手机网站公司,网站排名提高,seo企业网站优化作者#xff1a;vivo 互联网前端团队- Wei Xing 运营活动新玩法层出不穷#xff0c;web 3D炙手可热#xff0c;本文将一步步带大家了解如何利用Three.js和Blender来打造一个沉浸式web 3D展览馆。
一、前言
3D展览馆是什么#xff0c;先来预览下效果#xff1a; 看起来像… 作者vivo 互联网前端团队- Wei Xing 运营活动新玩法层出不穷web 3D炙手可热本文将一步步带大家了解如何利用Three.js和Blender来打造一个沉浸式web 3D展览馆。
一、前言
3D展览馆是什么先来预览下效果 看起来像个3D冒险类手游用户可以操纵屏幕中央的虚拟摇杆以第一人称视角在房间内自由移动、看展览。
1.1 为什么做3D展览馆
首先介绍一个背景我们的工作内容是做游戏中心的用户运营活动会做些好玩的活动让用户参与并get一些福利。
当时的活动背景是我司一年一度的vivo游戏节并且元宇宙是大热词。所以做它的原因有几个 vivo游戏节主题 契合元宇宙热点 新玩法、新体验
1.2 技术选型
用到的组合方案Three.js Blender。 why Three.js
开源的3D框架有很多但最常用的有两种Three.js、Babylon.js我们只需要从中二选一。分析后发现两者各有优势 考虑到3D展览馆的几个基本特性 简单的小型3D场景没有复杂的交互对镜头的要求不高 投放在移动设备需要尽可能小的包体以提升性能 工期短需要快速上手及更多的案例参考
Three.js包体更小、有更多参考案例、上手更快所以虽然Babylon.js有它的优势但Three.js更适合这个项目。 why Blender
Blender是一款轻量的开源3D建模软件有很多好用的免费插件而且Blender能导出GLTF / GLB模型后面会对GLTF / GLB模型做简介匹配Three.js的使用方式整体更简单好用一些。
所以就是它了。
二、实践部分
2.1 了解GLTF / GLB模型
在进入开发之前先简单了解Blender和GLTF / GLB模型。 简单了解 Blender
首先Blender大概长这样图中是设计师交付的3D展览馆稿子。简单理解为左侧是模型的层次结构中间是模型的预览效果右侧是模型的属性面板。
一般来说作为开发者我们不需要掌握太多Blender相关知识只需知道如何看懂模型结构、导出GLTF / GLB模型以及烘焙的基本原理即可。 GLTF / GLB模型
GLTFGraphics Language Transmission Format是一种标准的3D模型文件格式它以JSON的形式存储3D模型信息例如模型的层次结构、材质、动画、纹理等。
模型中依赖的静态资源比如图片可以通过外部URI的方式来引入也可以转成base64直接插入在GLTF文件中。
它包含两种形式的后缀分别是.gltfJSON/ASCII和.glbBinary。.gltf是以JSON的形式存储信息。.glb则是.gltf的扩展格式它以二进制的形式存储信息因此导出的模型体积也更小一些。如果我们不需要通过JSON对.gltf模型进行直接修改建议使用.glb模型它更小、加载更快。 Blender导出GLTF / GLB模型
在blender中可以直接将模型导出为GLTF / GLB格式三种选项的差别不再赘述我们先简单选择最高效的.glb格式。 有了模型之后我们可以开始通过Three.js创建场景并导入这个模型了。
2.2 Three.js 加载模型
为了防止篇幅过长这里假设大家已经掌握了Three.js的一些基本语法。文章重点放在如何加载模型并一步步进行调优和实现最终的3D展览馆效果。
怎么加载一个模型
1创建一个空场景
首先创建一个空场景scene后续所有的模型或材质都会被添加到这个场景中。
import * as THREE from three// 1. 创建场景
const scene new THREE.Scene(); // 2. 创建镜头
const camera new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
// 3. 创建Renderer
const renderer new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
2导入GLTF / GLB模型
通过GLTFLoader导入.glb模型并添加到场景中。
import GLTFLoader from GLTFLoader
const loader new GLTFLoader()
loader.load(path/to/gallery.glb,gltf {scene.add(gltf.scene) // 添加到场景中}
}
3开始渲染
通过requestAnimationFrame来调用renderer.render方法开始实时渲染场景。
function animate() {requestAnimationFrame( animate );renderer.render( scene, camera );
}
animate();
ok这样我们就完成了3D模型的导入但是发现整个场景一片漆黑。 试试加个环境光。
const ambientLight new THREE.AmbientLight(0xffffff, 1)scene.add(ambientLight) ok亮起来了但是效果依然很差很劣质。
原因是模型中的材质效果、光源、阴影、环境纹理这些全都丢失了所以当我们导入模型时看到的就是一堆简陋的纯色形状。
所以我们要一步步将这些丢失东西找回还原设计稿。
2.3 还原设计稿
接下来一步步还原设计稿。
1加上光源
查看Blender模型看到设计稿中添加了一堆点光源、平行光源。 点光源可以理解为房间中的灯泡光线强弱随着距离衰减
平行光源可以理解为太阳的直射光它和点光源不同光线强弱不随着距离衰减。
于是我们也增加一些光源
// 一些灯光选项
// 如果是平行光则没有distance、decay选项
const lightOptions [{type: point, // 灯光类型1. point点光源、2. directional平行光源color: 0xfff0bf, // 灯光颜色intensity: 0.4, // 灯光强度distance: 30, // 光照距离decay: 2, // 衰减速度position: { // 光源位置x: 2,y: 6,z: 0}},...]function createLights() {pointLightOptions.forEach(option {const light option.type point ?new THREE.PointLight(option.color, option.intensity, option.distance, option.decay) :new THREE.DirectionalLight(option.color, option.intensity)const position option.positionlight.position.set(position.x, position.y, position.z)scene.add(light)})
}createLights()
可以看到场景比之前好了一些有了光源后模型变得立体和真实了多了一些反色的光泽。 但是我们注意到画面中的logo、长椅的两侧都是黑色的并且旁边的球体、椅子等都显得不够真实。
所以我们需要进行下一步调整调整模型材质、增加环境纹理。
2调整模型材质增加环境纹理
先简单了解一下材质和环境纹理。 材质material
材质就像物体的皮肤我们可以调整皮肤的光泽、金属度、粗糙度、透明与否等属性让物体有不同的视觉效果。
一般从blender导出的模型中已经包含了一些材质属性但是Three.js中的材质属性和Blender中的属性并非完全的映射关系模型在导入到Three.js后效果和设计稿会有差异。这时候我们需要手动调整材质的属性来达到和设计稿近似的效果。 环境纹理environment map
环境纹理就是让模型映射周围的环境让场景或物体更真实。例如我们要渲染一个立方体把立方体放进一个屋子里这个屋子的环境就会影响立方体的渲染效果。
比如镜面的物体被贴上环境纹理后就可以实时反射周围的环境镜像看起来很real。
设计稿中也是将一个大厅作为了环境纹理让场景更真实。 环境纹理分为球形纹理和立方体形纹理。两者都可以这里我们采用一张大厅的球形纹理作为环境贴图。 以画面中的vivo游戏节logo为例我们通过调整它的材质和环境纹理让它变得更真实。 根据在blender中的命名找到logo模型 调整logo的表面粗糙度和金属度 加载并设置环境纹理贴图 const loader new GLTFLoader()
loader.load(path/to/gallery.glb,gltf {// 1. 根据Blender中物体的名字找到logo模型gltf.scene.traverse(child {if (isLogo(child)) {initLogo(child) // 2. 调整材质setEnvMap(child) // 3. 设置环境纹理}}scene.add(gltf.scene)}
}// 判断是否为Logo
const isLogo object.name logofunction initLogo(object) {object.material.roughness 0 // 调整表面粗糙度object.material.metalness 1 // 调整金属度
}
// 加载环境纹理let envMap
const envmaploader new THREE.PMREMGenerator(renderer)const setEnvMap (object) {if(envMap) {object.material.envMap envMap.texture} else {textureLoader.load(path/to/envMap.jpg,texture {texture.encoding THREE.sRGBEncodingenvMap envmaploader.fromCubemap(texture)object.material.envMap envMap.texture})}
}
经过上面的处理后可以看到原先黑色的logo有了金属光泽并且会反射周围的环境纹理。
其它物体经过类似的处理后也变得更真实一些。 现在整个场景更接近了设计稿一些但场景中少了阴影显得很干瘪。
加上阴影。
3增加阴影
增加阴影分四步 对renderer开启阴影支持renderer.shadowMap.enabled true 对光源设置castShadow true 对需要投影的物体设置castShadow true 对需要被投影的平面或物体比如地板设置receiveShadow true
// 1. renderer
const renderer new THREE.WebGLRenderer()
renderer.shadowMap.enabled true;
renderer.shadowMap.type THREE.PCFSoftShadowMap;// 2. light
const light new THREE.DirectionalLight()
light.castShadow true;// 3. object
gltf.scene.traverse(function (child) {if (child.isMesh) {child.castShadow true;}
});
// 4. floor
floor.receiveShadow true 添加阴影后有质的提升发现整个场景立体了很多此时还原度已经很高。
如果不考虑性能损耗这个场景的样式已经可以投入使用了。后续会提到性能优化
小结一下刚刚做的几件事 添加光源 调整模型材质、增加环境纹理 增加阴影
现在3D展览馆场景已经还原的差不多了接下来要构造一个虚拟移动摇杆控制第一人称镜头的移动和转向实现沉浸式逛展的效果。
2.4 虚拟移动摇杆
要实现通过虚拟移动摇杆控制镜头的移动和转向我们需要三个东西 一个移动摇杆handler 一个长方体player用于承载第一人称视角 一个镜头camera之前已经创建过了
有人会问为什么需要一个player通过摇杆直接控制镜头不就行了吗其实player的作用是用于做碰撞检测当player遇到凳子、墙壁等障碍物时需要停止镜头移动。直接控制镜头是无法做碰撞检测的。
所以实际上镜头移动的逻辑是
用户操纵摇杆 → 更新player位置和朝向 →从而同步更新camera位置和朝向
1创建移动摇杆
移动摇杆的实现原理很简单这里仅做简述。
核心在于创建一个圆盘监听触摸手势并根据手势的方向来实时更新move参数控制镜头的移动和转向。
const speed 8 // 移动速度
const turnSpeed 3 // 转向速度
// move option用于调整第一人称镜头的移动和转向
const move {turn: 0, // 旋转角度forward: 0 // 前进距离
}// 创建一个handler并监听手势调整move option
const handler new Handler()
handler.onTouchMove () { // update move option }
2创建player
首先创建一个player对象它是一个1.2 * 2 * 1的透明长方体。
function createPlayer() {const box new THREE.BoxGeometry(1.2, 2, 1)const mat new THREE.MeshBasicMaterial({color: 0x000000,wireframe: true})const mesh new THREE.Mesh(box, mat)box.translate(0, 1, 0)return mesh
}const player createPlayer() // 创建player
player.position.set(4.5, 2, 12) // 设置player的初始位置
3updatePlayer updateCamera
每次渲染render时更新player的位置和朝向并同步更新镜头的位置和朝向。
const clock THREE.clock()function render() {const dt clock.delta() // 获取每帧之间的时间间隔根据时间间隔长短来更新player和camera的移动距离和转向的多少updatePlayer(dt)updateCamera(dt)renderer.render(scene, camera)window.requestAnimationFrame(render)
}// 更新player的位置和朝向function updatePlayer(dt) {const pos player.position.clone()pos.y - 1.5 // 降低高度后续用于计算碰撞检测const dir new THREE.Vector3()player.getWorldDirection(dir)dir.negate()if (move.forward 0) dir.negate()// 调整镜头前进 or 后退if (move.forward ! 0) {player.translateZ(move.forward 0 ? -dt * speed : dt * speed * 0.5)}// 调整镜头朝向if (move.turn ! 0) {player.rotateY(move.turn * 1.2 * dt)}
}// 根据player的位置和朝向同步更新camera的位置和朝向function updateCamera(dt) {camera.position.lerp(activeCamera.getWorldPosition(new THREE.Vector3()), 0.08)const pos player.position.clone()pos.y 2.5camera.lookAt(pos)
}
注意render方法中使用clock.delta()来计算每次渲染之间的时间间隔并使用这个时间间隔来更新player和camera。因为在理想的60帧率情况下两帧时间间隔为16.67ms但实际上该数值会有波动因此我们要根据实际的渲染时间间隔来更新player和camera让镜头的移动和转向幅度更自然一些。
完成上述步骤后我们就可以通过控制虚拟移动摇杆来让镜头移动和转向了。
接下来加入碰撞检测对镜头移动加点限制。
2.5 碰撞检测
碰撞检测的步骤也很简单 收集障碍物colliders 检测碰撞基于THREE.Raycaster
1收集障碍物
模型加载完成后遍历所有的child如果child是一个物体mesh则把它加入到障碍物队列colliders中。
const colliders []loader.load(path/to/gallery.glb,gltf {gltf.scene.traverse(child {// 收集障碍物if(isMesh(child)) {colliders.push(child) }}}
})
2检测碰撞
调整刚刚的updatePlayer方法在其中插入检测碰撞的逻辑。
碰撞检测逻辑基于THREE.Raycaster来实现racaster可以理解为一个射线当射线穿过了某个物体我们就认为射线和物体相交了。
我们让射线的方向和player的朝向保持一致并且在移动过程中不断判断射线前方/后面是否有相交的物体如果有相交的物体且和射线顶点距离distance 2.5则认为遇到了障碍物不能再继续前进。
function updatePlayer(dt) {const pos player.position.clone()pos.y - 1.5 // 降低高度用于计算collisionconst dir new THREE.Vector3()// 获取当前player的朝向player.getWorldDirection(dir)dir.negate()// 如果是向后退需要对朝向取反if (move.forward 0) dir.negate()// 利用Raycaster判断player是否和colliders有碰撞行为const raycaster new THREE.Raycaster(pos, dir)let blocked falseif (colliders.length 0) {const intersect raycaster.intersectObjects(colliders)if (intersect.length 0) {// 如果相交距离2.5表示前方或后面有障碍物if (intersect[0].distance 2.5) {blocked true}}}// 如果遇到障碍物则停滞移动if (!blocked) {// 调整镜头前进 or 后退if (move.forward ! 0) {player.translateZ(move.forward 0 ? -dt * speed : dt * speed * 0.5)}}// 调整镜头朝向if (move.turn ! 0) {player.rotateY(move.turn * 1.2 * dt)}
}
这样镜头的移动和碰撞检测就完成了。
当我们移动到椅子、墙壁等障碍物附近时镜头会停止移动。镜头的移动范围也被我们限制在房间里不会穿到房间外部。 三、性能调优
3.1 纹理烘培
3D展览馆的基本功能已经完成了但还没有做任何的性能调优。当我们把项目运行在手机上会发现设备发热发烫帧率很低低端机型甚至无法运行。
经过分析实时的光影渲染是罪魁祸首。
页面中有10个光源每个光源都在实时投射阴影尤其是点光源十分消耗资源引起卡顿。但实际场景中的光源和物体位置都没有发生改变这意味着我们不需要计算实时阴影只需要固定的阴影。
这点可以通过纹理烘焙来实现。并且在移动端经过纹理烘焙的光影效果实际上要优于设备计算的实时光影效果。 纹理烘焙Texture Baking
纹理烘焙是指通过将场景效果预渲染到指定纹理上生成一个模型贴图。在Blender中我们可以选中任意对象进行烘焙。 以3D展览馆的地板为例我们可以通过纹理烘焙将光影效果直接渲染到贴图上。
左图是原本的棋盘格纹理右图是结合了光影效果的烘焙贴图。烘焙完成后地板上的光影效果就被固定下来了我们也不需要再做实时的光影渲染。 用同样的方式将地板、墙壁、天花板等物体一一进行烘焙处理导出一个新的模型。由于光影效果已经被渲染到贴图上我们可以将大部分光源去掉只保留2-3个必要的点、平行光源和全局光。再次运行后发现卡顿、发烫的问题已经不再明显。并且效果其实比实时渲染更精细一些。 这里没有对烘焙做过多介绍要生成精致的烘焙结果还需要依赖对UV Map、烘焙参数的了解虽然这些偏向于设计同学的工作一般由他们来输出烘焙纹理。但是作为开发者了解了这些后才能和UI更好地沟通和配合。
3.2 优化模型大小
模型大小约为23M首次加载模型需要9s左右。尤其是在做完纹理烘焙后由于贴图变得复杂模型更大了
以下是几个优化模型大小的建议 优先使用.glb而非.gltf格式。.glb是二进制格式它比.gltf的JSON格式小25% - 30%左右。 将纹理Texture和模型分离并行加载。23M的模型中其实只有2.3M为模型大小其余都为纹理贴图。将模型和纹理分开后可以极大减少模型的加载速度。 使用Draco、gltfpack等工具或一些online compressor来压缩模型Blender在导出gltf模型时就带有基于Draco的压缩选项。本项目通过该步骤压缩了50%的模型大小3M → 1.2M。 压缩纹理Texture。本项目用到了5张的Texture压缩后18M→ 2M。
经过优化初始模型大小由23M缩小为1.2M首次加载时间由9s缩短到3s以内。
左图为优化前右图为优化后 四、总结
现在我们基本完成了整个3D展览馆的开发。虽然有一些细节没有在文中涉及到但开发过程大致如此。
1了解Blender、GLTF / GLB模型
2js导入GLTF / GLB模型
3还原设计稿 添加光源 调整模型材质、增加环境纹理 增加阴影
4实现虚拟移动摇杆控制镜头移动
5增加碰撞检测
6性能调优 纹理烘培通过纹理烘焙降低实时光影的性能损耗。 优化包体大小
- 优先使用.glb而非.gltf格式
- 纹理和模型分离
- 压缩模型
- 压缩纹理
五、其他
一些建议 设计师在Blender中命名物体、材质时要规范化避免出现奇怪或没有标识意义的命名因为在开发过程中会使用到容易混淆。 设计师在在Blender中复用材质要谨慎避免开发在调整某个材质时影响到其它使用到相同材质的物体潜在bug。 模型加载缓慢时可以增加loading进度条缓解等待焦虑。Three.js loader支持加载进度查询。 Three.js在不同版本之间接口频繁变更在使用时注意版本差异google问题时也要注意接口兼容性。 Three.js实现物体发光效果较繁琐且消耗性能设计时可尽量避免使用。 Three.js的镜头移动不够丝滑注重镜头切换流畅性的项目可以尝试用Babylon.js。 部分浏览器不支持videoTexture在模型中播放视频谨慎设计该类型功能或做好兼容处理。 参考 ThreejsBlender打造3D全景VR画展「1」 – 码语派教室 three js blender animation - Google Search Loading Animated Characters in React Three Fiber 浅谈three.js中的needsUpdate - pissang - 博客园 Directional Light Shadow - Three.js Tutorials Shadows not working 自适应Shadow Bias算法 GLB Modify Material and add emission How can I improve the performance of a three.js script? HTML5 Audio events not triggering on Chrome http://jeromeetienne.github.io/threex.videotexture/examples/videotexture.html 部分代码参考自GitHub - mayupi/3dvr-gallery: 3dvr gallery with threejs and blender