近两年,web3D
的势头逐渐兴起。
例如得物的VR穿戴
,贝壳的VR游览
,高德地图的3D白模建筑以及VR导航
,懂车帝的汽车3D展示
等等,这些功能都需要具备一定的3D
开发能力。
web3D最直接的需求就是前几年兴起的数字孪生
概念,也有很多大厂单独成立了数字孪生部门去抢赛道。
可能有的同学还不知道数字孪生的概念,我将用最通俗易懂的语言去解释:
数字孪生:就好比你有一个双胞胎兄弟,你们长得一模一样,但一个是活在现实里的真人,另一个是活在电脑里的
虚拟
人。这个虚拟的兄弟,就是你的“数字孪生”。在现实世界中,数字孪生通常指的是通过各种数据和先进的技术手段,创建一个真实物体或系统的虚拟副本
。这个副本不仅外观和原型一样,而且还能模拟和反映原型在现实世界中的行为和状态。比如,一座大楼、一辆汽车,甚至是整个城市,都可以有自己的数字孪生。
OK,言归正传,写这篇文章的起因,也是因为前段时间,公司有这方面的需求,需要给公司的园区做一个数字孪生
,放在公司的展厅进行展示。
做数字孪生肯定就要涉及3D,在对比了众多3D开发引擎(unreal
,unity
,Babylonjs
,threejs
)之后,发现threejs
的开发成本是最低的。
所以我就被委以重任
,从零开始学习threejs开发。一开始是先在官网上学基础,例如相机视角
、矩阵
、法线
、射线交叉
、3D坐标系
、光线
、Shader材质
,模型加载
等等。
说实话,对于我这一直写2D的选手来说,确实一脸懵逼,这还没涉及到写更有难度的粒子效果GLSL
呢。。。
所以就想着去网上能不能找到相关实战代码,但是网上关于threejs实战开发的案例实在是少之又少,而且大部分都要收费,废了九牛二虎之力才找到了一个实战开发threejs-park
项目(由于项目过去很久了,开源的找不到了,找到的同学可以帮忙在评论区@一下),感觉还不错,因此就仿照着他的项目,自己一边熟悉一边梳理,最终改成了threejs+vue3+vite
的一个项目。
可以看出项目的核心是封装了一些Threejs常用到的基类,这些都封装好之后,再理解一下3D里的一些概念类的东西,剩下的就是纯JS逻辑了。
这个是threejs里最重要的一个元素,俗称视角,也就是说我们在3D场景里,肯定是需要一个视角去观看场景的,所以它是最基础且必不可少的。
export default class Viewer {
/**
*
* @param {*} id 场景容器id
*/
constructor(id) {
Cache.enabled = true // 开启缓存
this.id = id
this.renderer = undefined
this.scene = undefined
this.camera = undefined
this.controls = undefined
this.animateEventList = []
this.#initViewer()
}
#initViewer() {
this.#initRenderer()
this.#initCamera()
this.#initScene()
this.#initControl()
this.#initSkybox()
this.#initLight()
const animate = () => {
requestAnimationFrame(animate)
this.#updateDom()
this.#renderDom()
// 全局的公共动画函数,添加函数可同步执行
this.animateEventList.forEach(
event => {
event.fun && event.content && event.fun(event.content)
})
}
animate()
}
/**
* 创建初始化场景界面
*/
#initRenderer() {
// 获取画布dom
this.viewerDom = document.getElementById(this.id)
// 初始化渲染器
this.renderer = new WebGLRenderer({
// logarithmicDepthBuffer: true, // true/false 表示是否使用对数深度缓冲,true性能不好
antialias: true, // true/false表示是否开启反锯齿
alpha: true, // true/false 表示是否可以设置背景色透明
precision: "highp", // highp/mediump/lowp 表示着色精度选择
premultipliedAlpha: true, // true/false 表示是否可以设置像素深度(用来度量图像的分辨率)
})
this.renderer.clearDepth(); // 设置深度缓冲区
this.renderer.shadowMap.enabled = true // 场景中的阴影自动更新
this.viewerDom.appendChild(this.renderer.domElement) // 将渲染器添加到画布中
// 二维标签
this.labelRenderer = new CSS2DRenderer() // 标签渲染器
this.labelRenderer.domElement.style.zIndex = 2
this.labelRenderer.domElement.style.position = 'absolute'
this.labelRenderer.domElement.style.top = '0px'
this.labelRenderer.domElement.style.left = '0px'
this.labelRenderer.domElement.style.pointerEvents = 'none'// 避免HTML标签遮挡三维场景的鼠标事件
this.viewerDom.appendChild(this.labelRenderer.domElement)
// 三维标签
this.css3DRenderer = new CSS3DRenderer() // 标签渲染器
this.css3DRenderer.domElement.style.zIndex = 0
this.css3DRenderer.domElement.style.position = 'absolute'
this.css3DRenderer.domElement.style.top = '0px'
this.css3DRenderer.domElement.style.left = '0px'
this.css3DRenderer.domElement.style.pointerEvents = 'none'// 避免HTML标签遮挡三维场景的鼠标事件
this.viewerDom.appendChild(this.css3DRenderer.domElement)
}
/**
* 渲染相机
*/
#initCamera() {
this.camera = new PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 500000) // 透视相机
this.camera.position.set(50, 0, 50) // 相机位置
this.camera.lookAt(0, 0, 0) // 设置相机方向
}
/**
* 渲染场景
*/
#initScene() {
this.scene = new Scene()
this.css3dScene = new Scene()
this.scene.background = new Color('rgb(5,24,38)')
}
}
这个类的一些核心代码,包括初始化场景
、相机
、控制器
、光线
等,代码里的注释很详细,我就不一一解释了。
一个3D场景,肯定是需要光线
去照亮场景以及场景中的物体的,因此光线因素也是必不可少的。
而threejs中又把光线分成了环境光
、平行光
、点光源
、锥形光源
、矩形光源
等诸多概念,这些我们都在项目中有实际使用到。
import SunLensflare from './SunLensflare.js'
import DirectionalLight from './DirectionalLight.js'
import AmbientLight from './AmbientLight.js'
import PointLight from './PointLight.js'
import SpotLight from './SpotLight.js'
import RectAreaLight from './RectAreaLight.js'
export default class Lights {
constructor(viewer) {
this.viewer = viewer
this.lightList = []
}
/**
* 添加平行光源
* @param option
*/
addDirectionalLight(position = [200, 200, 200], option = { color: 'rgb(255,255,255)' }) {
const directionalLight = new DirectionalLight(this.viewer, position, option)
this.lightList.push(directionalLight)
return directionalLight
}
/**
* 添加环境光源
*/
addAmbientLight() {
const ambientLight = new AmbientLight(this.viewer)
this.lightList.push(ambientLight)
return ambientLight
}
/**
* 添加点状光源
* @param option
*/
addPointLight(position = [0, 40, 0], option = { color: 'rgb(255,255,255)' }) {
const pointLight = new PointLight(this.viewer, position, option)
this.lightList.push(pointLight)
return pointLight
}
/**
* 添加锥形光源
* @param option
*/
addSpotLight(position = [0, 40, 0], option = { color: 'rgb(255,255,255)' }) {
const pointLight = new SpotLight(this.viewer, position, option)
this.lightList.push(pointLight)
return pointLight
}
/**
* 添加矩形光源
* @param option
*/
addRectAreaLight(position = [0, 40, 0], option = { color: 'rgb(255,255,255)' }) {
const rectAreaLight = new RectAreaLight(this.viewer, position, option)
this.lightList.push(rectAreaLight)
return rectAreaLight
}
/**
* 添加炫光
* @param x
* @param y
* @param z
*/
addSunLensflare(x = 200, y = 200, z = 200) {
this.sunLensflare = new SunLensflare(this.viewer)
this.sunLensflare.addToScene(x, y, z)
}
/**
* 移除灯光
* @param light 灯光
*/
removeLight(light) {
this.viewer.scene.remove(light)
}
}
3D场景中,会由大大小小的各种3D模型组合而成,例如房子、道路、车、树、路灯等,这些都是加载3D模型渲染展示。
一些常见的3D模型格式
顶点
、面
、纹理坐标
和法线
。OBJ文件通常用于交换数据,因为它们可以被多种3D软件读取和编辑。动画
、骨骼
、纹理
等。高效
、跨平台
的文件格式。它旨在为Web
和移动设备
提供高性能的3D内容。Max软件
,但也可以被其他3D软件导入和使用。下面封装了一个threejs支持的比较好的加载模型GLTF及GLB的类:
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'
import DsModel from './DsModel'
/**
* 模型加载类(只能加载GLTF及GLB格式)
*/
export default class ModelLoader {
constructor(viewer) {
this.viewer = viewer
this.scene = viewer.scene
this.loaderGLTF = new GLTFLoader() // 加载gltf模型
this.loaderFBX = new FBXLoader() // 加载fbx模型
this.dracoLoader = new DRACOLoader() // 加载draco模型(加载基于Google Draco压缩格式的3D模型的类)
this.dracoLoader.setDecoderPath('/js/draco/') // 设置draco模型解码器路径
this.loaderGLTF.setDRACOLoader(this.dracoLoader) // 设置draco模型加载器
}
/**
* 添加模型数据
* @param url 模型的路径
* @param callback 返回模型对象,常用一些功能挂接在模型对象上
* @param progress 返回加载进度,还有问题,需要修改
*/
loadModelToScene(url, callback, progress) {
this.loadModel(url, model => {
this.scene.add(model.object) // 加载模型
callback?.(model)
}, num => {
progress?.(num) // 加载进度
})
}
/**
* 加载模型
* @param url 模型路径
* @param callback 回调模型
* @param progress 返回加载进度
*/
loadModel(url, callback, progress) {
let loader = this.loaderGLTF
if (url.indexOf('.fbx') !== -1) {
loader = this.loaderFBX
}
loader.load(url, model => {
callback?.(new DsModel(model, this.viewer))
}, xhr => {
progress?.((xhr.loaded / xhr.total).toFixed(2))
}, (error) => {
console.error('模型渲染报错:', error)
})
}
}
再就是我们需要操作场景中的某些元素,例如:楼体分层
,这时就需要鼠标事件去监听,点击的时候我需要计算出当前点击的具体楼层,需要用到射线求交
等知识。
import * as THREE from 'three'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
export default class ThreeMouseEvent {
constructor(viewer, isSelect, callback, type = 'click') {
this.viewer = viewer
this.isSelect = isSelect
this.callback = callback
this.type = type
this.composer = new EffectComposer(this.viewer.renderer)
return this
}
startSelect() {
this.stopSelect()
this.bingEvent = this.#event.bind(this, this)
this.viewer.renderer.domElement.addEventListener(this.type, this.bingEvent)
}
stopSelect() {
this.viewer.renderer.domElement.removeEventListener(this.type, this.bingEvent)
}
#event(that, event) {
const raycaster = new THREE.Raycaster() // 创建射线
const mouse = new THREE.Vector2() // 创建鼠标坐标
mouse.x = (event.offsetX / that.viewer.renderer.domElement.clientWidth) * 2 - 1
mouse.y = -(event.offsetY / that.viewer.renderer.domElement.clientHeight) * 2 + 1
raycaster.setFromCamera(mouse, that.viewer.camera) // 设置射线的起点和终点
// TODO: 第一个参数是否需要外部传入,减小监听范围
const intersects = raycaster.intersectObject(that.viewer.scene, true) // 检测射线与模型是否相交
if (intersects.length > 0 && intersects[0]) {
that.callback(intersects[0].object, intersects[0].point)
}
}
}