☰
three.js TubePainter 实战指南:沿点序列绘制动态管状笔画的 API 原理与 XR 画笔应用
2026/10/25 2:32:45 网站建设 项目流程

three.js TubePainter 实战指南:沿点序列绘制动态管状笔画的 API 原理与 XR 画笔应用

【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js

TubePainter 是 three.js 提供的一个 misc(杂项)附加模块,用于沿着一串三维点连续"挤出"出管状网格,模拟真实画笔在空间中留下带粗细、带颜色渐变笔迹的效果,它在官方 XR(虚拟现实/增强现实)绘画示例中承担核心绘制职责。阅读本文后,你将掌握 TubePainter 的全部公开 API(moveTo/lineTo/setColor/setSize/update及mesh属性)的用法、其内部动态几何缓冲与管线生成的工作原理,并能够把同样的能力复用到桌面鼠标绘画、触控笔迹、粒子轨迹等场景中。

TubePainter 是什么:功能定位与适用场景

根据本仓库 API 文档 docs/pages/TubePainter.html 与源码文件头部注释(见 examples/jsm/misc/TubePainter.js)的描述:

This module can be used to paint tube-like meshes along a sequence of points. This module is used in a XR painter demo.

即:TubePainter 沿"一序列点"生成管状网格,与 three.js 内置的 TubeGeometry 不同,它并不一次性根据既有曲线生成静态几何,而是面向交互式连续绘制设计——每一帧随着手写笔 / 手柄 / 鼠标位置变化追加一小段管道,已绘制的部分保持不动。因此它天然适配:

  • XR 空间画笔:手柄按下扳机移动,笔迹跟随手柄轨迹延伸(官方示例 examples/webxr_xr_paint.html);
  • 桌面自由绘画:鼠标 / 触控在平面或曲面上拖拽出立体"橡皮泥"笔迹;
  • 可视化轨迹:把动态物体的运动轨迹渲染成有体积的光带。

快速上手:创建一支画笔并加入场景

TubePainter 是一个addon(附加模块),不在核心库默认导出中,必须显式引入。在本仓库的 npm 包导出结构中,three/addons/*被映射到examples/jsm/*(见 package.json 的exports字段),因此以 npm 方式安装后可直接使用:

import { TubePainter } from 'three/addons/misc/TubePainter.js';

最基础的使用流程只有两步——创建实例,把它的画笔网格加入场景:

const painter = new TubePainter(); scene.add( painter.mesh );

此后调用painter.moveTo(起点)落笔,再逐帧调用painter.lineTo(新位置)与painter.update()即可不断延长笔迹。

若希望在浏览器中不经过打包工具直接运行,可参照仓库 examples 目录下现有页面顶部的 importmap 写法(见 examples/webxr_xr_paint.html):把three映射到本地构建产物、把three/addons/映射到examples/jsm/目录,即可让import { TubePainter } from 'three/addons/misc/TubePainter.js'在原生 ES Module 下生效。

构造函数与公开接口总览

构造函数不接收任何参数:

const painter = new TubePainter();

实例暴露一个属性和五个方法,下表对照源码逐一说明:

成员类型 / 签名作用源码位置
meshMesh被"画"出来的管状网格,必须手动加入场景才会显示examples/jsm/misc/TubePainter.js
moveTo( position )Vector3将当前绘制笔尖移动到指定位置(落笔,不产生几何)examples/jsm/misc/TubePainter.js
lineTo( position )Vector3从当前笔尖向目标位置画一段笔画(延长管道)examples/jsm/misc/TubePainter.js
setColor( color )Color设置后续新画管段的顶点颜色examples/jsm/misc/TubePainter.js
setSize( size )number设置后续新画管段的粗细examples/jsm/misc/TubePainter.js
update()—把新写入缓冲区的几何数据上传到 GPU 并让其可见examples/jsm/misc/TubePainter.js

.mesh : Mesh

painter.mesh

"被绘制的"管状网格。它由内部的BufferGeometry与MeshStandardMaterial组合而成,材质开启了顶点着色(vertexColors: true),从而每条新画的笔画都能携带独立颜色。该网格的包围体不会随绘制自动刷新,因此源码刻意将mesh.frustumCulled = false(examples/jsm/misc/TubePainter.js),避免因视锥剔除误判把正在延长的笔迹裁掉。使用方需要把mesh加入场景(或其它 Object3D 子树)后才会被渲染。

.moveTo( position : Vector3 )

painter.moveTo( new THREE.Vector3( 0, 1.6, - 1 ) );

把"当前绘画位置"移动到position。它只改变落笔参考点,不产生任何几何体,相当于把画笔抬起来移到新位置。从源码看(examples/jsm/misc/TubePainter.js),moveTo内部会重置三项笔画状态:

  • 记录新起点point2;
  • 复位法线参考lastNormal = (0,1,0),标记isFirstSegment = true(下一段将重新计算初始坐标系);
  • 清空上一笔的"笔尖端盖"记录(endCapStartIndex = null、endCapVertexCount = 0),准备在新起点处重新生成封口。

.lineTo( position : Vector3 )

painter.lineTo( new THREE.Vector3( x, y, z ) );

从当前笔尖向position画出一段笔画并延长管道。这是 XR 手柄边移动边绘制时被逐帧调用的核心方法。其内部执行顺序(见 examples/jsm/misc/TubePainter.js):

  1. 计算目标点与当前点的方向向量;若两点重合(长度为零)则直接返回;
  2. 根据方向计算管道在该段的局部坐标系(旋转最小化标架,详见下文"框架算法"小节);
  3. 若这是新落笔后的第一段,先为起点添加半球形封盖,并立即在笔尖处预留一段"末端封盖",记录其顶点区间用于原地更新;
  4. 在上一截面与当前截面之间生成一圈管壁三角形;
  5. 调用updateEndCap把末端封盖原位改写到最新笔尖处,使笔头始终是封闭的圆弧形,而不是漏出中空截面;
  6. 将当前点、坐标系、颜色、粗细"滚动"保存为上一状态,供下一段衔接。

注意:lineTo只负责向内部几何缓冲追加顶点,要让渲染结果刷新还需要紧接着调用update()(官方 demo 中两者总是在同一帧内先后出现)。

.setColor( color : Color )

painter.setColor( new THREE.Color( 0xff3366 ) ); // 或按 HSL 动态取色: painter.setColor( new THREE.Color().setHSL( hue, 1.0, 0.5 ) );

设置后续新绘制管段使用的颜色。它不会回溯改写已经画好的笔迹,只影响此后追加的顶点。若在连续绘制中逐帧改变颜色,相邻管段的顶点色不同,因材质启用了顶点着色,管壁上会呈现自然的颜色渐变过渡。

.setSize( size : number )

painter.setSize( 1.0 );

设置后续新绘制管段的粗细。源码中管段横截面半径与size的关系为radius = 0.01 * size(见 examples/jsm/misc/TubePainter.js),也就是说size = 1时半径约为 0.01 个世界单位,size = 5时约为 0.05。与setColor相同,它只影响之后追加的管段;若绘制过程中粗细发生改变,相邻截面尺寸不同,会得到类似书法笔触的渐粗渐细效果。

.update()

painter.lineTo( target ); painter.update();

lineTo/moveTo等调用只修改 CPU 侧的类型化数组,update()负责把这些改动真正交给 GPU。它内部只对"自上次 update 以来新增的顶点区间"打上脏标记(addUpdateRange)并把needsUpdate置为true,从而让渲染器按需上传这部分数据(详见下文"动态缓冲上传机制")。

从源码看实现原理

理解 TubePainter 的关键在于:它在构造时一次性预分配了超大容量的顶点缓冲,之后每次画一笔只是在已有数组的尾部继续写数据,并通过BufferGeometry.drawRange控制"实际参与渲染的顶点数量"逐渐扩大。以下逐层拆解 examples/jsm/misc/TubePainter.js 的实现。

一次性预分配 + 动态绘制用法

源码开头定义了常量与三个顶点属性(examples/jsm/misc/TubePainter.js):

const BUFFER_SIZE = 1000000 * 3; const positions = new BufferAttribute( new Float32Array( BUFFER_SIZE ), 3 ); positions.usage = DynamicDrawUsage; const normals = new BufferAttribute( new Float32Array( BUFFER_SIZE ), 3 ); normals.usage = DynamicDrawUsage; const colors = new BufferAttribute( new Float32Array( BUFFER_SIZE ), 3 ); colors.usage = DynamicDrawUsage; const geometry = new BufferGeometry(); geometry.setAttribute( 'position', positions ); geometry.setAttribute( 'normal', normals ); geometry.setAttribute( 'color', colors ); geometry.drawRange.count = 0;
  • 每个属性数组预分配300 万个Float32(约合每属性 12 MB,三个属性合计约 36 MB),最多可容纳约 100 万个顶点;
  • 三个属性都被标记为DynamicDrawUsage,提示 GPU 驱动这些缓冲会频繁更新,应优先放置在适合动态上传的存储;
  • 初始drawRange.count = 0,即虽然分配了巨量显存缓冲,但一开始"一个顶点都不画";
  • 每画一段,几何顶点数增长,代码只在数组尾部追加写入,写入位置由不断递增的count变量维护。

这一设计使 TubePainter 可以支撑长时间、高帧率的连续绘画而无需反复realloc或重建 BufferGeometry,代价是内存一次性占用较高,且单个实例存在约 100 万顶点的容量上限——源码中没有任何自动扩容逻辑,达到上限后继续lineTo会超出预分配数组边界,实践中若需要更长的笔迹应创建多个 TubePainter 实例分段管理。

管壁几何:横截面圆环与"两截面之间搭桥"

每段管道的截面由一个圆环近似。getPoints(examples/jsm/misc/TubePainter.js)在局部 X–Y 平面内生成均匀分布在圆周上的 15 个点(sides = 15),圆的半径即上文提到的0.01 * size。

当从"旧截面"推进到"新截面"时,stroke(examples/jsm/misc/TubePainter.js)把两个截面圆环上对应的 15 组顶点两两配对,每个配对(一个四边形)拆成两个三角形写入缓冲。因此每画一小段大约新增15 × 2 × 3 = 90个顶点(两个截面被相邻两段共享,实际每段净增量与其相邻几何有关,此处为粗略数量级)。粗略估算:若以每帧一小段、每段约 90 顶点计,100 万顶点的容量大致可支撑上万次lineTo调用,足以满足典型 XR 绘画会话。

笔尖封口:半球端盖与"原位更新"技巧

若管道两端不做处理,会露出中空圆环截面,观感上像是被切断的水管。TubePainter 用addCap(examples/jsm/misc/TubePainter.js)为两端补上圆弧形的半球封盖:

  • 起始封盖:新落笔后的第一次lineTo在起点处生成(isEndCap = false),凸面朝外;
  • 末端封盖:紧接在笔尖处预先生成(isEndCap = true),并记录它在缓冲区中的起始索引与顶点数(endCapStartIndex/endCapVertexCount,examples/jsm/misc/TubePainter.js);
  • 每次lineTo再通过updateEndCap(examples/jsm/misc/TubePainter.js)原地改写这笔尖端盖的顶点,使其始终"跟住"不断移动的笔尖。

因为端盖顶点始终位于预分配数组的同一区间,改写时只需对该区间调用addUpdateRange,不会造成几何无限膨胀——已画出的笔杆顶点数只增不减,而笔尖那顶"帽子"一直在原位被刷新。

框架算法:沿路径旋转最小化标架(由源码推断)

要让管道在转弯处不"拧麻花",每一段都需要一个稳定的局部坐标系。源码中calculateRMF(examples/jsm/misc/TubePainter.js)通过lastNormal、prevDirection、rotationAxis等状态变量,沿路径逐段递推地计算横截面朝向,从实现结构看采用的是**旋转最小化标架(Rotation Minimizing Frame,RMF,也称平行传输法线)**的思路:

  • 首段:根据当前前进方向相对世界轴的角度选择参考向量(Y 轴优先,方向接近 Y 轴时退化为 X 轴),再施以 Gram–Schmidt 正交化得到法线与副法线;
  • 后续段:用上一段法线绕两段方向的叉积轴旋转,实现法线沿路径"最小旋转"地搬运,避免笔迹扭转;
  • 平滑:每段法线再向上一段法线插值 30%(smoothFactor = 0.3,见 examples/jsm/misc/TubePainter.js),让笔迹转弯更柔和。

最终由matrix1.makeBasis( side, normal, -direction )组装出局部坐标系矩阵,把截面圆环从局部空间变换到世界空间(examples/jsm/misc/TubePainter.js)。

颜色与粗细的跨段渐变

TubePainter 内部维护了"当前值"与"上一段值"两套状态(color1/color2、size1/size2、matrix1/matrix2)。stroke在同一个四边形网格的三角形顶点上分别写入两组颜色值(见 examples/jsm/misc/TubePainter.js),配合逐帧变化的setColor,相邻两段之间便形成了平滑的顶点色渐变;同理,当笔刷尺寸在两段之间发生变化时,两端截面半径不同,管道呈现自然的粗细过渡。这也解释了官方 XR demo 为什么能在"同一支笔"上同时实现彩虹渐变色与可伸缩笔刷。

动态缓冲上传机制

update()的实现如下(examples/jsm/misc/TubePainter.js):

let count = 0; function update() { const start = count; const end = geometry.drawRange.count; if ( start === end ) return; positions.addUpdateRange( start * 3, ( end - start ) * 3 ); positions.needsUpdate = true; // normals / colors 同理... count = end; }
  • count(闭包变量)记录上次已提交的顶点号,drawRange.count记录当前几何实际顶点数;
  • 每次update只把[count, end)这一新增区间通过addUpdateRange登记为待上传,再置needsUpdate = true,渲染时由 WebGLRenderer 读取该更新范围做部分缓冲上传;
  • 若没有新顶点(start === end)则直接返回,避免无谓的 GPU 开销。

这种"增量标记 + 部分上传"机制保证了连续绘画时每帧仅上传新增的那一小段数据,是整支画笔能实时跟手的关键。

一个可直接运行的桌面绘画示例

TubePainter 并不绑定 XR——把官方 demo 中"手柄位置"替换成"鼠标射线与地面的交点",就得到一个立即可玩的桌面版本。下面示例演示了moveTo落笔、按住鼠标拖动画线、实时换色、结束后抬笔的完整闭环:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>TubePainter 桌面绘画</title> <style> html, body { margin: 0; overflow: hidden; } #info { position: absolute; top: 10px; left: 10px; color: #fff; font-family: sans-serif; } </style> </head> <body> <div id="info">按住鼠标在地面上拖动绘画;颜色随时间自动变化</div> <script type="importmap"> { "imports": { "three": "../build/three.module.js", "three/addons/": "./jsm/" } } </script> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { TubePainter } from 'three/addons/misc/TubePainter.js'; const renderer = new THREE.WebGLRenderer( { antialias: true } ); renderer.setSize( window.innerWidth, window.innerHeight ); document.body.appendChild( renderer.domElement ); const scene = new THREE.Scene(); scene.background = new THREE.Color( 0x111122 ); const camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 100 ); camera.position.set( 2, 3, 5 ); const controls = new OrbitControls( camera, renderer.domElement ); controls.target.set( 0, 1, 0 ); scene.add( new THREE.GridHelper( 6, 12 ) ); scene.add( new THREE.HemisphereLight( 0xffffff, 0x444466, 2 ) ); const painter = new TubePainter(); painter.setSize( 1.2 ); scene.add( painter.mesh ); // 把鼠标指针投射到 y = 0 的地面平面上 const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const groundPlane = new THREE.Plane( new THREE.Vector3( 0, 1, 0 ), 0 ); const target = new THREE.Vector3(); let isDrawing = false; function pickTarget() { raycaster.setFromCamera( pointer, camera ); return raycaster.ray.intersectPlane( groundPlane, target ) !== null; } renderer.domElement.addEventListener( 'pointerdown', ( e ) => { pointer.set( ( e.clientX / innerWidth ) * 2 - 1, -( e.clientY / innerHeight ) * 2 + 1 ); if ( ! pickTarget() ) return; isDrawing = true; painter.moveTo( target ); // 落笔 painter.setColor( new THREE.Color().setHSL( Math.random(), 1, 0.5 ) ); } ); renderer.domElement.addEventListener( 'pointermove', ( e ) => { pointer.set( ( e.clientX / innerWidth ) * 2 - 1, -( e.clientY / innerHeight ) * 2 + 1 ); if ( ! isDrawing || ! pickTarget() ) return; painter.setColor( new THREE.Color().setHSL( ( performance.now() * 0.0005 ) % 1, 1, 0.5 ) ); painter.lineTo( target ); // 画一笔 painter.update(); // 提交新顶点 } ); window.addEventListener( 'pointerup', () => ( isDrawing = false ) ); renderer.setAnimationLoop( () => renderer.render( scene, camera ) ); </script> </body> </html>

说明:若你在已安装three的 npm 项目(Vite 等)中运行,可去掉 importmap,改回import ... from 'three/addons/misc/TubePainter.js'即可;若要在本仓库examples/目录下离线运行,则保留 importmap 并保证核心库构建产物就位(参见 package.json 中定义的构建脚本)。

XR 画笔官方示例逐段解读

文档开头提到的"XR painter demo"正是仓库中的 examples/webxr_xr_paint.html,它与 TubePainter 组合使用的方式极具参考价值,核心流程如下。

1. 为左右手各创建一支画笔。每个手柄控制器实例对应一个独立 TubePainter,这样左右手可以同时作画、互不干扰(examples/webxr_xr_paint.html):

const painter1 = new TubePainter(); scene.add( painter1.mesh ); const painter2 = new TubePainter(); scene.add( painter2.mesh );

2. 用select事件模拟"按下/松开"扳机。按下瞬间先同步手柄的世界矩阵,再从手柄前端名为pivot的小标记物体取出世界坐标作为落笔点,调用painter.moveTo( cursor );松开时仅把状态位复位(examples/webxr_xr_paint.html)。

3. 用squeeze(握紧)事件调节笔刷粗细。握住手柄上下移动时,根据手柄位移增量缩放笔尖标记,并同步把缩放值传给painter.setSize(),同时限制最小值为0.1(examples/webxr_xr_paint.html、[L180-L188])。

4. 动画循环里"边移动边画"。这是最有代表性的模式——每帧处理每个手柄:若处于按压状态,就依据当前时间生成连续变化的 HSL 色调用setColor上色,把笔尖pivot的世界坐标作为目标调用lineTo,最后立刻update()提交(examples/webxr_xr_paint.html):

if ( userData.isSelecting === true ) { const hue = ( performance.now() * 0.001 ) % 1; color.setHSL( hue, 1.0, 0.5 ); painter.setColor( color ); painter.lineTo( cursor ); painter.update(); }

配合renderer.setAnimationLoop( animate )与renderer.xr.enabled = true,这段代码构成了完整的"VR 彩虹画笔":笔迹颜色随时间流动、粗细可握持调节、笔头始终是封闭圆头。pivot只是一个很小的二十面体网格(examples/webxr_xr_paint.html),用于在控制器前端提供可视化的"笔尖位置",并作为lineTo的世界坐标来源。

使用建议与注意事项

  • 容量有上限:单实例预分配约 100 万顶点,写入超过数组长度会越界;超长笔迹应拆分为多个 TubePainter 实例或周期性"封笔换新"。
  • 先moveTo再lineTo:新落笔必须先用moveTo设置起点,否则首次lineTo会从原点附近开始;每次moveTo后第一条lineTo才会生成起始封盖。
  • lineTo之后必须update():前者只写 CPU 缓冲,后者才触发 GPU 上传;官方示例中两者成对出现。
  • 颜色 / 粗细是"面向未来"的:setColor与setSize只影响随后追加的管段,修改前已画好的笔迹不受影响,适合做逐段渐变而非整体改色。
  • 关闭视锥剔除:内部已将mesh.frustumCulled = false,请勿在外部重新开启,否则笔迹延伸出包围体后可能被误剔除。
  • 材质为 MeshStandardMaterial:需要光照环境才能呈现立体感,示例中搭配了半球光与平行光;若做纯色扁平风格可另行更换材质并保持vertexColors: true。
  • 使用前确保三点共线之外的状态正确:连续调用lineTo时若目标点与当前点重合,内部会直接跳过,不会产生零长度段。

相关文件速查

用途仓库路径
TubePainter 完整源码(本文全部行号引用均指向此文件)examples/jsm/misc/TubePainter.js
XR 画笔官方示例(含左右手、粗细与颜色控制的完整集成)examples/webxr_xr_paint.html
本 API 参考文档源(Markdown)docs/pages/TubePainter.html.md
本 API 参考文档(HTML)docs/pages/TubePainter.html
addon 聚合导出(TubePainter 被统一收录于此)examples/jsm/Addons.js
npm 包导出映射(three/addons/*→examples/jsm/*)package.json

【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询