背景

公司要做3D大屏和3D组态,首先要做几个3D大屏对外演示

基本使用

以下记录我的工作中需求的基本实现,方便以后使用。官方的代码中有示例,大部分效果都能在示例中找到。

安装

工程项目引入可以使用npm,html文件使用,直接下载官方提供的代码即可。这里使用0.148.0版本,threejs官方更新频繁,注意版本,160的版本按148的语法,材质反光就不一样,具体未深入研究。

1
2
3
4
5
6
7
8
<script type="importmap">
{
"imports": {
"three": "./node_modules/three/build/three.module.js",
"three/addons/": "./node_modules/three/examples/jsm/"
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { DragControls } from 'three/addons/controls/DragControls.js';
// 引入dat.gui.js的一个类GUI
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
// gltf加载器
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
// fbx加载器
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
// obj加载器
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
// mtl 材质加载器
import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
// 渲染
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js"
import { FXAAShader } from "three/addons/shaders/FXAAShader.js"
// css3d
import { CSS3DRenderer, CSS3DObject, CSS3DSprite } from 'three/addons/renderers/CSS3DRenderer.js'
</script>
场景

1.新建一个场景,以后的光源、相机、模型等都需要添加到场景中。
2.给场景添加材质,这里用一个天空的360全景分成的前后上下左右6个面做天空盒的6个方位的面。也可以用球体和360全景图做,后面会写个文档记录一下全景图
3.将相机放在天空盒内,就有了置身场景内的效果。更多功能看官方文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const scene = new THREE.Scene()
// 天空盒
var urls = [
'./models/new0713/yun4/px.png',
'./models/new0713/yun4/nx.png',
'./models/new0713/yun4/py.png',
'./models/new0713/yun4/ny.png',
'./models/new0713/yun4/pz.png',
'./models/new0713/yun4/nz.png'
];

var cubeLoader = new THREE.CubeTextureLoader();
scene.background = cubeLoader.load(urls);
// 边缘雾化,使效果更逼真
scene.fog = new THREE.Fog(0xD3DBE7, 1500, 2000)

// 平面几何体PlaneGeometry,做地面
let texture1 = textureLoader.load('./models/new0713/dimian.jpg');
var textureNormal1 = textureLoader.load('./models/new0713/dimian.jpg')

texture1.wrapS = texture1.wrapT = THREE.RepeatWrapping;
texture1.repeat.set(25, 25);
texture1.anisotropy = 16;

const planeGeometry = new THREE.PlaneGeometry(5000, 5000, 320, 320);
const planeMaterial = new THREE.MeshStandardMaterial({
color: 0x707070,
map: texture1,// 普通纹理贴图
roughness: 0.3,
lightMap: textureNormal1,
// normalMap: textureNormal, //法线贴图
bumpScale: 3
})
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.name = '地面'
plane.rotation.x = - 0.5 * Math.PI; // 跟坐标系x,z平面平行
plane.position.set(0, 0, 0)
plane.receiveShadow = true;
scene.add(plane);
光源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 点光源
const pointLight = new THREE.PointLight(0xffffff, 2.0)
pointLight.position.set(-1000, 1000, -1000)
pointLight.castShadow = true
pointLight.shadow.mapSize.width = 2048; // default
pointLight.shadow.mapSize.height = 2048; // default
pointLight.shadow.camera.near = 0.5; // default
pointLight.shadow.camera.far = 10000 // default
scene.add(pointLight)

// 可以帮助观察对象在场景中的位置
const pointLightHelper = new THREE.PointLightHelper(pointLight, 5.0, 'yellow')
scene.add(pointLightHelper)

// 环境光
let ambient = new THREE.AmbientLight(0x404040);
scene.add(ambient);

// 平行光 不能产生阴影
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
scene.add(directionalLight);

// 半球光
const hemiLight = new THREE.HemisphereLight(0xddeeff, 0x0f0e0d, 0.02);
hemiLight.intensity = 1;
scene.add(hemiLight);
相机
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const width = window.innerWidth
const height = window.innerHeight

const camera = new THREE.PerspectiveCamera(75, width / height, 1, 3000)
camera.setViewOffset(width, height, 0, 0, width, height);
camera.position.set(-800, 100, 80)
camera.lookAt(0, 0, 0)

// 使用了OrbitControls后,相机的属性被此控制器接管,包括位置和观察目标方位,如果设置camera.lookAt不生效,优先考虑这个方面
const controls = new OrbitControls(camera, renderer.domElement)
controls.enablePan = false
controls.addEventListener('change', function () {
renderer.render(scene, camera)
})
模型
glb/gltf自带材质
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// glb/gltf自带材质
const loader = new GLTFLoader()

var model = null
loader.load(
'./models/house.glb',
function (gltf) {
model = gltf.scene;
// model.traverse(child => {
// if (child.type === 'Mesh' && child.children.length === 0) {
//添加标签文字
const div = document.createElement('div');
div.setAttribute('id', 'tag')
div.innerHTML = 'glb模型'
div.style.color = '#fff'
div.style.background = 'red'
div.style.width = '120px'
div.style.height = '50px'
const tag = new CSS3DObject(div)
tag.rotation.y = - 0.5 * Math.PI
tag.position.set(-8, 7, 0)
tag.scale.set(0.05, 0.05, 0.05)
model.add(tag); // 添加到指定的场景里
// 模型对象能接收阴影
model.castShadow = true
model.children.forEach(item => {
item.name = 'glb模型:楼房'
item.castShadow = true
})
model.name = '建筑'
model.position.set(-100, 20, -200);
model.scale.set(20, 20, 20);
scene.add(model);
},
function (xhr) {
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
},
function (error) {
console.log('An error happened');
}
);
obj,需要单独引入材质
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// obj,需要单独引入材质
const objLoader = new OBJLoader()

// 引入材质
mtlLoader.load('./models/new0711/medieval-house.3dcool.net.mtl', (mtl) => {
mtl.preload();
for (const material of Object.values(mtl.materials)) {
material.side = THREE.DoubleSide;
}
// 使用材质
objLoader.setMaterials(mtl);

objLoader.load(
'./models/new0711/medieval-house.3dcool.net.obj',
function (object) {
const div1 = document.createElement('div');
div1.setAttribute('id', 'tag1')
div1.innerHTML = 'obj模型+材质'
div1.style.color = '#fff'
div1.style.background = 'red'
div1.style.width = '120px'
div1.style.height = '50px'
const tag1 = new CSS3DObject(div1)
tag1.position.set(200, 1300, -200)
tag1.scale.set(10, 10, 10)
object.add(tag1)

object.scale.set(0.1, 0.1, 0.1)
object.rotation.y = - 0.5 * Math.PI; // 跟坐标系x,z平面平行
object.children.forEach(item => {
item.castShadow = true
item.name = 'obj模型:别墅'
})
scene.add(object);
},
// called when loading is in progresses
function (xhr) {
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
},
// called when loading has errors
function (error) {
console.log('An error happened');
}
);

});
glb/gltf自带材质
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// fbx
const fbxLoader = new FBXLoader()

fbxLoader.load(
'./models/robot092504.fbx',
function (object) {
console.log('object', object)
console.log('animation', object.animations)
// 获取模型的尺寸
// 获取模型的包围盒
const box = new THREE.Box3().setFromObject(object);

// 计算模型的尺寸
const size = box.getSize(new THREE.Vector3());
console.log('size', size)
// x: 125.1762405627781
// y: 101.02761733693433
// z: 92.89316311313561

//获取动画
mixer = new THREE.AnimationMixer( object );
var action = mixer.clipAction( object.animations[0] );
// action.timeScale = 1; //默认1,可以调节播放速度
// action.loop = THREE.LoopOnce; //不循环播放
// action.clampWhenFinished=true;//暂停在最后一帧播放的状态
action.play();//播放

// 模型居中
const box1 = new THREE.Box3().setFromObject(object);
const center = new THREE.Vector3();
box1.getCenter(center);
object.position.sub(center);

object.traverse( function ( child ) {
if ( child.isMesh ) {
if (child.material) {
child.frustumCulled = false
}
// 材质
child.castShadow = true;
child.receiveShadow = true;
// const texture = new THREE.TextureLoader().load('./images/zhansun.jpg');
// child.material.map = texture;
// 对每个Mesh应用材质
console.error('child.material', child.material)
if (child.material.isMaterial) {
console.log('name', child.name)
let meshMaterial = new THREE.MeshPhysicalMaterial({
color: 0xffffff, metalness: 0.8, roughness: 0.5
});
meshMaterial.needsUpdate = true
child.material = meshMaterial
}
}
} );
scene.add(object);
},
function (xhr) {
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
setTimeout(() => {
render()
}, 10);
},
function (error) {
console.log('An error happened', error);
}
);

渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const renderer = new THREE.WebGLRenderer()
renderer.setSize(width, height)
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement)

const composer = new EffectComposer(renderer);
// OutlinePass第一个参数v2的尺寸和canvas画布保持一致
const v2 = new THREE.Vector2(window.innerWidth, window.innerWidth);
const outlinePass = new OutlinePass(v2, scene, camera);
outlinePass.renderToScreen = false;
outlinePass.edgeStrength = 1 // 粗
outlinePass.edgeGlow = 2 //发光
outlinePass.edgeThickness = 2 // 光晕粗
outlinePass.pulsePeriod = 1 // 闪烁
outlinePass.usePatternTexture = false // 是否使用贴图
outlinePass.visibleEdgeColor.set('yellow'); // 设置显示的颜色
outlinePass.hiddenEdgeColor.set('white'); // 设置隐藏的颜色
outlinePass.clear = true

const renderPass = new RenderPass(scene, camera)
composer.addPass(renderPass)

function render() {
renderer.render(scene, camera)
r.render(scene, camera)
controls.update()
requestAnimationFrame(render)
if (composer) composer.render()
}

const controls = new OrbitControls(camera, renderer.domElement)
// controls.maxPolarAngle = 1.5;
// 上下翻转的最小角度
// controls.minPolarAngle = 0.3;
// controls.minDistance = 0;
// controls.maxDistance = 2000;
controls.addEventListener('change', function () {
renderer.render(scene, camera)
composer.render()
r.render(scene, camera)
})

render()
事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
document.onclick = onDocumentMouseDown
var mouse = {}
function onDocumentMouseDown(e) {
e.preventDefault();
screenToWorld(e.clientX, e.clientY)
// 将鼠标点击位置的屏幕坐标转成threejs中的标准坐标,具体解释见代码释义
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
// 新建一个三维单位向量 假设z方向就是0.5
// 根据照相机,把这个向量转换到视点坐标系
var vector = new THREE.Vector3(mouse.x, mouse.y, 0.5).unproject(camera);
const pointer = new THREE.Vector2();
pointer.x = mouse.x
pointer.y = mouse.y
// 在视点坐标系中形成射线,射线的起点向量是照相机, 射线的方向向量是照相机到点击的点,这个向量应该归一标准化。
var raycaster = new THREE.Raycaster(camera.position, vector.sub(camera.position).normalize());

// 射线和模型求交,选中一系列直线
var intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
// 选中第一个射线相交的物体
// SELECTED = intersects[0].object;
var intersected = intersects[0].object;
var worldPosition = new THREE.Vector3();
outlinePass.selectedObjects = [intersected];
composer.addPass(outlinePass)
// 自定义的着色器通道 作为参数
var effectFXAA = new ShaderPass(FXAAShader)
effectFXAA.uniforms.resolution.value.set(1 / window.innerWidth, 1 / window.innerHeight)
effectFXAA.renderToScreen = true
composer.addPass(effectFXAA)

if (document.querySelector('.tip')) {
document.querySelector('.tip').remove()
}
const doc = document.querySelector('body')
const dom = document.createElement('div')
dom.setAttribute('class', 'tip')
let aaa = intersects[0].object.aaa || ''
dom.innerHTML = `<span>${intersects[0].object.name}</span>`
dom.style.background = 'rgba(0,0,0,0.8)'
dom.style.padding = '10px 10px'
dom.style.border = '1px solid #fff'
dom.style.color = '#fff'
dom.style.position = 'absolute'
dom.style.top = e.clientY + 'px'
dom.style.left = e.clientX + 'px'
doc.appendChild(dom)
}
}