three.js CSS2DRenderer 详解:将 HTML 标签挂载到 3D 场景的完整实践
CSS2DRenderer 是 three.js 官方提供的 HTML 标签渲染器,用于把 DOM 元素(文本、图标、卡片等)锚定到三维场景中的物体上,并随相机视角实时投影到正确的屏幕位置。本篇基于官方 API 文档 docs/pages/CSS2DRenderer.html.md 与源码 examples/jsm/renderers/CSS2DRenderer.js 展开,读完你可以掌握:CSS2DRenderer 与 CSS3DRenderer 的定位差异、全部构造参数/属性/方法的含义与底层实现、从 3D 坐标到屏幕像素的投影管线,以及可直接运行的“WebGL 渲染器 + CSS2D 渲染器”双渲染器集成方案(含图层控制与窗口自适应)。
一、定位:CSS3DRenderer 的简化版
官方文档对 CSS2DRenderer 的定义是:
This renderer is a simplified version of CSS3DRenderer. The only transformation that is supported is translation.(它是 CSS3DRenderer 的简化版,仅支持平移变换。)
其核心用途:把基于 HTML 的标签(label)与 3D 对象结合。用法上与 CSS3DRenderer 类似——把 DOM 元素包装进 CSS2DObject 实例并加入场景图(scene graph);而场景中其他可渲染的 3D 对象(网格、点云等)会被 CSS2DRenderer 完全忽略,它只关心 CSS2DObject。
两个关键约束(来自官方文档):
- 仅支持平移:标签始终“面朝”屏幕,不会随 3D 空间翻转、缩放。从源码注释看,除平移外还支持一个绕屏幕法线的 2D 旋转(
rotation2D属性,见 CSS2DObject 构造器),文档页面尚未更新这一点; - 仅支持 100% 浏览器与显示缩放:即页面缩放(Ctrl +/-)不等于 100% 时,标签与 3D 内容可能出现偏移。
CSS2DRenderer 与 CSS3DRenderer 的选型对比:
| 维度 | CSS2DRenderer | CSS3DRenderer |
|---|---|---|
| 支持的变换 | 仅平移(源码另支持 2D 旋转) | 完整 3D 变换(平移、旋转、缩放) |
| 实现方式 | 每帧用 transform: translate(...) rotate(...) 更新 DOM |
用 CSS 3D 矩阵模拟透视投影 |
| 适用场景 | 3D 场景上的 HTML 标签、标注、UI 卡片 | 完全以 DOM 元素构建 3D 场景(分子模型、沙盒等) |
| 标签朝向 | 始终平行于屏幕 | 跟随 3D 空间方向 |
二、导入方式:显式引入 Addon
CSS2DRenderer 属于 three.js 的 addon,必须显式导入,安装方式参见 Installation#Addons:
import { CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js';
由于官方示例(如 examples/css2d_label.html)使用 ES Module + importmap 加载,完整导入形如:
<script type="importmap">
{
"imports": {
"three": "../build/three.module.js",
"three/addons/": "./jsm/"
}
}
</script>
import * as THREE from 'three';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
CSS2DObject 与 CSS2DRenderer 定义在同一个模块中一并导出(见 export 语句)。
三、CSS2DObject:DOM 元素的 3D 载体
CSS2DObject 是 CSS2DRenderer 唯一支持的 3D 对象类型(继承自 Object3D,API 详见 docs/pages/CSS2DObject.html.md)。阅读其 构造器源码 可以理解它对 DOM 元素做了哪些默认处理:
const div = document.createElement( 'div' );
div.textContent = 'Earth';
const label = new CSS2DObject( div );
label.position.set( 1.5, 0, 0 );
earth.add( label ); // 挂到父物体的局部空间
label.center.set( 0, 1 ); // 锚点移到元素左下角
构造器行为(CSS2DRenderer.js):
- element 默认值:不传参时自动
document.createElement('div'); - 强制样式:
element.style.position = 'absolute'、userSelect = 'none',并设置draggable="false"。即标签元素会被绝对定位、禁止文本选中、禁止拖拽; - center : Vector2(L56):锚点/枢轴点,
(0.5, 0.5)为元素中心,(0, 0)为左上角。它会被转成transform-origin百分比参与投影变换; - rotation2D : number(L64):2D 旋转角(弧度,逆时针约定,默认 0)。注意渲染时实际取负号应用(见下文投影管线);
- isCSS2DObject:类型测试标志,渲染管线正是靠它识别应处理的对象;
- removed 事件自动清理(L66-L82):当对象(及其子节点)被移出场景图时,会
traverse并调用element.remove()把 DOM 从页面移除。因此删除标签 = 从场景移除对象,DOM 生命周期自动管理; - copy():深拷贝元素时执行
cloneNode( true ),并复制center、rotation2D。
四、构造函数与参数
new CSS2DRenderer( parameters : CSS2DRenderer~Parameters )
构造一个新的 CSS2DRenderer。parameters 为可选对象:
Parameters 类型定义(官方文档 "Type Definitions" 一节):
| 参数 | 类型 | 说明 |
|---|---|---|
element |
HTMLElement |
渲染器向其追加子元素的 DOM 容器。若不传入,将新建一个 div 元素 |
源码对应逻辑(L140-L149):
const domElement = parameters.element !== undefined ? parameters.element : document.createElement( 'div' );
domElement.style.overflow = 'hidden';
this.domElement = domElement;
注意容器 domElement 会被强制设置 overflow: hidden,超出容器范围的标签会被裁剪而非产生滚动条。
五、属性
.domElement : HTMLElement
渲染器向其追加所有标签元素的 DOM 容器。实际集成时通常手动设置 position: absolute、top: 0 使其与 WebGL 画布完全重叠(见 css2d_label.html 示例)。另一个实用细节:可以把 labelRenderer.domElement 而不是 WebGL canvas 交给 OrbitControls,因为标签层覆盖在画布上方,交互动画会被标签层拦截(示例中的做法:new OrbitControls( camera, labelRenderer.domElement ),L187)。
.sortObjects : boolean
控制渲染器是否为 CSS2DObject 的 DOM 元素分配 z-index。设为 true 时,z-index 的赋值优先级为:
- 先按对象的
renderOrder排序; - 再按到相机的距离(distance to camera)排序。
设为 false 时不分配任何 z-index。默认值为 true(L159)。
其底层实现是 zOrder() 函数(L313-L338):
const sorted = filterAndFlatten( scene ).sort( function ( a, b ) {
if ( a.renderOrder !== b.renderOrder ) {
return b.renderOrder - a.renderOrder; // renderOrder 大者优先
}
const distanceA = cache.objects.get( a ).distanceToCameraSquared;
const distanceB = cache.objects.get( b ).distanceToCameraSquared;
return distanceA - distanceB; // 距离近者优先
} );
const zMax = sorted.length;
for ( let i = 0, l = sorted.length; i < l; i ++ ) {
sorted[ i ].element.style.zIndex = zMax - i; // 排第 i 名的元素 zIndex = zMax - i
}
其中 filterAndFlatten() 通过 scene.traverseVisible() 收集所有 isCSS2DObject 的对象并压平成一维数组(L299-L311)。也就是说:renderOrder 相同的两个标签,离相机更近的会显示在更上层——这对防止近处标签被远处标签遮挡非常重要,是 sortObjects 默认开启的原因。
六、方法
.render( scene : Object3D, camera : Camera )
用给定相机渲染给定场景。
- scene:
Scene或任意Object3D; - camera:相机实例。
源码实现(L181-L192)完整流程:
this.render = function ( scene, camera ) {
if ( scene.matrixWorldAutoUpdate === true ) scene.updateMatrixWorld();
if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld();
_viewMatrix.copy( camera.matrixWorldInverse );
_viewProjectionMatrix.multiplyMatrices( camera.projectionMatrix, _viewMatrix );
renderObject( scene, scene, camera );
if ( this.sortObjects ) zOrder( scene );
};
步骤拆解:
- 若
scene.matrixWorldAutoUpdate为真则先更新世界矩阵;相机若不在场景图中(parent === null)也会更新其世界矩阵; - 预计算
viewProjectionMatrix = projectionMatrix × matrixWorldInverse(视空间 × 投影矩阵),供所有对象复用; - 递归执行
renderObject()完成逐个对象的投影与 DOM 更新; sortObjects为真时执行 z-index 排序。
逐对象投影管线(renderObject,L225-L288)是理解该渲染器的关键:
if ( object.visible === false ) { hideObject( object ); return; } // 不可见子树整体隐藏
if ( object.isCSS2DObject ) {
_vector.setFromMatrixPosition( object.matrixWorld ); // 1. 取世界坐标
_vector.applyMatrix4( _viewProjectionMatrix ); // 2. 投影到 NDC
const visible = ( _vector.z >= - 1 && _vector.z <= 1 )
&& ( object.layers.test( camera.layers ) === true ); // 3. 视锥内外 + 图层过滤
element.style.display = visible === true ? '' : 'none';
if ( visible === true ) {
object.onBeforeRender( _this, scene, camera ); // 4. 渲染前钩子
const cx = 100 * object.center.x; // 5. 应用 center 锚点
const cy = 100 * object.center.y;
element.style.transformOrigin = `${cx}% ${cy}%`;
const angle = - object.rotation2D; // 6. NDC → 像素坐标
const tx = _vector.x * _widthHalf + _widthHalf;
const ty = - _vector.y * _heightHalf + _heightHalf;
element.style.transform =
`translate(${- cx}%, ${- cy}%) translate(${tx}px, ${ty}px) rotate(${angle}rad)`;
if ( element.parentNode !== domElement ) domElement.appendChild( element );
object.onAfterRender( _this, scene, camera ); // 7. 渲染后钩子
}
const objectData = { distanceToCameraSquared: getDistanceToSquared( camera, object ) };
cache.objects.set( object, objectData ); // 8. 缓存距离供 zOrder 用
}
值得注意的实现细节:
- NDC 裁剪:投影后 z 分量在
[-1, 1]区间之外(即相机背后或超出近/远裁剪面)的标签直接display: none; - layers 机制:
object.layers.test( camera.layers )决定标签是否显示——这与 examples/css2d_label.html 中的 GUI 完全对应:通过camera.layers.toggle(0)/toggle(1)即可动态切换显示/隐藏不同图层上的标签组(示例把 “Earth/Moon 名称” 放在 layer 0、质量数值放在 layer 1); - 坐标换算:
tx = x·(W/2) + W/2、ty = -y·(H/2) + H/2,即标准的 NDC→CSS 像素映射(y 轴翻转); - DOM 复用:元素只会被移动到
domElement下一次(parentNode !== domElement判断),避免每帧频繁重排 DOM 树; - 回调钩子:可见时会依次触发
onBeforeRender/onAfterRender,可用于在每帧渲染前后自定义修改标签内容。
.setSize( width : number, height : number )
将渲染器调整为指定宽高的CSS 像素尺寸。内部更新 _width、_height、_widthHalf、_heightHalf(L200-L211),并把 domElement 的 style.width/height 设为对应 px 值。由于投影公式依赖 _widthHalf/_heightHalf,窗口尺寸变化后必须同步调用,否则标签位置会错位——官方示例中在 resize 监听里成对更新(onWindowResize):
window.addEventListener( 'resize', onWindowResize );
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight ); // WebGL 渲染器
labelRenderer.setSize( window.innerWidth, window.innerHeight ); // CSS2D 渲染器
}
.getSize() : Object
返回包含渲染器宽高的对象:
{ width: _width, height: _height }
七、实战示例:双渲染器集成
下面是基于官方示例 examples/css2d_label.html 精简后的可运行骨架(地球与月球各挂名称标签,随 OrbitControls 实时投影):
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
// 1. 场景 / 相机 / 对象(略:构建 earth、moon 等 Mesh)
const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 200 );
camera.position.set( 10, 5, 20 );
const scene = new THREE.Scene();
// scene.add( earth, moon, dirLight, ... );
// 2. WebGL 渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
// 3. CSS2D 渲染器:与 WebGL 画布完全重叠
const labelRenderer = new CSS2DRenderer(); // 不传 element,内部新建 div
labelRenderer.setSize( window.innerWidth, window.innerHeight );
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
document.body.appendChild( labelRenderer.domElement );
// 4. 标签:DOM 元素 -> CSS2DObject -> 加入场景图
const earthDiv = document.createElement( 'div' );
earthDiv.className = 'label';
earthDiv.textContent = 'Earth';
const earthLabel = new CSS2DObject( earthDiv );
earthLabel.position.set( 1.5 * EARTH_RADIUS, 0, 0 ); // 放在地球右侧 1.5 倍半径处
earthLabel.center.set( 0, 1 ); // 锚点:元素左下
earth.add( earthLabel );
// 5. 交互:把 OrbitControls 绑定到最上层的 labelRenderer.domElement
const controls = new OrbitControls( camera, labelRenderer.domElement );
controls.minDistance = 5;
controls.maxDistance = 100;
// 6. 每帧:两个渲染器先后调用 render(scene, camera)
function animate() {
requestAnimationFrame( animate );
moon.position.set( Math.sin( elapsed ) * 5, 0, Math.cos( elapsed ) * 5 );
renderer.render( scene, camera ); // 渲染 3D 网格
labelRenderer.render( scene, camera ); // 渲染 HTML 标签(同一 scene、camera)
}
CSS 侧只需给标签一个基础样式(示例中为白字半透明黑底):
.label {
color: #fff;
font-family: sans-serif;
padding: 2px;
background: rgba( 0, 0, 0, .6 );
}
示例运行效果可参考仓库截图 examples/screenshots/css2d_label.jpg。
八、使用细节与常见问题
结合源码,以下几点是集成时最容易踩的坑:
- 100% 缩放前提:文档明确
CSS2DRenderer只支持 100% 的浏览器与显示缩放。因为投影公式直接用 CSS 像素(px),若页面 zoom 非 100%,DOM 层与 WebGL 层可能不一致。移动端user-scalable=no的 viewport 设置(见示例 meta 标签)即是此类约束的体现; - 标签层级永远在最上:
labelRenderer.domElement后插入、position: absolute覆盖画布,因此标签天然浮于 3D 内容之上;标签与标签之间的前后关系才由sortObjects/renderOrder/相机距离决定。若需自定义某标签的层级,直接改其renderOrder即可; - visible 与图层是两套显隐开关:
object.visible = false会连同子树 DOM 一起display: none(hideObject);而layers只对相机可见性生效,DOM 仍会保留在页面上,适合做“信息分组切换”(如示例中的名称/质量两组标签); - center 决定“钉”在哪里:默认
(0.5, 0.5)让 3D 点落在元素中心;若标签是“引线式”的,设(0, 1)让 3D 点对齐左下角更自然(示例中地球标签即center.set( 0, 1 ),质量标签center.set( 0, 0 )形成上下错位); - 与其他渲染器共用同一场景图:
CSS2DRenderer对非CSS2DObject节点直接跳过递归处理它们(但仍遍历子节点),因此可与 WebGLRenderer、CSS3DRenderer 混合使用。仓库中 CSS3D 系列示例(如 examples/css3d_molecules.html)展示了同目录下的完整 3D DOM 方案,两者可按需组合; - 手动 DOM 清理注意:若你自行把标签从
domElement中移出(而非从场景图移除),渲染器下一帧又会把它appendChild回去;正确姿势始终是通过场景图管理标签的生命周期。
九、API 速查表
| 成员 | 签名 | 说明 |
|---|---|---|
| 构造函数 | new CSS2DRenderer( parameters? ) |
parameters.element 可选,不传则新建 div |
.domElement |
HTMLElement |
标签容器,强制 overflow: hidden |
.sortObjects |
boolean,默认 true |
是否按 renderOrder + 相机距离分配 z-index |
.render( scene, camera ) |
— | 更新矩阵 → NDC 投影 → 更新 DOM transform → z-index 排序 |
.setSize( width, height ) |
— | 更新 CSS 像素尺寸与投影用半宽/半高 |
.getSize() |
→ {width, height} |
读取当前尺寸 |
CSS2DObject.center |
Vector2,默认 (0.5, 0.5) |
锚点,映射为 transform-origin |
CSS2DObject.rotation2D |
number,默认 0 |
2D 旋转角(弧度) |
CSS2DObject.element |
HTMLElement |
外观由该 DOM 元素决定,强制绝对定位 |
十、参考路径
- 源码实现:examples/jsm/renderers/CSS2DRenderer.js
- 官方 API 文档:docs/pages/CSS2DRenderer.html.md、docs/pages/CSS2DObject.html.md、docs/pages/CSS3DRenderer.html.md
- 可运行示例:examples/css2d_label.html、examples/css2d_sandbox.html
- 安装说明:manual/pages/installation.html
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00