three.js InteractionManager 深入解析:让 HTMLTexture 三维网格上的 DOM 交互脱离场景图独立工作
InteractionManager 是 three.js 官方的交互管理器插件,专门解决「挂在三维网格表面上的 HTML 元素如何正确响应鼠标、键盘等浏览器原生指针事件」这一难题。它独立于场景图管理对象的交互注册,每帧为带 HTMLTexture 的对象计算 CSS matrix3d 变换,让底层 HTML 元素与网格保持精确对齐,从而直接复用浏览器原生的 DOM 事件体系。读完本文,你将掌握该管理器完整的初始化流程、全部 API 语义,以及其矩阵换算的底层原理,并能在自己的 WebGL / WebGPU 渲染管线中直接落地交互式三维页面。
InteractionManager 要解决什么问题
在现代 Web 三维开发中,我们经常需要在三维世界里嵌入真实 HTML 内容,例如富文本标签、表单输入框、可点击按钮等。three.js 通过 HTMLTexture(相关文档见 HTMLTexture.html.md,实现见 HTMLTexture.js)把一段 HTML 元素作为纹理贴到模型表面,使这些内容可以随网格旋转、缩放并被光照影响。
但这里存在一个关键矛盾:纹理是「像素」,而交互是「DOM 事件」。画布只是把 HTML 元素"画"成了纹理位图,浏览器并不知道按钮、输入框在屏幕上的实际位置,自然不会把 pointer 事件分发给它们。InteractionManager 的思路非常巧妙——它让 HTML 元素仍然作为画布(canvas)的真实子节点存活在 DOM 树中,每帧计算一个 CSS matrix3d 变换,把元素精确"摆放"到其网格在屏幕上投影的位置。由于元素确实是画布的子节点,浏览器会原生地把指针事件分发给它们,不需要任何手动命中测试。
按照官方定义,InteractionManager 的核心职责是:
- 独立于场景图(scene graph)地管理三维对象的交互注册;
- 对带有
HTMLTexture的对象,每帧计算 CSSmatrix3d变换,使底层 HTML 元素始终与网格对齐; - 借助"元素是画布子节点"这一结构,让浏览器原生派发指针事件。
导入 InteractionManager
InteractionManager 是 three.js 的 addon(附加组件),不会打包进核心库,必须显式导入。仓库中的实现位于 examples/jsm/interaction/InteractionManager.js:
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
本地演示项目中,three/addons/ 前缀通过 importmap 映射到 ./jsm/ 目录(映射方式可参考官方示例 webgl_materials_texture_html.html 中的 <script type="importmap">)。在你的工程中导入路径需要与自身的打包器别名保持一致。
快速上手:一个可交互的 HTML 纹理立方体
InteractionManager 的典型用法只有三步:创建管理器并 connect 渲染器与相机、把网格 add 进管理器、在动画循环中每帧调用 update。官方文档给出的骨架如下:
const interactions = new InteractionManager();
interactions.connect( renderer, camera );
// 网格可以位于场景图的任意位置
scene.add( mesh );
// 交互注册与场景层级无关,单独进行
interactions.add( mesh );
// 在动画循环中
interactions.update();
仓库中完整的可运行实现见 webgl_materials_texture_html.html(WebGPU 版本见 webgpu_materials_texture_html.html)。以下是去掉装饰代码后的核心逻辑:
import * as THREE from 'three';
import { installHtmlInCanvasPolyfill } from 'three-html-render/polyfill';
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
// —— 检测并补齐 HTML-in-Canvas 能力 ——
if ( ! ( 'requestPaint' in HTMLCanvasElement.prototype ) ) {
installHtmlInCanvasPolyfill();
}
const renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
const camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 1, 2000 );
camera.position.z = 500;
// —— 构造带 HTML 内容的 div ——
const element = document.createElement( 'div' );
element.id = 'draw_element';
element.innerHTML = `
Hello world! <input type="text" placeholder="Type here...">
<button>Click me</button>
`;
// —— HTMLTexture 作为漫反射贴图 ——
const geometry = new THREE.RoundedBoxGeometry( 200, 200, 200, 10, 10 );
const material = new THREE.MeshStandardMaterial( { roughness: 0, metalness: 0.5 } );
material.map = new THREE.HTMLTexture( element );
const mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
// —— 注册交互 ——
const interactions = new InteractionManager();
interactions.connect( renderer, camera );
interactions.add( mesh );
// 元素作为 canvas 子节点,原生事件即可生效
element.querySelector( 'button' ).addEventListener( 'click', function () {
this.textContent = 'Clicked!';
} );
function animate( time ) {
mesh.rotation.x = Math.sin( time * 0.0005 ) * 0.5;
mesh.rotation.y = Math.cos( time * 0.0008 ) * 0.5;
interactions.update(); // 每帧同步元素变换
renderer.render( scene, camera );
}
renderer.setAnimationLoop( animate );
运行这段代码后,立方体上的按钮、输入框会随网格旋转始终贴合表面,并且点击按钮会像普通网页一样触发 click 事件——这正是 InteractionManager 的价值所在。
需要说明的是:该机制依赖 HTML 元素被"画"进画布底层的能力。原生实现依赖 HTML-in-Canvas 相关 API(如 requestPaint),在不支持的浏览器上,官方示例会引入 three-html-render/polyfill 作为降级方案,见示例中 installHtmlInCanvasPolyfill() 的调用条件。
API 参考
构造器
new InteractionManager()
无参构造器。创建一个空的管理器,此时 objects 为空数组,camera 与 element 均为 null。可在构造后立即使用 connect() 与 add() 完成装配,也可以先注册对象、稍后再连接渲染器(update() 会在二者缺失时安全地直接返回)。
属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| .camera | Camera | null |
用于计算元素变换的相机。通过 connect() 写入。 |
| .element | HTMLCanvasElement | null |
画布元素,即 renderer.domElement。通过 connect() 写入。 |
| .objects | Array.<Object3D> | [] |
已注册的交互对象列表。通过 add() / remove() 维护。 |
.objects 仅存放显式注册的对象。内部每帧遍历该数组,因此网格是否 add 到场景中与是否注册进管理器是两件独立的事——场景树只管渲染,管理器只处理交互。此外,实现内部还有两个私有缓存 _cachedCssW 与 _cachedCssH(初始为 -1),用于在画布 CSS 尺寸未变化时复用已构造的视口矩阵,避免每帧重复计算。
方法
.add( ...objects : Object3D ) : this
向管理器添加一个或多个对象。若对象已在列表中则自动跳过(内部通过 indexOf 判重),因此重复添加是安全的;方法返回 this 以支持链式调用:
interactions.add( meshA, meshB, meshC );
.remove( ...objects : Object3D ) : this
从管理器移除一个或多个对象。对象不存在于列表时调用同样安全;返回 this 支持链式调用:
interactions.remove( meshB );
.connect( renderer : WebGPURenderer | WebGLRenderer, camera : Camera )
存储计算元素变换所需的渲染器与相机。源码实现为:
connect( renderer, camera ) {
this.camera = camera;
this.element = renderer.domElement;
}
需要注意:connect 并不会为传入的 canvas 做事件绑定,也不修改 DOM 结构——请保证承载 HTML 元素的容器就是 canvas 本身(或包含于画布渲染结果中),这样元素作为画布子节点时浏览器才会原生派发指针事件。WebGPURenderer 与 WebGLRenderer 均可使用,因为它们都对外暴露 domElement。
.disconnect()
断开管理器与渲染器/相机的连接,清除引用并重置尺寸缓存。调用后若不重新 connect,update() 会因 canvas === null || camera === null 直接返回:
disconnect() {
this.camera = null;
this.element = null;
this._cachedCssW = - 1;
this._cachedCssH = - 1;
}
适合在销毁场景、切换页面或复用单例管理器时调用,避免对已卸载的 canvas 与相机保持强引用造成泄漏。
.update()
为所有已注册对象更新元素变换,必须在动画循环中每帧调用一次(通常放在 renderer.render() 之前)。若相机或画布尚未连接,方法会直接返回;对于每个对象,只有当 object.material.map 存在且满足 texture.isHTMLTexture === true(即使用 HTMLTexture)时才会真正计算变换,其余普通纹理对象会被跳过,不会产生额外开销。
底层原理:从三维顶点到 CSS matrix3d 的数学换算
理解 update() 的矩阵流水线,是进阶使用与排查定位问题的关键。整个计算发生在 examples/jsm/interaction/InteractionManager.js,逐段拆解如下。
第一步:构造 CSS 像素视口矩阵
先把 NDC 坐标(-1 ~ 1)映射到画布的 CSS 像素坐标系,同时翻转 Y 轴以匹配屏幕坐标系:
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
_viewport.set(
cssW / 2, 0, 0, cssW / 2,
0, - cssH / 2, 0, cssH / 2,
0, 0, 1, 0,
0, 0, 0, 1
);
这里特意使用 clientWidth / clientHeight(CSS 像素)而非物理像素,这样最终矩阵可以直接作为 CSS transform 使用,无需再做 DPR 换算。画布 CSS 尺寸未变化时,该矩阵不会重建(由 _cachedCssW/_cachedCssH 控制)。
第二步:为每个对象拼接局部坐标映射
对每个已注册对象,先取出材质贴图并校验其为 HTMLTexture,然后按如下步骤处理(源码 InteractionManager.js):
// 元素绝对定位到画布左上角,变换原点设为左上角
element.style.position = 'absolute';
element.style.left = '0';
element.style.top = '0';
element.style.transformOrigin = '0 0';
const elemW = element.offsetWidth;
const elemH = element.offsetHeight;
// 取几何体包围盒
if ( ! geometry.boundingBox ) geometry.computeBoundingBox();
geometry.boundingBox.getSize( _size );
// 元素像素坐标 (0,0)-(elemW,elemH) → 网格局部坐标
_pixelToLocal.set(
_size.x / elemW, 0, 0, - _size.x / 2,
0, - _size.y / elemH, 0, _size.y / 2,
0, 0, 1, geometry.boundingBox.max.z,
0, 0, 0, 1
);
该矩阵把元素的"正面"对齐到网格局部空间的前表面:左上角对应 (-sizeX/2, sizeY/2, maxZ),右下角对应 (sizeX/2, -sizeY/2, maxZ)。由此 HTML 内容被平铺到几何体包围盒的前脸上。这意味着你应当让承载交互内容的网格几何体包围盒与其可见面大致吻合,否则纹理覆盖范围会与预期偏差。源码同时会按需调用 geometry.computeBoundingBox(),若网格从未被渲染过且未手动计算包围盒,也能得到正确结果。
第三步:合成 MVP 并应用视口
// Model-View-Projection
_mvp.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse );
_mvp.multiply( object.matrixWorld );
_mvp.multiply( _pixelToLocal );
// 应用视口
_mvp.premultiply( _viewport );
矩阵链可以写作:Viewport × Projection × View × Model × PixelToLocal。其中 camera.matrixWorldInverse 要求相机矩阵是最新的——若你手动修改了相机位置/朝向,应在调用 interactions.update() 前确保相机完成了 updateMatrixWorld()(renderer.render() 内部会自动处理,但如果你把 interactions.update() 放在 render() 之后、又立即改动了相机,就可能出现一帧的错位)。
第四步:写出 matrix3d
element.style.transform = 'matrix3d(' + _mvp.elements.join( ',' ) + ')';
_mvp 是透视投影矩阵,matrix3d 恰好支持 16 个分量组成的 4×4 矩阵;当元素远离相机时矩阵中 w 分量会让 CSS 引擎自动完成透视除法(perspective divide),从而呈现正确的近大远小效果。整个过程每帧对每个注册对象执行一次,把 HTML 元素"钉"在网格投影位置上。
WebGL / WebGPU 双后端兼容
connect() 的参数类型同时接受 WebGLRenderer 与 WebGPURenderer(源码类型注释见 InteractionManager.js)。仓库提供了两套完全对等的官方示例来验证这一点:
两者都使用 RoundedBoxGeometry + MeshStandardMaterial + HTMLTexture 的组合,并都通过同样的 connect → add → update 三步接入 InteractionManager。因此交互层代码与后端渲染器解耦,迁移渲染后端时无需改动交互逻辑。
使用建议与注意事项
综合文档说明与源码实现,以下是实践中容易踩坑的几个要点:
- HTML 元素必须是画布的子节点体系:机制依赖浏览器向 canvas 子元素原生派发指针事件。不要把 HTML 元素放到与 canvas 无关的普通 DOM 容器中,否则对齐与事件分发都会失效。
- 每帧调用
update():变换计算是显式的,遗漏调用会导致元素停留在上一帧位置,尤其当相机或网格持续运动时。 - 只有
HTMLTexture对象会被处理:update()内部通过texture.isHTMLTexture快速筛选,普通纹理(如CanvasTexture)不会获得任何变换。 - 尽量不手改元素样式:管理器每帧会把
position、left、top、transformOrigin、transform重置为计算值,自定义样式请加在内部子元素上或通过 CSS 变量等方式间接控制。 - 几何体包围盒决定贴图范围:矩阵映射基于
geometry.boundingBox,若网格被缩放/变形,交互元素尺寸同样会按该逻辑换算,请确认几何体数据在合理范围内。 - 留意 HTML-in-Canvas 浏览器能力:官方示例在不支持
requestPaint的环境中会加载 polyfill。生产环境部署前应在目标浏览器中验证该能力是否存在及是否需要引入降级库。 - 清理时机:页面卸载或重建场景时调用
disconnect()并解除相关事件监听,避免长生命周期页面中残留引用。 - 对象较多时关注性能:
update()是逐对象矩阵运算,涉及较多 DOM 样式写入。若注册大量对象,可将确实需要交互的对象单独注册,避免把只读装饰网格也加入objects列表。
小结
InteractionManager 提供了一套优雅的"以 DOM 换事件"方案:通过 HTMLTexture 把 HTML 渲染到三维表面,再通过逐帧 matrix3d 变换让真实 DOM 元素始终与网格投影重合,从而让浏览器原生的点击、输入等事件在三维场景中无缝工作。它的注册机制与场景图解耦(add / remove 独立管理),配合 connect + 每帧 update 的最小使用模型,无论 WebGL 还是 WebGPU 渲染后端都能即插即用。理解其内部 Viewport × MVP × PixelToLocal 的矩阵链后,你也能在其基础上扩展出拖拽、悬浮提示、表单输入等更丰富的三维 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 StartedRust0625
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
