代码git地址:https://github.com/buglas/threejs-lesson
知识点
- 场景 Scene
- 透视相机 PerspectiveCamera
- 基础材质 MeshBasicMaterial
- 几何体 BufferGeometry
- 网格对象 Mesh
- 渲染对象 WebGLRenderer
- 轨道控制器 OrbitControls
项目概述
按理说,学习一门新技术的时候,没有一入门就实战的。
但是,有个三维机房的案例,确实很适合一入门就实战,因为它是很简单,很经典,也很适合我们统揽全局,看一下threejs 是如何实战的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zdCK57bk-1653464897384)(images/1-1648868935945.gif)]
等说完这个案例,我们会从基础慢慢说threejs。
1-IT机房简介
在一些安全要求比较高的企业,比如电信、网通、移动等,都有自己的独立服务器,而不是使用阿里云、腾讯云之类的第三方服务器。
这种大企业的服务器可能很多,需要装进IT 机柜里。
一般小点的企业的服务器需要二三十个机柜,而大的则需要上千个机柜。
IT 机房就是用于放置这些机柜的房间。
2-三维IT机房
随着科技的进步和信息化进程的推进,IT机房的重要性越来越高,企业需要对IT机房进行更加妥善的管理和监控,比如实时监控机房的温度和湿度。
现在市面上好点的IT 机柜都可以将其内部数据同步到服务端,这个时候,虚拟现实的三维IT机房便有了用武之地。
三维IT机房可以将机房数据可视化,让企业更好的监控和管理IT 机柜。
3-项目需求
当前这个项目先不整太复杂了,毕竟现在还是入门阶段,以后再逐步深入。
咱们先说几个IT 机房的几个常见功能:
- 在前端页面对IT 机房进行三维展示。
- 当鼠标划入IT 机柜的时候,提示当前机柜的详细信息。
- 一键显示机房中过热的机柜。
react+ts+threejs 开发三维IT机房
react+ts 是我当前所知的大部分企业开发三维项目的标配,所以我着这里就选择了react+ts。
1-1-建模思路
- 简化模型,能用贴图表现的细节,就用贴图。这样可提高渲染速度。
- 将光效融入贴图中,即模型贴图后便具备光效和体感。这样在three 中就无需打灯,即可提高开发速度,亦可提高渲染效率。
1-2-建模软件
现在市面上可以3d建模的软件有很多,3dsMax、ZRender、C4D 都可以。
我当前用的3dsMax 版本是2018,无法导出gltf 文件,所以还需要安装一个gltf 文件导出插件。
一般公司都是有专门的建模师。
1-3-模型文件
GLTF 模型文件包含了整个场景的数据,比如几何体、材质、动画、相机等。
GLTF 模型在使用起来,要比传统的obj 模型方便很多。
在导出GLTF模型后,一般会包含以下文件:
- gltf 模型文件
- bin文件
- 贴图文件
1-4-规范模型的结构和命名
在建模软件中,同一类型的模型文件可以放入一个数组里,数组可以多层嵌套。
当前的机房模型比较简单,我就没有使用数组,所有的Mesh对象都是平展开的。
为了便于访问和区分模型,需要对模型进行规范命名,如机房中的IT机柜都是按照cabinet-001、cabinet-002、cabinet-003 命名的。
假设IT机柜的名称都是唯一的,那我们便可以基于这个名称从后端获取相应机柜的详细信息。
1.5 构建项目
npx create-react-app 02-machineroom --template typescript
npm install three @types/three --save
npm run start
调整一下ts的配置文件,取消strict 模式。因为如果strict为true,用threejs 写程序时,会比较麻烦。
tsconfig.json
{"compilerOptions": {……// "strict": true,……},……
}
如果当前的vscode编辑器还无法对tsx 文件做格式化,可以安装一个Prettier - Code formatter 插件。
- react 18 对react 做了更新,index.tsx需要这样写:
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";const container = document.getElementById("root");
const root = createRoot(container);
root.render(<App />);
创建models 文件夹,将之前的模型文件放进去。同理,在public文件夹里放一份。
在App.tsx中创建canvas画布
- App.tsx
import React from 'react';
import './App.css';class App extends React.Component {// 建立canvas 画布render() {return <div className="App"><canvas id='canvas'></canvas></div>}
}export default App;
设置css样式,让App组件充满窗口。
- index.css
html{height: 100%;
}
body{height: 100%;margin: 0;
}
#root{height: 100%;
}
- App.css
.App {height: 100%;overflow: hidden;
}
建立机房对象-MachineRoom.ts
机房对象会把所有图形相关的对象都封装进去,对模型进行统一管理和渲染。
src/component/MachineRoom.ts
机房对象会把所有图形相关的对象都封装进去,对模型进行统一管理和渲染。
import {MeshBasicMaterial,MeshStandardMaterial,Mesh, PerspectiveCamera,Raycaster,Scene,Texture,TextureLoader,WebGLRenderer, Vector2
} from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'// GLTF 模型加载器
const gltfLoader=new GLTFLoader()export default class MachineRoom{// 渲染器renderer: WebGLRenderer// 场景scene: Scene// 相机camera: PerspectiveCamera// 轨道控制器controls: OrbitControls// 存放模型文件的目录modelPath: string// 初始化场景constructor(canvas: HTMLCanvasElement,modelPath: string = './models/') {this.renderer = new WebGLRenderer({ canvas })this.scene=new Scene()/*** 实例化透视相机* 45:45度* 相机宽高比例* 0.1 近裁剪距离* 1000 远裁剪距离*/this.camera = new PerspectiveCamera(45, canvas.width / canvas.height, 0.1, 1000)// 设置相机视点位置this.camera.position.set(0, 10, 15)// 相机看向0,0,0点this.camera.lookAt(0, 0, 0)/**** 实例化相机轨道控制器* 参数* camera: 相机* canvas*/this.controls = new OrbitControls(this.camera,this.renderer.domElement);this.modelPath=modelPath}// 加载GLTF模型loadGLTF(modelName: string = '') {gltfLoader.load(this.modelPath + modelName, ({ scene: { children } }) => {this.scene.add(...children);})}// 连续渲染animate() {this.renderer.render(this.scene, this.camera)requestAnimationFrame(() => {this.animate()})}
}
实例化机房对象
- App.tsx
import React from 'react';
import './App.css';
import MachineRoom from './component/MachineRoom'//机房对象
let room: MachineRoom//canvas画布
let canvas:HTMLCanvasElementclass App extends React.Component {componentDidMount() {// 组件挂载完成,实例化机房对象渲染机房if (!canvas) { return }canvas.width = window.innerWidthcanvas.height = window.innerHeightroom=new MachineRoom(canvas)// 加载模型room.loadGLTF('machineRoom.gltf')room.animate()}// 建立canvas 画布,并通过ref 获取其HTMLCanvasElement对象render() {return <div className="App"><canvasid='canvas'ref={ele => canvas = ele}></canvas></div>}
}export default App;
效果如下:
真实的贴图如下:
对于以上的结果,我们发现模型渲染出来的太黑了,下面解决:色差问题。
修改模型材质
1. 排查色差问题:
在机房对象里打印模型
loadGLTF(modelName: string = '') {gltfLoader.load(this.modelPath + modelName, ({ scene: { children } }) => {console.log(...children);:this.scene.add(...children);})
}
分析一下children里的Mesh 对象,可以发现:所有Mesh对象的material材质都是MeshStandardMaterial 类型。
再分析一下material 中的map 贴图,可以发现其map贴图为 Texture 对象,其具备以下重要信息:
- name 是贴图图片的名称。
- flipY为false,即不对图像的y轴做翻转。
- image图像源是ImageBitmap 类型。
- wrapS 纹理横向重复,即THREE.RepeatWrapping。
- wrapT 纹理纵向重复,即THREE.RepeatWrapping。
注:THREE.RepeatWrapping=1000
在此,我们要知道以下threejs 知识:
- MeshStandardMaterial 材质会感光,这不是我们所需要的,我们不需要打光,需要将其材质换成MeshBasicMaterial。
- ImageBitmap 的图像类型是渲染效果变黑的关键原因,因此需要将其换成Image() 对象。
接下来咱们就给模型换一个材质和图像源。
2. 修改模型的材质和图像源
1.为机房对象添加maps属性,用来存储纹理对象,以避免贴图的重复加载。
MachineRoom.ts
// 存储纹理对象
maps: Map<string, Texture>=new Map()
2.在加载GLTF 的时候,用changeMat()方法修改Mesh 对象的材质
MachineRoom.ts
loadGLTF(modelName: string = '') {gltfLoader.load(this.modelPath+modelName, ({ scene: { children } }) => {children.forEach((obj:Mesh) => {//断言 obj.material是 MeshStandardMaterialconst { map,color} = obj.material as MeshStandardMaterial// 修改材质this.changeMat(obj,map,color)})})
}
3.为机房对象中添加一个修改材质的方法changeMat()
MachineRoom.ts
// 修改材质
// obj: 贴图
// Texture:纹理材质
// color:颜色
changeMat(obj: Mesh, map: Texture, color: Color) {// 如果有贴图,就换一个贴图,如果没有就显示原来的颜色。if (map) {obj.material = new MeshBasicMaterial({// 添加建立纹理对象的方法map: this.crtTexture(map.name)})} else {obj.material = new MeshBasicMaterial({color})}
}
changeMat() 方法的参数:
- obj:需要修改材质的Mesh 对象
- map:GLTF 模型里的贴图对象
- color:GLTF 模型的颜色
其中的if 逻辑是:若Mesh模型有贴图,就为其换一个材质和贴图;否则,就换一个材质,并继承原GLTF 模型的颜色。
4.为机房对象添加建立纹理对象的方法crtTexture() 。
MachineRoom.ts
// 创建纹理对象
crtTexture(imgName: string) {// 获取maps的纹理对象let curTexture=this.maps.get(imgName)// 如果没有纹理对象,则创建纹理对象if (!curTexture) {// new TextureLoader().load:加载纹理对象curTexture=new TextureLoader().load(this.modelPath+imgName)// 配置纹理对象的属性// flipY:纹理对象是否反转 false:不做反转curTexture.flipY = false// 在s方向重复curTexture.wrapS = 1000//在t方向重复curTexture.wrapT = 1000this.maps.set(imgName,curTexture)}// 如果有纹理对象,则直接返回,避免纹理对象的重复建立return curTexture
}
crtTexture() 会根据贴图名称建立Texture 纹理对象。
- TextureLoader().load() 可以根据贴图路径,加载贴图,返回一个Texture 对象。
- curTexture 的flipY、wrapS、wrapT是对原始GLTF 贴图的相应属性的继承。
效果如下:
为IT机柜添加鼠标事件
首先为机房添加2个属性,3个事件。
这2个属性是:1.机柜的集合 2.当前鼠标滑过的机柜
MachineRoom.ts
//机柜集合
cabinets: Mesh[] = []// 鼠标划入的机柜
curCabinet:Mesh// 鼠标划入机柜事件,参数为机柜对象
onMouseOverCabinet = (cabinet:Mesh) => { }// 鼠标在机柜上移动的事件,参数为鼠标在canvas画布上的坐标位
onMouseMoveCabinet = (x:number,y:number) => { }// 鼠标划出机柜的事件
onMouseOutCabinet = () => { }
2.在构造函数中,为maps 添加一个机柜的高亮贴图。之后鼠标划入机柜时,会将其贴图更换为高亮贴图。
MachineRoom.ts
constructor(canvas: HTMLCanvasElement,modelPath: string = './models/') {……// 为maps 添加一个机柜的高亮贴图。之后鼠标划入机柜时,会将其贴图更换为高亮贴图。this.crtTexture("cabinet-hover.jpg")}
3.在加载GLTF 模型时,若模型名称包含’cabinet’,便将其存入cabinets。
loadGLTF(modelName: string = '') {this.gltfLoader.load(this.modelPath+modelName, ({ scene: { children } }) => {children.forEach((obj:Mesh) => {const {color, map} = obj.material as MeshStandardMaterialthis.changeMat(obj,map,color)// 在加载GLTF 模型时,若模型名称包含'cabinet',便将其存入cabinets。if (obj.name.includes('cabinet')) {this.cabinets.push(obj)}})this.scene.add(...children);})
}
4.在机房对象外面建立一个射线投射器,一个二维点,以避免在鼠标选择时机柜时重复实例化。
//射线投射器,可基于鼠标点和相机,在世界坐标系内建立一条射线,用于选中模型
const raycaster = new Raycaster()
//鼠标在裁剪空间中的点位
const pointer = new Vector2()
注:对于基于鼠标点和相机,用射线选择模型的原理,在WebGL的“进入三维世界”的选择立方体里有详细讲解,此处不再赘述。
5.为机房对象添加选择模型的方法selectCabinet(x,y),其参数为鼠标的canvas坐标位
// 选择机柜
selectCabinet(x:number, y:number) {const {cabinets,renderer,camera,maps,curCabinet}=thisconst { width, height } = renderer.domElement// 鼠标的canvas坐标转裁剪坐标pointer.set((x / width) * 2 - 1,-(y / height) * 2 + 1,)// 基于鼠标点的裁剪坐标位和相机设置射线投射器raycaster.setFromCamera(pointer, camera)// 选择机柜const intersect = raycaster.intersectObjects(cabinets)[0]// 当前选择的模型let intersectObj=intersect?intersect.object as Mesh:null// 若之前已有机柜被选择,且不等于当前所选择的机柜,取消之前选择的机柜的高亮if (curCabinet&&curCabinet!==intersectObj) {const material =curCabinet.material as MeshBasicMaterial// 设置贴图material.setValues({map: maps.get('cabinet.jpg')})}/* 若当前所选对象不为空:触发鼠标在机柜上移动的事件。若当前所选对象不等于上一次所选对象:更新curCabinet。将模型高亮。触发鼠标划入机柜事件。否则若上一次所选对象存在:置空curCabinet。触发鼠标划出机柜事件。*/if (intersectObj) {this.onMouseMoveCabinet(x,y)if (intersectObj !== curCabinet) {this.curCabinet=intersectObjconst material = intersectObj.material as MeshBasicMaterialmaterial.setValues({map: maps.get('cabinet-hover.jpg')})this.onMouseOverCabinet(intersectObj)}} else if(curCabinet) {this.curCabinet = nullthis.onMouseOutCabinet()}
}
6.在App.tsx 文件中,为APP 组件添加鼠标移动事件。
class App extends React.Component {……// 鼠标移动事件mouseMove({clientX,clientY}) {room.selectCabinet(clientX, clientY)}// 建立canvas 画布,并通过ref 获取其HTMLCanvasElement对象render() {return <div className="App" onMouseMove={this.mouseMove}>……</div>}
}
鼠标滑过机柜出现提示信息
其最简单的做法就是用HTML 建立一个信息面板,当鼠标在IT机柜上移动的时候,就让其随鼠标移动。
1.建立信息提示板
- app.tsx
class App extends React.Component {
+ state = {// 信息面板的位置planePos: {left: 0, //信息面板的左侧位置top:0 // 信息面板的顶部位置},//信息面板的可见性planeDisplay: 'none',//当前机柜信息curCabinet: {//名称name:'Loading……',//温度temperature: 0,//容量capacity: 0,//服务器数量count:0}}……render() {
+ const {planePos: { left, top },planeDisplay: display,curCabinet:{name,temperature,capacity,count}} = this.statereturn <div className="App" onMouseMove={this.mouseMove}><canvasid='canvas'ref={ele => canvas = ele}></canvas>+ <divid='plane'style={{left,top,display}}><p>机柜名称:{name}</p><p>机柜温度:{temperature}°</p><p>使用情况:{count}/{capacity}</p></div></div>}
}
- 在App.css 文件中设置面板样式
#plane{position: absolute;top: 0;left: 0;background-color: rgba(0,0,0,0.5);color: #fff;padding: 0 18px;transform: translate(12px,-100%);display: none;
}
2.根据绑定在机柜对象上的鼠标事件,设置信息面板的可见性和位置。
componentDidMount() {if (!canvas) { return }canvas.width = window.innerWidthcanvas.height = window.innerHeightroom=new MachineRoom(canvas)room.modelPath='./models/'room.loadGLTF('machineRoom.gltf')room.animate()//当鼠标划入机柜,显示信息面板
+ room.onMouseOverCabinet = () => {this.setState({planeDisplay: 'block'})}//当鼠标在机柜上移动,让信息面板随鼠标移动
+ room.onMouseMoveCabinet = (left,top) => {this.setState({planePos: {left,top}})}//当鼠标划出机柜,隐藏信息面板
+ room.onMouseOutCabinet = () => {this.setState({planeDisplay: 'none'})}
}
效果如下:
总结
通过三维机房的案例,我给大家分享一个我自己在实战中的经验:图形组件与前端页面的分离。
在这个案例里,我们可以看出,其最核心部分,就是三维机房对象MachineRoom。
MachineRoom 就是图形组件,它有两种职责:
- 提供与图形相关的操作,比如场景搭建,模型加载,模型选择,场景渲染等。
- 提供与前端交互的接口,比如鼠标在机柜上的划入、划出和移动事件。
图形组件尽量不要参与前端的业务逻辑,比如参与前端数据解析和存储,参与前端DOM元素的交互操作……
图形组件与前端页面的分离,会带来以下好处:
-
可以明确WebGL工程师与react、vue等主流前端工程师的分工。
由于图形学的水很深,当我们专心于图形学的学习时,短时间内,往往会忽略对react、vue等主流框架的研究。
图形组件与前端页面的分离,可以让我们专精与我们擅长的领域。在项目开发的时候,我们只需要与主流前端工程师做好接口对接就好。
-
降低图形组件与前端业务逻辑的耦合度,降低前端业务需求的频繁修改对图形组件的影响。
有些大厂,涉及的业务逻辑复杂了,频繁改需求、改数据结构、改接口都是很正常的,若图形组件与前端业务逻辑混为一体,那WebGL工程师的工作就会很焦灼。
WebGL工程师与react、vue等主流前端工程师有了明确分工之后,便需要考虑团队协作。
现在在大厂里,一般主流的前端工程师比较好找,而有结实的图形学基础和实战经验的WebGL工程师却很难找。
所以,在一个以三维图形为主导的项目中,一个优秀的WebGL工程师会有很高的话语权。
不过,若WebGL工程师不精通项目工程化、不精通主流前端框架,建议不要主导整个项目的开发,也不要承担下自己不擅长的东西,只要一心一意的做好自己擅长的图形组件,与团队做好交流沟通即可。这样既省心,又省力。
关于图形组件与前端页面的分离,咱们就说到这。
后面,我们还可以一键显示机房中过热的机柜,这个原理和机柜的高亮是一样的,为其换一个偏红色调的贴图即可。大家可以自己在课后练习一下,我就不再赘述了,有问题可以在群里说。
当前这个案例主要是让大家对three实战项目有一个系统的认知。
根据不同的项目需求,我们可能还会对三维项目有不同的要求。
比如,若是我们用这种渲染三维机房的方式渲染室内设计,然后把效果图拿给业主看,那这单子肯定会搞砸了。
这是因为这种三维机房的渲染效果还是太假了。
因此,我们要不要让一个三维项目渲染得更加逼真,还是要看项目需求的。
比如这个三维机房,更多的是示意性的,若是将其整得太逼真,让它实时光线追踪,那交互起来就可能会卡。
若只是想渲染一张效果图给业主看,那咱们就得全心全意多花点时间,把一帧渲好了就行。
关于三维机房的实战,咱们就说到这。
接下来,我会系统讲解threejs 知识,告诉大家如何用three 应对不同的项目需求。