首页
/ three.js Flow 曲线弯曲修改器完全指南:让网格沿曲线流动的 CurveModifier 详解

three.js Flow 曲线弯曲修改器完全指南:让网格沿曲线流动的 CurveModifier 详解

2026-09-06 19:02:21作者:宣海椒Queenly

导读Flow 是 three.js 官方提供在 three/addons/modifiers/CurveModifier.js 中的修改器(modifier),用于让网格(Mesh)沿着任意曲线“弯曲”与“流动”,是制作管道沿轨动画、路径游走文字、赛道形变等效果的核心工具。本文将基于官方文档 docs/pages/Flow.html 梳理 Flow 的构造函数、moveAlongCurveupdateCurve 等 API,并深入源码与示例,剖析其背后的“样条纹理 + 顶点着色器”实现原理、WebGL/WebGPU 双后端差异,以及面向大批量对象的 InstancedFlow 实例化用法,帮助你写出一段可直接运行沿曲线弯曲的网格动画。

Flow 是什么

Flow 是一个顶点层面的曲线弯曲修改器。给定一个普通的三维网格(例如一段文字、一条管道模型),它可以克隆该网格并把它的顶点重新映射到一条曲线的局部坐标系上,从而让整段网格在三维空间里“贴上”曲线并保持法线、朝向正确。它还可以随时间推进让网格沿着曲线持续“流动”(flushing),效果类似传送带或血液在血管中流动的动画。

其源码位于 examples/jsm/modifiers/CurveModifier.js,类注释开头的原始出处来自社区项目 zz85/threejs-path-flow,three.js 将其收录并演化为官方 addon 模块。

从源码结构来看,整个模块可以拆成三部分协同工作:

  1. CPU 端曲线预处理:把 Curve 离散采样,连同其 Frenet 标架(切向量、法向量、副法向量)编码进一张数据纹理(DataTexture),供着色器查询;
  2. 着色器改写:通过 material.onBeforeCompile 把标准顶点着色器替换为“沿曲线重定位顶点”的实现;
  3. 对象封装Flow(单网格)与 InstancedFlow(实例化网格)对外暴露简单 API,隐藏纹理与矩阵细节。

渲染后端约束:WebGL 与 WebGPU

Flow 的实现依赖对材质着色器源码进行字符串级改写,因此与渲染后端强相关:

  • WebGLRenderer:使用 CurveModifier.js,内部通过 material.onBeforeCompile 直接改写 GLSL 顶点着色器;
  • WebGPURenderer:应导入 CurveModifierGPU.js 中的同名 Flow,其内部改用 TSL(Three Shading Language)的 positionNode / normalNode 以节点方式挂接重定位逻辑,不再触碰 GLSL 字符串。

官方文档对此有明确说明:“This module can only be used with WebGLRenderer. When using WebGPURenderer, import the class from CurveModifierGPU.js.”,InstancedFlow 类同样声明“只能用于 WebGLRenderer”。这意味着若你的项目运行在 WebGPU 后端,切换曲线弯曲能力时必须同步切换 import 来源,两套 API 方法名保持一致,便于无缝替换。

导入方式

Flow 属于 addon 模块,必须显式导入(详见官方 Installation#Addons 说明),不会自动包含在核心 three 包中:

import { Flow } from 'three/addons/modifiers/CurveModifier.js';

在 WebGPU 场景下改为:

import { Flow } from 'three/addons/modifiers/CurveModifierGPU.js';

若需要批量实例化版本,则从同一文件导入 InstancedFlow

import { InstancedFlow } from 'three/addons/modifiers/CurveModifier.js';

构造函数与参数

new Flow( mesh : Mesh, numberOfCurves : number )

构造一个新的 Flow 实例,mesh 是被克隆并沿曲线弯曲的原始网格,numberOfCurves 是为将来追加更多曲线预分配的空间数量,默认值为 1

重要语义:Flow 并不修改你传入的网格本身。从 CurveModifier.js 的构造实现可见,它先执行 mesh.clone() 得到 obj3D,随后遍历克隆体的所有后代:

  • 对每个 Mesh / InstancedMesh 子节点,若 material 是数组(多材质),则逐份 clone() 一份新材质并改写其着色器;否则克隆单一材质;
  • 材质改写通过 modifyShader( newMaterial, uniforms, numberOfCurves ) 完成;
  • 最后在实例上保存 curveArraycurveLengthArrayobject3DsplineTextureuniforms 等内部状态。

因此使用时应把 flow.object3D 加入场景而不是原来的 mesh,且原始网格不受污染,可重复用于多个 Flow。

构造函数执行后的内部产物:

内部属性 含义
object3D 克隆并“改性”后的场景对象,加入场景使用
splineTexture 存储曲线采样点及 Frenet 标架的数据纹理
uniforms 供着色器使用的统一变量(见下文)
curveArray / curveLengthArray 各曲线槽位对应的曲线对象与总弧长

new InstancedFlow( count, curveCount, geometry, material )

InstancedFlow 继承自 Flow,是实例化版本,适合把大量对象沿同一条或多条曲线排布与运动。构造参数:

  • count:实例元素数量;
  • curveCount:要预分配的曲线槽位数量;
  • geometry:实例网格使用的几何体;
  • material:实例网格使用的材质。

其构造实现先内部创建一个 InstancedMesh( geometry, material, count ),并做两项关键设置:instanceMatrix.setUsage( DynamicDrawUsage )(矩阵需逐帧动态更新)与 mesh.frustumCulled = false(顶点会在着色器中被重新定位,剔除包围盒不可靠,故禁用视锥剔除),随后才调用父类构造函数完成曲线纹理与材质初始化。

核心方法

.moveAlongCurve( amount : number )

沿曲线移动网格,amount 是推进偏移量。该方法实现非常简洁(CurveModifier.js):

moveAlongCurve( amount ) {
	this.uniforms.pathOffset.value += amount;
}

它只是累加 pathOffset uniform 的值。在每帧渲染循环中持续传入一个很小的增量即可产生“流动”动画,例如官方示例每帧执行 flow.moveAlongCurve( 0.001 )

需要说明的是:moveAlongCurve 的推进量并非严格的世界空间距离,而是与 pathSegment、曲线离散采样密度共同作用在纹理纵坐标上的增量,使用时通过视觉反馈调参即可。

.updateCurve( index : number, curve : Curve )

为给定的曲线槽位 index 更新曲线,curve 是用于弯曲网格的 Curve(例如 CatmullRomCurve3CubicBezierCurve3)。该方法内部(CurveModifier.js)做四件事:

  1. 越界检查:if ( index >= this.curveArray.length ) throw Error( 'Flow: Index out of range.' )
  2. 计算弧长:const curveLength = curve.getLength()
  3. 同步 spineLength uniform 与 curveLengthArray[index]curveArray[index]
  4. 调用 updateSplineTexture( this.splineTexture, curve, index ) 把曲线重新编码进数据纹理。

当交互式拖拽样条控制点、曲线几何发生变化后,应重新调用该方法让弯曲结果更新,官方示例正是在 TransformControlsdragging-changed 事件回调里调用 flow.updateCurve( 0, curve )

源码深潜:曲线纹理与着色器如何工作

理解 Flow 的关键,是知道曲线不是被 CPU 逐帧计算的,而是被“烘焙”进纹理由 GPU 读取。

样条数据纹理的编码

模块顶部定义了纹理布局常量:

const CHANNELS = 4;
const TEXTURE_WIDTH = 1024;
const TEXTURE_HEIGHT = 4;

initSplineTextureCurveModifier.js)为每条曲线分配一块 1024 × 4DataTexture(实际高度按 TEXTURE_HEIGHT * numberOfCurves 增长),格式为 RGBAFormat + HalfFloatType,数据数组按 Uint16Array 分配以承载半浮点。纹理环绕模式均设为 RepeatWrapping,过滤模式为 LinearFilter

写入时 updateSplineTextureCurveModifier.js)采样 numberOfPoints = 1024 个均匀弧长点:

  • 通过 curve.arcLengthDivisionsupdateArcLengths() 预计算弧长表,保证按“弧长”均匀采样;
  • getSpacedPoints 获取采样点坐标;
  • computeFrenetFrames( numberOfPoints, true ) 计算每一点的 Frenet 标架;
  • 数据纹理被组织为4 个通道行:第 0 行存顶点坐标,第 1/2/3 行分别存该点的切向量 tangent、法向量 normal、副法向量 binormal,三者构成局部正交基。

顶点着色器中的弯曲重定位

改写后的顶点着色器核心逻辑(CurveModifier.js)思路如下:

  1. 先把顶点变换到世界空间:worldPos = modelMatrix * vec4(position, 1.)
  2. 依据顶点在世界空间下的 x 坐标推算出它落在曲线的哪个“进度”(spine portion):spinePortion = (worldPos.x + spineOffset) / spineLength;当 flow <= 0 时不弯曲(bend = false),并且该顶点 x 权重 xWeight = 1,即仍保留原始 x 偏移;
  3. 把进度换算为纹理采样纵坐标 mt,并对 textureStacks(值为 1,即 TEXTURE_HEIGHT / 4)取模以支持闭合曲线循环;
  4. 从样条纹理中取出该点的位置 spinePos 与局部基向量 a/b/c,构成 mat3 basis
  5. 最终 transformed = basis * vec3(worldPos.x * xWeight, worldPos.y, worldPos.z) + spinePos,即“网格截面相对曲线位置 + 曲线绝对位置”,顶点法线也随之用 basis 旋转:transformedNormal = normalMatrix * (basis * objectNormal)

与此同时,GLSL 片段中还注入了 5 个 uniform(见 getUniforms):

uniform 默认值 作用
spineTexture 样条数据纹理 保存曲线采样与 Frenet 标架
pathOffset 0 沿路径的时间/偏移量,moveAlongCurve 只改它
pathSegment 1 路径覆盖的分数长度(1 表示使用整条曲线)
spineOffset 161 网格“吸附”到曲线的起始世界 X 偏移
spineLength 400 曲线总长,由 updateCurve 自动同步为 curve.getLength()

其中 spineOffsetspineLength 等默认值对应社区原始示例的默认场景尺寸。当网格几何体在世界 X 方向从 0 到某长度伸展时,弯曲会以 spineOffset 为起点、沿 spineLength 跨度贴合曲线;因此把被弯曲网格设计为沿 X 轴延伸(官方示例的 TextGeometry 即如此)是与 Flow 配合的隐含约定。

WebGPU 版实现差异

CurveModifierGPU.js 在 CPU 端纹理编码、构造流程上与 WebGL 版几乎一致,区别在着色器接入方式:

  • 不再改写 GLSL 字符串,而是通过 material.positionNode 挂接一个 TSL Fn,内部用 modelWorldMatrixpositionLocalreference( 'pathOffset', 'float', uniforms )texture( spineTexture, ... ) 等 TSL 节点复刻同样的弯曲公式;
  • 额外声明 varyingProperty( 'vec3', 'curveNormal' ) 并在 material.normalNode 输出,从而在节点系统内保持法线弯曲一致;
  • flow uniform 在 TSL 版中被声明为 float(通过 reference),与 GLSL 版的 int flow 取值判断略有差异,但 API 层对使用者透明。

完整示例:一段沿曲线流动的文字

官方示例 webgl_modifier_curve.html 展示了 Flow 的最小可用套路。剥离交互与字体加载,核心链路如下:

// 1. 准备曲线
const curve = new THREE.CatmullRomCurve3( handlePoints ); // handlePoints 为控制点数组
curve.curveType = 'centripetal';
curve.closed = true;

// 2. 构造将被弯曲的网格(文字几何体 / 任意网格均可)
const geometry = new TextGeometry( 'Hello three.js!', { font, size: 0.2, depth: 0.05, ... } );
geometry.rotateX( Math.PI );
const material = new THREE.MeshStandardMaterial( { color: 0x99ffff } );
const objectToCurve = new THREE.Mesh( geometry, material );

// 3. 创建 Flow 并把曲线绑定到 0 号槽位
const flow = new Flow( objectToCurve );
flow.updateCurve( 0, curve );
scene.add( flow.object3D ); // 注意加入场景的是 flow.object3D

// 4. 渲染循环中持续推进,产生流动动画
flow.moveAlongCurve( 0.001 );

该示例还额外用 TransformControls 拖拽四个绿色方块控制点来修改 CatmullRomCurve3,并在每次拖拽结束(dragging-changed 且非拖拽中)时重新执行 flow.updateCurve( 0, curve )——这也是曲线交互编辑场景中必须牢记的“曲线变了要重新 updateCurve”惯例。

多实例版本:InstancedFlow 的用法

当对象数量很大时(例如大量文字或粒子排布在曲线上),逐个创建 Mesh 会让 draw call 激增,此时应使用 InstancedFlow。官方示例 webgl_modifier_curve_instanced.html 演示了 8 个实例沿 2 条曲线分布的情形:

const numberOfInstances = 8;
const flow = new InstancedFlow( numberOfInstances, curves.length, geometry, material );

// 先为每条曲线注册槽位
curves.forEach( function ( { curve }, i ) {
	flow.updateCurve( i, curve );
	scene.add( flow.object3D );
} );

// 再为每个实例选择曲线并分配初始位置
for ( let i = 0; i < numberOfInstances; i ++ ) {
	const curveIndex = i % curves.length;
	flow.setCurve( i, curveIndex );
	flow.moveIndividualAlongCurve( i, i * 1 / numberOfInstances );
	flow.object3D.setColorAt( i, new THREE.Color( 0xffffff * Math.random() ) );
}

InstancedFlow 额外提供三个方法(继承自 Flow 的同时复用其曲线纹理机制):

  • .setCurve( index, curveNo ):让第 index 个实例使用第 curveNo 条曲线;
  • .moveIndividualAlongCurve( index, offset ):让第 index 个实例沿它所在的曲线独立推进 offset;
  • .writeChanges( index ):把上述“曲线长度、曲线编号、偏移量”编码进该实例的模型矩阵并标记 instanceMatrix.needsUpdate = true,供着色器中的 USE_INSTANCING 分支读取。

CurveModifier.jswriteChanges 使用一个复用的 Matrix4.makeTranslation(spineLength, whichCurve, offset) 塞进实例矩阵平移分量,而顶点着色器里对应的 #ifdef USE_INSTANCING 分支正是从 instanceMatrix[3][0..2] 取出 spineLength、曲线编号和 pathOffset,从而让每个实例拥有独立的曲线归属与相位(着色器片段)。实例仍可整体使用 flow.moveAlongCurve( amount ) 让所有实例同步前进——示例的动画循环正是这样调用的。

实战要点与注意事项

  • 加入场景的对象是 flow.object3D,不是传入构造函数的原始 mesh;构造函数内部会 clone 原始对象,原始对象不会被破坏。
  • 网格建议沿 X 轴延伸:弯曲映射以世界 X 坐标为“脊柱进度”基准,需要水平延展的几何(文字、长条管道)在 X 方向布局可获得理想效果;spineOffset/spineLength 两个 uniform 可在材质上手动调节弯曲起点与跨度。
  • 曲线改变后必须重新 updateCurve:动态编辑控制点时,在编辑结束的回调里对受影响槽位重放新曲线;越界索引会抛出 Flow: Index out of range.
  • WebGL 与 WebGPU 分别导入:前者用 CurveModifier.js,后者用 CurveModifierGPU.js,实例化时注意 InstancedFlow 目前仅声明支持 WebGLRenderer(对应源码中无 GPU 版 InstancedFlow 导出)。
  • 动态实例记得设置InstancedFlow 内部已自动配置 DynamicDrawUsagefrustumCulled = false;如果你手动构造等价实现,这两项缺一不可,否则实例位置不会随矩阵更新或会在镜头旋转时被错误剔除。
  • 纹理资源开销:每条曲线固定占据约 1024 × 4 的 half-float 数据纹理,numberOfCurves 决定预分配槽位数;大量曲线的场景请按需分配,避免过度预留。

相关资源

结合官方示例直接运行验证,或在自身工程中以“克隆对象 + updateCurve + moveAlongCurve”三步接入 Flow,即可获得基于曲线驱动的低成本网格流动效果。

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