three.js Curve 基类详解:参数化曲线插值、弧长等距采样与 Frenet 标架完整指南
本篇围绕 three.js 的抽象基类 Curve 展开,系统讲解其插值模型(t 与 u 双参数体系)、弧长缓存机制、Frenet 标架生成以及序列化接口,并结合 src/extras/core/Curve.js 的源码实现深入剖析等距采样的底层原理。读完后,你将掌握如何在 three.js 中正确采样曲线、处理曲线参数变更、以及在 TubeGeometry/ExtrudeGeometry 等场景中正确使用 Frenet 标架。
Curve 是什么:抽象基类的定位
Curve 是用于创建解析曲线(analytic curve)对象的抽象基类,它提供了一整套插值方法(参见 Curve 官方 API 文档)。它本身不可直接实例化使用——源码中 getPoint() 的默认实现只会输出一条警告:
// src/extras/core/Curve.js#L68-L72
getPoint( /* t, optionalTarget */ ) {
warn( 'Curve: .getPoint() not implemented.' );
}
真正可用的曲线是其子类,全部位于 src/extras/curves/ 目录下,包括:
LineCurve/LineCurve3:直线段(2D/3D)QuadraticBezierCurve/QuadraticBezierCurve3:二次贝塞尔曲线CubicBezierCurve/CubicBezierCurve3:三次贝塞尔曲线SplineCurve:二维样条CatmullRomCurve3:三维 Catmull-Rom 样条EllipseCurve/ArcCurve:椭圆弧与圆弧
Curve 的职责是提供与具体曲线方程无关的通用能力:弧长计算、等距采样、切线求取、Frenet 标架、克隆与序列化。具体曲线只需实现 getPoint(t)(部分曲线还覆写 getTangent 提供精确导数)。
构造函数与核心属性
new Curve() 是抽象构造函数,初始化四个关键成员(Curve.js):
.type(只读,string)
构造函数中固定为 'Curve',子类会覆写为自己的类型名(如 CurvePath 子类设为 'CurvePath')。该属性用于序列化/反序列化时识别对象类型:
// src/extras/core/Curve.js#L27
this.type = 'Curve';
.arcLengthDivisions(number,默认 200)
决定 getLengths() 计算累计弧长时对整个曲线的分段数。官方文档明确建议:如果曲线很大,应增大该值以保证 getSpacedPoints 等方法的精度。因为弧长表是用弦长(相邻采样点直线距离)累加近似得到的,分段越多,近似越贴近真实弧长。
.needsUpdate(boolean,默认 false)
曲线参数发生变化后必须将其设为 true,以强制弧长缓存失效。源码中的缓存命中条件直接依赖此标志(见下文弧长一节)。
cacheArcLengths(内部私有缓存)
文档未列出但源码中确实存在的关键成员(Curve.js#L48-L55):
this.cacheArcLengths = null; // 预计算弧长的内部缓存
它保存 getLengths() 的结果,是 getUtoTmapping 等方法的性能基础。
两套采样模型:参数 t 与弧长参数 u
理解 Curve 最重要的概念是区分两种参数:
- t(参数域):
[0, 1]区间内均匀取值,但曲线上的物理间距不一定均匀。getPoint(t)由子类实现具体插值公式。 - u(弧长域):同样取
[0, 1],但对应曲线上等距离的位置。所有*At后缀方法都基于 u。
.getPoint(t, optionalTarget)(抽象方法)
返回给定插值因子处的 2D/3D 向量(取决于曲线定义),t 必须位于 [0, 1];optionalTarget 可选,指定后结果写入该向量,避免重复分配对象,适合在渲染循环中高频调用。
.getPointAt(u, optionalTarget)
与 getPoint 的语义区别在于尊重曲线长度:
// src/extras/core/Curve.js#L83-L88
getPointAt( u, optionalTarget ) {
const t = this.getUtoTmapping( u );
return this.getPoint( t, optionalTarget );
}
即先通过 getUtoTmapping 把弧长参数 u 换算成参数 t,再调用 getPoint。
.getPoints(divisions) 与 .getSpacedPoints(divisions)
两者都在整条曲线上采样,返回 divisions + 1 个点(含首尾),默认 divisions = 5:
| 方法 | 底层采样 | 点间距特征 |
|---|---|---|
getPoints(divisions = 5) |
逐点调用 getPoint(d / divisions) |
参数域均匀,物理间距不均匀 |
getSpacedPoints(divisions = 5) |
逐点调用 getPointAt(d / divisions) |
物理距离近似相等 |
// src/extras/core/Curve.js#L97-L109(getPoints 核心循环)
getPoints( divisions = 5 ) {
const points = [];
for ( let d = 0; d <= divisions; d ++ ) {
points.push( this.getPoint( d / divisions ) );
}
return points;
}
典型应用:getPoints 常用来生成曲线形状(如 Shape 的轮廓点、TubeGeometry 沿路径的截面采样);getSpacedPoints 用于需要在曲线上均匀布点/布线的场景(如沿路径等距摆放灯光或标记物)。
弧长体系:getLength、getLengths 与缓存失效
.getLength()
返回曲线总弧长,实现非常简洁——取累计弧长数组的最后一个元素:
// src/extras/core/Curve.js#L140-L145
getLength() {
const lengths = this.getLengths();
return lengths[ lengths.length - 1 ];
}
.getLengths(divisions = this.arcLengthDivisions)
返回累计弧长数组(长度 divisions + 1,首元素为 0),实现采用"相邻弦长累加"的数值近似:
// src/extras/core/Curve.js#L153-L184(节选)
getLengths( divisions = this.arcLengthDivisions ) {
if ( this.cacheArcLengths &&
( this.cacheArcLengths.length === divisions + 1 ) &&
! this.needsUpdate ) {
return this.cacheArcLengths; // 缓存命中,直接返回
}
this.needsUpdate = false;
const cache = [];
let current, last = this.getPoint( 0 );
let sum = 0;
cache.push( 0 );
for ( let p = 1; p <= divisions; p ++ ) {
current = this.getPoint( p / divisions );
sum += current.distanceTo( last );
cache.push( sum );
last = current;
}
this.cacheArcLengths = cache;
return cache;
}
从源码结构看有三点值得注意:
- 缓存命中三条件:已有缓存、分段数匹配、且
needsUpdate为false。任一条不满足就重新计算; - 缓存重建后会自动把
needsUpdate置回false(L163),因此手动修改曲线参数后只需置一次true; - 弧长是弦长累加的近似值,精度由
arcLengthDivisions控制——这就是官方文档"曲线很大时增大arcLengthDivisions"建议的由来。
.updateArcLengths()
官方文档强调:曲线参数每次变化后都必须调用它。源码实现为"置脏 + 立即重算":
// src/extras/core/Curve.js#L192-L197
updateArcLengths() {
this.needsUpdate = true;
this.getLengths();
}
还有一个容易被忽略的约束:如果变更后的曲线是 CurvePath 等组合曲线的一部分,必须同时对组合曲线调用 updateArcLengths(),否则外层曲线持有的弧长表仍是旧值。这一约定在 src/extras/core/CurvePath.js 的 getPoint 实现中得到印证——它先按 t * this.getLength() 把全局参数换算为路径距离,再在子曲线中查找对应位置,完全依赖组合曲线自身的弧长缓存。
等距采样核心:getUtoTmapping 的查表与内插
getUtoTmapping(u, distance = null) 是把弧长参数换算回参数 t 的核心方法。给定 u ∈ [0, 1],它返回一个可直接用于 getPoint 的 t;distance 参数可选,用于"沿曲线前进指定距离"这类需求(此时目标弧长直接取 distance 而非 u * 总长)。
实现分两步(Curve.js#L208-L281):
第一步:二分查找定位弧长区间。 在单调递增的弧长表中做经典二分,找到"最大且不超过目标弧长"的下标 i:
// src/extras/core/Curve.js#L229-L256(节选)
let low = 0, high = il - 1, comparison;
while ( low <= high ) {
i = Math.floor( low + ( high - low ) / 2 );
comparison = arcLengths[ i ] - targetArcLength;
if ( comparison < 0 ) {
low = i + 1;
} else if ( comparison > 0 ) {
high = i - 1;
} else {
high = i;
break;
}
}
i = high;
第二步:区间内线性内插。 若恰好命中某个采样点则直接返回 i / (il - 1);否则计算目标弧长在 [arcLengths[i], arcLengths[i+1]] 段内的比例 segmentFraction,再折算回参数域:
// src/extras/core/Curve.js#L266-L279(节选)
const lengthBefore = arcLengths[ i ];
const lengthAfter = arcLengths[ i + 1 ];
const segmentLength = lengthAfter - lengthBefore;
const segmentFraction = ( targetArcLength - lengthBefore ) / segmentLength;
const t = ( i + segmentFraction ) / ( il - 1 );
return t;
这说明"等距"是基于弧长表的分段线性近似:arcLengthDivisions 越大,弧长表越密,getPointAt/getTangentAt/getSpacedPoints 的等距精度越高。
切线:getTangent 与 getTangentAt
.getTangent(t, optionalTarget)
返回指定参数处的单位切向量。文档说明:若派生曲线没有实现解析导数,基类会用两个相距很小 δ 的点求梯度作为近似——从源码看实现即为中心差分 + 端点钳制:
// src/extras/core/Curve.js#L293-L313
getTangent( t, optionalTarget ) {
const delta = 0.0001;
let t1 = t - delta;
let t2 = t + delta;
// Capping in case of danger
if ( t1 < 0 ) t1 = 0;
if ( t2 > 1 ) t2 = 1;
const pt1 = this.getPoint( t1 );
const pt2 = this.getPoint( t2 );
const tangent = optionalTarget || ( ( pt1.isVector2 ) ? new Vector2() : new Vector3() );
tangent.copy( pt2 ).sub( pt1 ).normalize();
return tangent;
}
两个工程细节:δ 固定为 0.0001;在不提供 optionalTarget 时,会根据 getPoint 返回的是 Vector2 还是 Vector3 自动选择目标向量类型,因此 2D/3D 曲线都能正确工作。
.getTangentAt(u, optionalTarget)
与 getPointAt 同理,先做 u→t 换算再求切线,得到的是弧长域等距采样下的切线:
// src/extras/core/Curve.js#L323-L328
getTangentAt( u, optionalTarget ) {
const t = this.getUtoTmapping( u );
return this.getTangent( t, optionalTarget );
}
computeFrenetFrames:为扫掠几何体提供局部坐标系
.computeFrenetFrames(segments, closed = false)
在 3D 曲线上生成 Frenet 标架(每段一个切向量、法向量、副法向量三元组),是 TubeGeometry 与 ExtrudeGeometry 这类"沿曲线扫掠截面"几何体的基础。参数:
- segments(number):分段数;
- closed(boolean,默认
false):曲线是否闭合。
Returns:{ tangents, normals, binormals } 三个 Vector3 数组。
实现遵循经典的"平行传输"思路(Curve.js#L338-L450,源码注释引用了 Indiana 大学技术报告 TR425):
- 逐段求切线:对每个
u = i / segments调用getTangentAt,因此同样受益于弧长等距采样; - 选定初始法向量:取第一个切向量中绝对值最小的分量所对应的坐标轴方向作为参考,
cross两次得到与切线正交的初始法向量——这种"避开平行方向"的选取策略避免了一般化法向量选择中的数值退化; - 沿曲线传播:相邻切向量的叉积给出旋转轴,
acos(t1·t2)给出旋转角 θ,用旋转矩阵makeRotationAxis把上一段的法向量旋转到当前段,从而让标架"平滑演化"而不会随切线微小抖动而翻转; - 闭合后处理:当
closed === true时,把首尾法向量之间的夹角均摊到每一段,对法向量做微量渐进旋转("twist a little"),保证首尾法向量一致,闭合管状物没有接缝扭曲。
调用示例:
import * as THREE from 'three';
const curve = new THREE.CatmullRomCurve3( [
new THREE.Vector3( - 10, 0, 0 ),
new THREE.Vector3( 0, 5, 0 ),
new THREE.Vector3( 10, 0, 0 )
] );
const frames = curve.computeFrenetFrames( 32, false );
console.log( frames.tangents.length ); // 33 个切向量(segments + 1)
克隆、拷贝与序列化
| 方法 | 说明 | 返回 |
|---|---|---|
.clone() |
返回复制了本实例数值的新曲线 | 该实例的克隆 |
.copy(source) |
把给定曲线的数值复制到当前实例 | 指向当前曲线的引用 |
.toJSON() |
序列化为 JSON | 表示序列化曲线的 JSON 对象 |
.fromJSON(json) |
从 JSON 反序列化 | 指向当前曲线的引用 |
源码实现揭示了各子类序列化协作的方式(Curve.js#L457-L512):
clone() {
return new this.constructor().copy( this );
}
copy( source ) {
this.arcLengthDivisions = source.arcLengthDivisions;
return this;
}
toJSON() {
const data = {
metadata: { version: 4.7, type: 'Curve', generator: 'Curve.toJSON' }
};
data.arcLengthDivisions = this.arcLengthDivisions;
data.type = this.type; // 反序列化时据此识别曲线类型
return data;
}
fromJSON( json ) {
this.arcLengthDivisions = json.arcLengthDivisions;
return this;
}
clone()通过new this.constructor().copy(this)实现,因此子类只要覆写copy补充自身字段(如控制点数组),克隆即可完整工作;toJSON()的metadata.type与data.type正是文档所说"用于序列化/反序列化时识别对象类型"的机制,具体曲线的控制点由子类追加;ObjectLoader的parse流程在加载 JSON 场景时会按type字段选择对应曲线类实例化。
组合曲线:CurvePath 与缓存维护
Curve 的直接扩展是 src/extras/core/CurvePath.js:把多条曲线串联为一条路径,同时保留完整 Curve API。它覆写 getPoint 的策略是:
// src/extras/core/CurvePath.js#L81-L116(节选)
getPoint( t, optionalTarget ) {
const d = t * this.getLength();
const curveLengths = this.getCurveLengths();
// 找到 d 落在哪条子曲线上,换算出该子曲线的局部 u
while ( i < curveLengths.length ) {
if ( curveLengths[ i ] >= d ) {
const segmentLength = curve.getLength();
const u = segmentLength === 0 ? 0 : 1 - diff / segmentLength;
return curve.getPointAt( u, optionalTarget );
}
i ++;
}
return null;
}
即"全局距离 → 定位子曲线 → 局部弧长参数"。这再次说明 updateArcLengths 在组合场景下必须逐级维护的原因。
实战示例与测试验证
仓库中的真实示例展示了这套 API 的组合用法。以 examples/webgl_geometry_extrude_splines.html 为例:用 CatmullRomCurve3 定义管道中心线,在着色器/几何逻辑中直接调用 path.getPointAt(t, position) 取等距位置,并用 ( t + offset / path.getLength() ) % 1 计算"沿路径前进一段固定距离"的视点位置——getPointAt 与 getLength 配合正是 getUtoTmapping(distance) 能力的宏观体现。
实现正确性由单元测试覆盖,测试入口在 test/unit/three.source.unit.js 中统一导入,相关测试文件包括:
- test/unit/src/extras/core/Curve.tests.js
- test/unit/src/extras/core/CurvePath.tests.js
- 各具体曲线测试:
src/extras/curves/下的CatmullRomCurve3、CubicBezierCurve、EllipseCurve、LineCurve3等(如 test/unit/src/extras/curves/CatmullRomCurve3.tests.js)
要点小结
- 区分 t 与 u:
getPoint/getTangent/getPoints工作在参数域,getPointAt/getTangentAt/getSpacedPoints工作在弧长域,需要物理等距时一律用*At系列; - 参数变更后的标准动作:修改曲线参数 → 调用
updateArcLengths()(或置needsUpdate = true);若曲线嵌在CurvePath中,外层也要更新; - 精度旋钮:
arcLengthDivisions(默认 200)控制弧长表密度,大尺度或高精度等距场景应适当调大; - 性能提示:渲染循环中高频采样时传入
optionalTarget复用向量,可避免每帧分配; - 扩展方式:自定义曲线继承
Curve并实现getPoint(可选覆写getTangent提供精确导数),即可自动获得弧长、等距采样、Frenet 标架与序列化全部能力。
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