Three.js设置DIV跟随场景的两种方法

方法一:使用Sprite对象

首先实例化一个SpriteMaterial,设置好材质或颜色,可以是透明的

1
2
3
4
let spriteMap = new TextureLoader().load("./static/img/img.png");
let pinMaterial = new SpriteMaterial({
map: spriteMap
});

然后创建一个div或者绑定html上现有的div元素,设置好默认的位置属性,可以用css来设置默认样式
1
2
3
4
5
6
let Div = document.createElement('div')
Div.className = 'text'
Div.id = "text"
Div.style.left = 0 + 'px'
Div.style.top = 0 + 'px'
document.body.appendChild(Div)

然后声明一个坐标点并初始化,之后用来保存每次改变后的坐标点

1
2
3
private circleData: Vector3
...
this.circleData = new Vector3(-0.2, 2.3, 0)

接着创建一个创建一个Sprite对象(这里我使用的是webpack引用依赖的方式,因此不需要THREE.Sprite),并设置坐标点是初始化的坐标点

1
2
3
4
5
6
7
let pin: Sprite = new Sprite(pinMaterial);
pin.position.set(
this.circleData.x,
this.circleData.y,
this.circleData.z
);
pin.scale.set(0.3, 0.3, 1);

然后写一个方法用来更新坐标点并设置div的坐标,其原理是每次render时使用Vector3中的project方法将Sprite对象中的世界坐标点转换为屏幕坐标点,并且改变div的css位置信息
project接受一个参数是相机实例
最后通过一个固定公式转换成屏幕坐标点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private textShow(): void {
let worldVector = new Vector3(
this.circleData.x,
this.circleData.y,
this.circleData.z
);
let standardVector = worldVector.project(this.camera);
let a = this.$window.clientWidth / 2;
let b = this.$window.clientHeight / 2;
let x = Math.round(standardVector.x * a + a);
let y = Math.round(-standardVector.y * b + b);
let Div: HTMLElement = document.getElementById('text')
Div.style.left = x + 30 + "px";
Div.style.top = y + "px";
}

最后在render方法中调用textShow方法即可
1
2
3
4
5
6
private render(): void {
window.requestAnimationFrame(() => this.render());
this.textShow();
this.renderer.render(this.scene, this.camera); // 必须放在这个位置

}

方法二:使用CSS2DObject和CSS2DRenderer渲染器

这个方法的原理是将dom元素通过CSS2DObject方法转换为three对象,然后利用CSS2DRenderer渲染器将其渲染在页面上
首先引入CSS2DObject和CSS2DRenderer

1
import { CSS2DObject, CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer";

接着声明一个CSS2DRenderer
1
private labelRenderer: CSS2DRenderer

新建一个div设置一些类名或者样式,然后将div传入CSS2DObject对象的实例中
将实例add进场景,再声明一个CSS2DRenderer,通过setSize方法设置渲染器的大小
通过labelRenderer.domElement来设置被转换成three对象之后的dom元素的样式
最后将labelRenderer.domElement对象appendChild到你想要的元素中
1
2
3
4
5
6
7
8
9
10
11
12
13
const Div: HTMLElement = document.createElement('div')
Div.id = 'trace'
Div.className = 'text'
Div.textContent = "CSS2D方法"
var moonLabel = new CSS2DObject(Div);
moonLabel.position.set(0, -3, 0);
this.scene.add(moonLabel);

this.labelRenderer = new CSS2DRenderer();
this.labelRenderer.setSize(window.innerWidth, window.innerHeight);
this.labelRenderer.domElement.style.position = 'absolute';
this.labelRenderer.domElement.style.top = 0 + 'px';
document.body.appendChild(this.labelRenderer.domElement)

最后直接再render方法中执行labelRenderer的render,传入场景和相机
1
this.labelRenderer.render(this.scene, this.camera);

这里有个问题,如果场景有OrbitControls控制器,那么将会失效,因为CSS2DRenderer会先生成有个和渲染大小一样的div(就是setSize方法设置的大小)
如果能正常显示出div那么它的z-index是高于WebGLRenderer渲染出的canvas的层级,这样OrbitControls控制器就会失效
解决方法也很简单粗暴:给这个生成出来的div给一个pointer-events: none;样式即可

相比之下,我觉得方案二还是更简单一点,但是如果需要设置多个或不确定数量的div,那么可以使用第一个,使用for循环来处理

完整代码

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
import './assets/less/index.less'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { Scene, PerspectiveCamera, WebGLRenderer, AmbientLight, DirectionalLight, AnimationMixer, AnimationAction, BoxGeometry, MeshBasicMaterial, Mesh, DoubleSide, Clock, Raycaster, Vector2, Vector3, Group, TextureLoader, Sprite, SpriteMaterial, LoopOnce } from "three";
import { GLTFLoader, GLTF } from "three/examples/jsm/loaders/GLTFLoader";
import { CSS2DObject, CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer";
export class Three {
private scene: Scene
private camera: PerspectiveCamera
private renderer: WebGLRenderer
private controls: OrbitControls
private mixer: AnimationMixer
private clock: Clock
private mainGroup: Group
private circleGroup: Group
private $window: HTMLElement
private fov: number
private originalCamera: Vector3
private circleData: Vector3
private gltf: GLTF
private anindex: number
private animation: AnimationAction
private isAnimate: boolean
private labelRenderer: CSS2DRenderer
constructor() {
this.fov = 26
this.originalCamera = new Vector3(-2.3363492363063805, 5.3542278380602655, 13.216669448945213)
this.$window = document.querySelector("#app");
this.clock = new Clock()
this.circleData = new Vector3(-0.2, 2.3, 0)
this.anindex = 0
this.isAnimate = false
this.initScene();
this.initThree();
this.initCamera();
this.initControls()
this.initLight();
this.initPulseCircle()
this.initTrace()
this.render()
window.addEventListener('resize', () => this.onWindowResize());
this.renderer.domElement.addEventListener("click", event => this.modelTrigger(event));
this.renderer.domElement.addEventListener("touchstart", event => this.modelTrigger(event));

let loader = new GLTFLoader(); //创建模型加载器对象
// let dracoloader = new DRACOLoader();//draco加载器
const textureLoader = new TextureLoader();
const skyboxGeometry = new BoxGeometry(200, 200, 200);
const skyboxMaterials = [
new MeshBasicMaterial({ map: textureLoader.load('./static/textures/rt.png'), side: DoubleSide }),
new MeshBasicMaterial({ map: textureLoader.load('./static/textures/lf.png'), side: DoubleSide }),
new MeshBasicMaterial({ map: textureLoader.load('./static/textures/up.png'), side: DoubleSide }),
new MeshBasicMaterial({ map: textureLoader.load('./static/textures/dn.png'), side: DoubleSide }),
new MeshBasicMaterial({ map: textureLoader.load('./static/textures/bk.png'), side: DoubleSide }),
new MeshBasicMaterial({ map: textureLoader.load('./static/textures/ft.png'), side: DoubleSide }),
];
const skyboxMesh = new Mesh(skyboxGeometry, skyboxMaterials);
skyboxMesh.name = 'skyboxMesh';
skyboxMesh.position.y = 97.45
this.scene.add(skyboxMesh);

loader.load('./static/models/glb/RobotExpressive.glb', object => {
console.log(object)
this.gltf = object
this.gltf.scene.position.set(0, -2.5, 0);
this.scene.add(this.gltf.scene);
this.mixer = new AnimationMixer(this.gltf.scene);
this.selectAnim(5, '点我的身体会有惊喜!', true)
});

document.getElementById('submit').addEventListener('click', () => {
this.send()
});

document.getElementById("input").addEventListener("keyup", function (event) {
event.preventDefault();
if (event.keyCode === 13) {
document.getElementById("submit").click();
}
});
}
private send(): void {
let value: string = (<HTMLInputElement>document.getElementById('input')).value;
(<HTMLInputElement>document.getElementById('input')).value = ""
let index = 0
if (/跳舞/.test(value)) {
index = 0
} else if (/死/.test(value)) {
index = 1
} else if (/闲/.test(value)) {
index = 2
} else if (/开心|哈哈|ha/.test(value)) {
index = 3
} else if (/生气/.test(value)) {
index = 4
} else if (/牛|666/.test(value)) {
index = 5
} else if (/你好|hey|hello/.test(value)) {
index = 6
} else if (/是|不/.test(value)) {
index = 7
} else {
index = Math.floor(Math.random() * 9)
}

switch (index) {
case 0:
this.selectAnim(0, '啦~~啦~~~啦~~~')
break
case 1:
this.selectAnim(1, '狗带!')
break
case 2:
this.selectAnim(2, '无所事事。。。')
break
case 3:
this.selectAnim(3, '开心')
break
case 4:
this.selectAnim(5, '好气哦!')
break
case 5:
this.selectAnim(9, '你牛逼!')
break
case 6:
this.selectAnim(12, '你好!')
break
case 7:
this.selectAnim(13, 'yes')
break
case 8:
this.selectAnim(4, '你说什么我听不懂?')
break
default:
this.selectAnim(4, '你说什么我听不懂?')
break;
}

}
private modelTrigger(event): void {
const Sx = event.clientX; //鼠标单击位置横坐标
const Sy = event.clientY; //鼠标单击位置纵坐标
//屏幕坐标转标准设备坐标
const x = (Sx / window.innerWidth) * 2 - 1; //标准设备横坐标
const y = -(Sy / window.innerHeight) * 2 + 1; //标准设备纵坐标
//创建射线投射器对象
const raycaster = new Raycaster();
//返回射线选中的对象
raycaster.setFromCamera(new Vector2(x, y), this.camera);
const intersects = raycaster.intersectObjects(this.scene.children, true);
const intersect = intersects.filter(intersect => intersect.object.name !== 'skyboxMesh')
if (intersect.length > 0) {
const result = intersect.filter(item => item.object.name === 'Torso_0')
if (result.length > 0) {
this.selectAnim(5, '不要乱摸')
return
}
switch (intersect[0].object.name) {
case 'Head_1':
this.selectAnim(3, '摸头杀')
break
case 'Head_2':
console.log('头')
this.selectAnim(1, '我的眼睛!!')
break
case 'Torso_1':
this.selectAnim(12, '给我捶捶背')
break
case 'FootL':
this.selectAnim(3, '左脚可以摸')
break
case 'FootR':
this.selectAnim(5, '别碰我的jiong')
break
default:
break
}

}
}
private selectAnim(index: number, text?: string, loop?: boolean): void {
if (this.isAnimate) {
return
}
this.isAnimate = true
// animation
this.mixer.uncacheClip(this.gltf.animations[this.anindex])
this.animation = this.mixer.clipAction(this.gltf.animations[index])
this.animation.clampWhenFinished = true; //播放完停留在最后一帧
if (!loop) {
this.animation.setLoop(LoopOnce, 1)
}
this.animation.fadeIn(1)
this.animation.play()
setTimeout(() => {
this.isAnimate = false
}, this.gltf.animations[index].duration * 1000);
this.anindex = index

document.getElementById('text').innerText = text
}
//创建场景
private initScene(): void {
this.scene = new Scene();
this.scene.position.set(0, 0, 0);
this.scene.lookAt(this.scene.position);
}
//创建相机
private initCamera(): void {
this.camera = new PerspectiveCamera(
this.fov,
this.$window.clientWidth / this.$window.clientHeight,
0.1,
5000
);
this.camera.position.x = this.originalCamera.x
this.camera.position.y = this.originalCamera.y
this.camera.position.z = this.originalCamera.z
this.camera.lookAt(0, 0, 0);
}
//创建3D渲染器
private initThree(): void {
this.renderer = new WebGLRenderer();
this.renderer.setSize(
this.$window.clientWidth,
this.$window.clientHeight
);
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setClearColor(0xb34149, 1); //设置背景颜色
this.$window.appendChild(this.renderer.domElement);
}
//创建光源
private initLight(): void {
// 环境光
this.scene.add(new AmbientLight(0xd29c96, 1));
// 平行光
let light = new DirectionalLight(0xffffff, 0.6);
light.position.set(0, 10, 5);
this.scene.add(light);
}


// 开启控制器
private initControls(): void {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true; // 惯性滑动,滑动大小默认0.25
this.controls.dampingFactor = 0.05;

// //控制
this.controls.enableZoom = true; // 缩放
this.controls.enableKeys = true; // 键盘
this.controls.enablePan = false; // 是否开启右键拖拽

// 旋转速度
this.controls.rotateSpeed = 1;

// 自动旋转
this.controls.autoRotate = false;
this.controls.autoRotateSpeed = -0.01;

//设置仰视角和俯视角,后续进行重置
this.controls.maxPolarAngle = Math.PI / 2;
this.controls.minPolarAngle = Math.PI / 4;
this.controls.zoomSpeed = 1;
//设置相机距离原点的最远距离
// controls.minDistance = 120;
//设置相机距离原点的最远距离
// controls.maxDistance = 120 + 120 * 0.5;
}
// 创建发光点
private initPulseCircle(): void {
this.mainGroup = new Group();
this.scene.add(this.mainGroup);
var spriteMap = new TextureLoader().load("./static/img/img.png");
let pinMaterial = new SpriteMaterial({
map: spriteMap
});
this.circleGroup = new Group();
let container = document.createElement('div')
container.className = 'show'
let Div = document.createElement('div')
Div.className = 'text'
Div.id = "text"
Div.style.left = 0 + 'px'
Div.style.top = 0 + 'px'
document.body.appendChild(Div)
let pin: Sprite = new Sprite(pinMaterial);
pin.position.set(
this.circleData.x,
this.circleData.y,
this.circleData.z
);
pin.scale.set(0.3, 0.3, 1);
this.circleGroup.add(pin);
}
// 发光点DOM文本
private textShow(): void {
let worldVector = new Vector3(
this.circleData.x,
this.circleData.y,
this.circleData.z
);
let standardVector = worldVector.project(this.camera);
let a = this.$window.clientWidth / 2;
let b = this.$window.clientHeight / 2;
let x = Math.round(standardVector.x * a + a);
let y = Math.round(-standardVector.y * b + b);
let Div: HTMLElement = document.getElementById('text')
Div.style.left = x + 30 + "px";
Div.style.top = y + "px";
}
private initTrace(): void {
const Div: HTMLElement = document.createElement('div')
Div.id = 'trace'
Div.className = 'text'
Div.textContent = "Moon"
var moonLabel = new CSS2DObject(Div);
moonLabel.position.set(0, -3, 0);
this.scene.add(moonLabel);

this.labelRenderer = new CSS2DRenderer();
this.labelRenderer.setSize(window.innerWidth, window.innerHeight);
this.labelRenderer.domElement.style.position = 'absolute';
this.labelRenderer.domElement.style.top = 0 + 'px';
document.body.appendChild(this.labelRenderer.domElement)
}

private render(): void {
window.requestAnimationFrame(() => this.render());
if (this.mixer) {
this.mixer.update(this.clock.getDelta());
}
this.controls.update();
this.textShow();
this.renderer.render(this.scene, this.camera); // 必须放在这个位置

this.labelRenderer.render(this.scene, this.camera);
}
private onWindowResize(): void {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
}
}