首页
/ three.js CSS2DRenderer 详解:将 HTML 标签挂载到 3D 场景的完整实践

three.js CSS2DRenderer 详解:将 HTML 标签挂载到 3D 场景的完整实践

2026-09-06 13:06:49作者:裴锟轩Denise

CSS2DRenderer 是 three.js 官方提供的 HTML 标签渲染器,用于把 DOM 元素(文本、图标、卡片等)锚定到三维场景中的物体上,并随相机视角实时投影到正确的屏幕位置。本篇基于官方 API 文档 docs/pages/CSS2DRenderer.html.md 与源码 examples/jsm/renderers/CSS2DRenderer.js 展开,读完你可以掌握:CSS2DRendererCSS3DRenderer 的定位差异、全部构造参数/属性/方法的含义与底层实现、从 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

两个关键约束(来自官方文档):

  1. 仅支持平移:标签始终“面朝”屏幕,不会随 3D 空间翻转、缩放。从源码注释看,除平移外还支持一个绕屏幕法线的 2D 旋转(rotation2D 属性,见 CSS2DObject 构造器),文档页面尚未更新这一点;
  2. 仅支持 100% 浏览器与显示缩放:即页面缩放(Ctrl +/-)不等于 100% 时,标签与 3D 内容可能出现偏移。

CSS2DRendererCSS3DRenderer 的选型对比:

维度 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';

CSS2DObjectCSS2DRenderer 定义在同一个模块中一并导出(见 export 语句)。

三、CSS2DObject:DOM 元素的 3D 载体

CSS2DObjectCSS2DRenderer 唯一支持的 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 : Vector2L56):锚点/枢轴点,(0.5, 0.5) 为元素中心,(0, 0) 为左上角。它会被转成 transform-origin 百分比参与投影变换;
  • rotation2D : numberL64):2D 旋转角(弧度,逆时针约定,默认 0)。注意渲染时实际取负号应用(见下文投影管线);
  • isCSS2DObject:类型测试标志,渲染管线正是靠它识别应处理的对象;
  • removed 事件自动清理L66-L82):当对象(及其子节点)被移出场景图时,会 traverse 并调用 element.remove() 把 DOM 从页面移除。因此删除标签 = 从场景移除对象,DOM 生命周期自动管理;
  • copy():深拷贝元素时执行 cloneNode( true ),并复制 centerrotation2D

四、构造函数与参数

new CSS2DRenderer( parameters : CSS2DRenderer~Parameters )

构造一个新的 CSS2DRendererparameters 为可选对象:

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: absolutetop: 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 的赋值优先级为:

  1. 先按对象的 renderOrder 排序;
  2. 再按到相机的距离(distance to camera)排序。

设为 false 时不分配任何 z-index。默认值为 trueL159)。

其底层实现是 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 )

用给定相机渲染给定场景。

  • sceneScene 或任意 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 );
};

步骤拆解:

  1. scene.matrixWorldAutoUpdate 为真则先更新世界矩阵;相机若不在场景图中(parent === null)也会更新其世界矩阵;
  2. 预计算 viewProjectionMatrix = projectionMatrix × matrixWorldInverse(视空间 × 投影矩阵),供所有对象复用;
  3. 递归执行 renderObject() 完成逐个对象的投影与 DOM 更新;
  4. sortObjects 为真时执行 z-index 排序。

逐对象投影管线renderObjectL225-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/2ty = -y·(H/2) + H/2,即标准的 NDC→CSS 像素映射(y 轴翻转);
  • DOM 复用:元素只会被移动到 domElement 下一次(parentNode !== domElement 判断),避免每帧频繁重排 DOM 树;
  • 回调钩子:可见时会依次触发 onBeforeRender / onAfterRender,可用于在每帧渲染前后自定义修改标签内容。

.setSize( width : number, height : number )

将渲染器调整为指定宽高的CSS 像素尺寸。内部更新 _width_height_widthHalf_heightHalfL200-L211),并把 domElementstyle.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

八、使用细节与常见问题

结合源码,以下几点是集成时最容易踩的坑:

  1. 100% 缩放前提:文档明确 CSS2DRenderer 只支持 100% 的浏览器与显示缩放。因为投影公式直接用 CSS 像素(px),若页面 zoom 非 100%,DOM 层与 WebGL 层可能不一致。移动端 user-scalable=no 的 viewport 设置(见示例 meta 标签)即是此类约束的体现;
  2. 标签层级永远在最上labelRenderer.domElement 后插入、position: absolute 覆盖画布,因此标签天然浮于 3D 内容之上;标签与标签之间的前后关系才由 sortObjects/renderOrder/相机距离决定。若需自定义某标签的层级,直接改其 renderOrder 即可;
  3. visible 与图层是两套显隐开关object.visible = false 会连同子树 DOM 一起 display: nonehideObject);而 layers 只对相机可见性生效,DOM 仍会保留在页面上,适合做“信息分组切换”(如示例中的名称/质量两组标签);
  4. center 决定“钉”在哪里:默认 (0.5, 0.5) 让 3D 点落在元素中心;若标签是“引线式”的,设 (0, 1) 让 3D 点对齐左下角更自然(示例中地球标签即 center.set( 0, 1 ),质量标签 center.set( 0, 0 ) 形成上下错位);
  5. 与其他渲染器共用同一场景图CSS2DRenderer 对非 CSS2DObject 节点直接跳过递归处理它们(但仍遍历子节点),因此可与 WebGLRenderer、CSS3DRenderer 混合使用。仓库中 CSS3D 系列示例(如 examples/css3d_molecules.html)展示了同目录下的完整 3D DOM 方案,两者可按需组合;
  6. 手动 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 元素决定,强制绝对定位

十、参考路径

登录后查看全文
热门项目推荐
相关项目推荐