很多时候,我们希望给 3D 场景中的物体加一些标注信息:
比如点击某个物体的时候,弹出一个介绍框:

这种标注怎么做呢?
Three.js 提供了 CSS2DRenderer 来做这件事情。
它是用 html 渲染的,覆盖在 three.js 的那层 canvas 之上。
我们试一下:
npx create-vite css2d-annotation

进入项目,安装依赖:
npm install
npm install --save three
npm install --save-dev @types/three
改下 src/main.js
import './style.css';
import * as THREE from 'three';
import {
OrbitControls
} from 'three/addons/controls/OrbitControls.js';
import mesh from './mesh.js';
const scene = new THREE.Scene();
scene.add(mesh);
const directionLight = new THREE.DirectionalLight(0xffffff, 2);
directionLight.position.set(500, 400, 300);
scene.add(directionLight);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const width = window.innerWidth;
const height = window.innerHeight;
const helper = new THREE.AxesHelper(500);
scene.add(helper);
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 10000);
camera.position.set(500, 600, 800);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height)
function render() {
renderer.render(scene, camera);
requestAnimationFrame(render);
}
render();
document.body.append(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
创建 Scene、Light、Camera、Renderer。
改下 style.css
body {
margin: 0;
}
然后创建 mesh.js
import * as THREE from 'three';
const planeGeometry = new THREE.PlaneGeometry(1000, 1000);
const planeMaterial = new THREE.MeshLambertMaterial({
color: new THREE.Color('skyblue')
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotateX(- Math.PI / 2);
plane.position.y = -50;
const boxGeometry = new THREE.BoxGeometry(100, 100, 100);
const boxMaterial = new THREE.MeshLambertMaterial({
color: new THREE.Color('orange')
});
const box = new THREE.Mesh(boxGeometry, boxMaterial);
const box2 = box.clone();
box2.position.x = 200;
const mesh = new THREE.Group();
mesh.add(plane);
mesh.add(box);
mesh.add(box2);
export default mesh;
就是一个平面,两个立方体。
跑一下:
npm run dev

看下效果:

然后我们想在点击的时候加一个标注信息。
这时候就可以用 CSS2DRenderer


import { CSS2DObject } from 'three/examples/jsm/Addons.js';
const ele = document.createElement('div');
ele.innerHTML = '<p style="background:#fff;padding: 10px;">这是 box1</p>';
const obj = new CSS2DObject(ele);
obj.position.y = 100;
box.add(obj);
const ele2 = document.createElement('div');
ele2.innerHTML = '<p style="background:#fff;padding: 10px;">这是 box2</p>';
const obj2 = new CSS2DObject(ele2);
obj2.position.y = 100;
box2.add(obj2);
分别在 box 和 box2 下面添加一个 CSS2DObject,它的参数是一个 dom 元素。
然后还要在 main.js 里加一个新的渲染器:

import { CSS2DRenderer } from 'three/examples/jsm/Addons.js';
const css2Renderer = new CSS2DRenderer();
css2Renderer.setSize(width, height);
const div = document.createElement('div');
div.style.position = 'relative';
div.appendChild(css2Renderer.domElement);
css2Renderer.domElement.style.position = 'absolute';
css2Renderer.domElement.style.left = '0px';
css2Renderer.domElement.style.top = '0px';
css2Renderer.domElement.style.pointerEvents = 'none';
div.appendChild(renderer.domElement);
document.body.appendChild(div);
function render() {
css2Renderer.render(scene, camera);
renderer.render(scene, camera);
requestAnimationFrame(render);
}
render();
// document.body.append(renderer.domElement);
创建 CSS2DRenderer,它也会返回一个 domElement
我们创建一个 div,把两个 domElement 放进去,并且让 css2dRenderer.domElement 绝对定位并且不响应鼠标事件。
在 render 循环里调用 css2dRenderer.render 来一帧帧渲染。
看下效果:

这样,我们就给 3D 场景中的物体加上了标注信息。
那它的原理是什么呢?
我们打开 devtools 看一下:

可以看到,最外层 div 下一个是 WebGLRenderer 渲染的 canvas,另一个就是 CSS2DRenderer 渲染的 div 了。
这个 div 下有两个 div 放着那俩标签。
当我们移动物体位置或者相机位置的时候,它们的位置也会跟着变:

这就是它的原理。
那它是怎么从 3D 世界的坐标转换为屏幕坐标的呢?
还记得我们之前讲的射线和点击么?
它是从屏幕坐标转化为 3D 场景中的坐标:


那反过来从 3D 场景中的坐标算屏幕坐标自然也可以做到。
此外,之前一直没讲窗口 resize 时的处理:

这里顺便讲一下。

window.onresize = function () {
const width = window.innerWidth;
const height = window.innerHeight;
renderer.setSize(width,height);
css2Renderer.setSize(width,height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
只要在窗口 resize 的时候重新计算下宽高比,调整下 renderer 的 size,更新下相机的 aspect 参数就好了,调完相机参数还要 updateProjectionMatrix 更新相机投影矩阵才能生效。

这样就好了。
这个标注信息可以点击的时候再显示:

obj2.name = 'tag';
obj2.visible = false;
加个 name,然后设置 visible 为 false。
点击的时候再显示:

renderer.domElement.addEventListener('click', (e) => {
const y = -((e.offsetY / height) * 2 - 1);
const x = (e.offsetX / width) * 2 - 1;
const rayCaster = new THREE.Raycaster();
rayCaster.setFromCamera(new THREE.Vector2(x, y), camera);
const intersections = rayCaster.intersectObjects(mesh.children);
if(intersections.length) {
const obj = intersections[0].object;
const tag = obj.getObjectByName('tag');
if(tag) {
tag.visible = !tag.visible;
}
}
});
点击的时候,拿到射线射中的物体,根据 name 查找标签,设置 visible 就好了。

案例代码上传了小册仓库。
总结
这节我们学了用 CSS2DRenderer 实现信息标注。
它是通过在 canvas 元素上加一层 div,根据 3D 物体的位置来计算出屏幕坐标的位置,调整标签位置,来实现在 3D 物体上加标注的功能。
咋要标注的物体上加一个 CSS2DObject,传入 dom 元素,这样就会在那里展示一个标注。
可以最开始设置标注的 visible 为 false,然后点击的时候再设置为 true,这样就是点击的时候显示标注的效果。
3D 场景的标注在开发的过程中用的很多,后面会经常用到。