首页
/ three.js CurvePath:将多条曲线组合为单一路径的 API 参考与源码解析

three.js CurvePath:将多条曲线组合为单一路径的 API 参考与源码解析

2026-09-06 17:20:13作者:卓炯娓

CurvePath 是 three.js 曲线系统(Curve system)中的核心组合类:它把多条子曲线(直线、贝塞尔曲线、椭圆弧、样条曲线等)串联成一条路径,同时完整保留父类 Curve 的插值 API,使得整条路径可以像单条曲线一样按参数 t 采样、计算弧长、均匀取点。本文基于官方 API 文档 CurvePath.html.md 与源码 CurvePath.js,逐条讲解其属性、方法与底层实现,帮助你在生成挤出几何体(ExtrudeGeometry)、自定义描边、运动路径等场景中正确使用该类。

一、类的定位与继承关系

官方文档明确给出的继承链为:

Curve → CurvePath

源码中这一点直接体现为类声明(CurvePath.js#L10):

class CurvePath extends Curve {

	constructor() {

		super();

		this.type = 'CurvePath';

		// 定义路径的曲线数组
		this.curves = [];

		// 是否由线段自动闭合路径
		this.autoClose = false;

	}

CurvePath 在库内的地位可以从导出文件 Three.Core.js 得到印证——它与 CurvePath 等一起被打包进核心导出(export { CurvePath } from './extras/core/CurvePath.js')。更重要的是,它是整个 2D 路径工具链的基座:Path.js 中的 Path 类直接继承自 CurvePath,而 ShapeShapePath 又构建在 Path 之上,最终服务于 ShapeUtilsExtrudeGeometry 等几何生成流程。因此理解 CurvePath,就理解了 three.js 中「点集 → 路径 → 形状 → 实体几何」这条链路的起点。

单元测试 CurvePath.tests.js 也验证了继承与实例化行为:

QUnit.test( 'Extending', ( assert ) => {

	const object = new CurvePath();
	assert.strictEqual(
		object instanceof Curve, true,
		'CurvePath extends from Curve'
	);

} );

二、构造器

const path = new THREE.CurvePath();

new CurvePath() 不需要任何参数。它初始化一个空的 curves 数组和一个 type = 'CurvePath' 的序列化标识,并调用父类 Curve 的构造器。父类构造器(Curve.js#L15-L57)还顺带初始化了两个对 CurvePath 同样重要的成员:

  • arcLengthDivisions = 200:父类数值积分弧长时的分段数。虽然 CurvePath 的长度直接由子曲线长度累加得出(见下文),但当子曲线自身参与计算时,该值仍影响精度;
  • needsUpdate = false / cacheArcLengths = null:父类弧长缓存控制开关。

CurvePath 自身则追加了两个专属属性,构成其全部公开状态。

三、属性详解

3.1 .curves : Array<Curve>

定义路径的曲线数组,是 CurvePath 唯一真正的数据载体。所有操作(addclosePathgetCurveLengthstoJSON…)本质上都是在读写这个数组。

一个典型用法是把直线和贝塞尔曲线混排(各曲线类定义在 src/extras/curves/ 目录,由 Curves.js 汇总导出):

const path = new THREE.CurvePath();

path.add( new THREE.LineCurve( new THREE.Vector2( 0, 0 ), new THREE.Vector2( 1, 0 ) ) );
path.add( new THREE.CubicBezierCurve(
	new THREE.Vector2( 1, 0 ),
	new THREE.Vector2( 2, 0.5 ),
	new THREE.Vector2( 2, -0.5 ),
	new THREE.Vector2( 1, -1 )
) );
path.add( new THREE.QuadraticBezierCurve(
	new THREE.Vector2( 1, -1 ),
	new THREE.Vector2( 0.5, -1.5 ),
	new THREE.Vector2( 0, 0 )
) );

注意:2D 曲线(如 CubicBezierCurve)与 3D 曲线(如 CubicBezierCurve3)返回的采样点分别是 Vector2Vector3。从源码实现看,CurvePath 并不强制子曲线维度一致,采样结果的维度完全取决于当前命中的那条子曲线,因此在 2D/3D 曲线混用时调用方需要自行处理维度差异。

3.2 .autoClose : boolean

默认值 false。为 true 时,采样方法会在返回的点序列末尾自动补上起点,使路径在几何意义上闭合。它影响 getSpacedPointsgetPoints 两处输出(CurvePath.js#L189-L193L227-L231):

// getSpacedPoints 中的处理
if ( this.autoClose ) {

	points.push( points[ 0 ] );

}

需要区分两种「闭合」语义:

  • autoClose = true 只是在采样结果上补点curves 数组不变,路径的 getLength() 也不包含隐含的闭合线段;
  • 真正修改路径几何的是 closePath() 方法,它会向 curves 数组中追加一条连接终点与起点的线曲线,闭合部分随后被计入弧长与采样。

autoClose 的典型场景是 Path/Shape 构建轮廓供 ExtrudeGeometry 使用时,轮廓必须首尾相接才能形成封闭截面。

四、方法详解

4.1 .add( curve )

add( curve ) {

	this.curves.push( curve );

}

实现极其直接:把曲线推入 curves 数组(CurvePath.js#L45-L49)。它没有返回值,也不做参数校验,因此传入的必须是 Curve 体系内的曲线对象(即实现了 getPoint 等接口的实例)。

实践提示:add 后如果之前已经调用过 getLength()/getCurveLengths(),内部长度缓存会失效吗?答案是不会自动失效——缓存仅在 cacheLengths.length === curves.length 时复用(见 4.4 节),数组长度变化后缓存自然失效。但若修改的是已存在子曲线的形状参数,则必须手动调用 updateArcLengths()(见 4.5 节)。

4.2 .closePath() : CurvePath

closePath() {

	// Add a line curve if start and end of lines are not connected
	const startPoint = this.curves[ 0 ].getPoint( 0 );
	const endPoint = this.curves[ this.curves.length - 1 ].getPoint( 1 );

	if ( ! startPoint.equals( endPoint ) ) {

		const lineType = ( startPoint.isVector2 === true ) ? 'LineCurve' : 'LineCurve3';
		this.curves.push( new Curves lineType  );

	}

	return this;

}

方法逻辑(CurvePath.js#L56-L71)分三步:

  1. 取首条曲线的起点 curves[0].getPoint(0) 与末条曲线的终点 curves[last].getPoint(1)
  2. 若两点不相等,按起点向量类型选择 LineCurve(2D)或 LineCurve3(3D),构造一条从终点指回起点的线曲线;
  3. 推入 curves 数组并返回 this,支持链式调用。

两个值得注意的实现细节:

  • 如果路径本身已经首尾相连(起点恰好等于终点),方法不会追加任何曲线,直接返回;
  • 闭合判定使用 equals 精确比较 Vector2/Vector3,浮点场景下两条曲线端点存在微小误差时仍会追加一条极短线段——这通常无害,但说明「已经闭合」的判断对调用方的数据精度敏感。

4.3 .getPoint( t, optionalTarget ) : Vector2 | Vector3

这是 CurvePath 对父类抽象方法 Curve#getPoint 的核心重写(CurvePath.js#L81-L120)。参数说明:

  • t : number — 沿整条路径的位置参数,取值 [0, 1]
  • optionalTarget : Vector2 | Vector3 — 可选,结果写入该向量以复用内存;
  • 返回 : 2D 或 3D 向量,取决于当前命中的子曲线定义。

实现遵循源码注释中写明的四步算法:

getPoint( t, optionalTarget ) {

	// 1. Length of each sub path have to be known
	// 2. Locate and identify type of curve
	// 3. Get t for the curve
	// 4. Return curve.getPointAt(t')

	const d = t * this.getLength();
	const curveLengths = this.getCurveLengths();
	let i = 0;

	while ( i < curveLengths.length ) {

		if ( curveLengths[ i ] >= d ) {

			const diff = curveLengths[ i ] - d;
			const curve = this.curves[ i ];

			const segmentLength = curve.getLength();
			const u = segmentLength === 0 ? 0 : 1 - diff / segmentLength;

			return curve.getPointAt( u, optionalTarget );

		}

		i ++;

	}

	return null;

}

其工作机理是基于弧长的全局参数化

  1. d = t * this.getLength():把全局参数 t 换算成沿路径的目标弧长 d
  2. getCurveLengths() 返回各子曲线长度的前缀和数组,线性扫描找到第一个 curveLengths[i] >= d 的分段,确定 d 落在第 i 条子曲线上;
  3. diff = curveLengths[i] - d 是该子曲线内部尚未走完的剩余弧长,因此局部参数 u = 1 - diff / segmentLength 恰好把全局进度换算为该子曲线的局部进度(segmentLength 为 0 时直接取 u = 0 防止除零);
  4. 调用 curve.getPointAt( u ) 而非 getPoint( u )——这一步很关键:getPointAt 会在子曲线内部再做一次弧长重参数化,从而保证沿整条路径的采样是弧长均匀的,而不是各子曲线参数空间的拼接。

一个容易忽略的边界情况:若 t * totalLength 因浮点误差略大于前缀和末尾(理论上 t <= 1 时不会越过末位),循环走完会返回 null。实际调用中把 t 限制在 [0, 1] 内即可规避。

4.4 .getCurveLengths() : Array<number>

返回各子曲线长度的累积数组(CurvePath.js#L148-L177):

getCurveLengths() {

	// cacheLengths must be recalculated when curve count changes
	if ( this.cacheLengths && this.cacheLengths.length === this.curves.length ) {

		return this.cacheLengths;

	}

	const lengths = [];
	let sums = 0;

	for ( let i = 0, l = this.curves.length; i < l; i ++ ) {

		sums += this.curves[ i ].getLength();
		lengths.push( sums );

	}

	this.cacheLengths = lengths;

	return lengths;

}

要点:

  • 返回数组的第 i 项是「从路径起点到第 i 条子曲线终点」的累计弧长,末位元素即整条路径总长;
  • 缓存策略是按数组长度匹配cacheLengths.length === curves.length 时直接复用。因此 add 一条曲线后长度变化会使旧缓存作废;
  • 源码注释特意说明不能覆盖父类的 getLengths(),因为 Curve#getUtoTmapping 依赖它,二者各司其职。

4.5 getLength()updateArcLengths()

文档页面未单列、但源码中存在且与 getPoint 强相关的两个方法:

getLength() {

	// We cannot use the default THREE.Curve getPoint() with getLength() because in
	// THREE.Curve, getLength() depends on getPoint() but in THREE.CurvePath
	// getPoint() depends on getLength

	const lens = this.getCurveLengths();
	return lens[ lens.length - 1 ];

}

updateArcLengths() {

	// cacheLengths must be recalculated.

	this.needsUpdate = true;
	this.cacheLengths = null;
	this.getCurveLengths();

}

源码注释点明了一个设计上的鸡生蛋问题:父类 CurvegetLength() 依赖 getPoint()(数值积分采样),而 CurvePathgetPoint() 又依赖 getLength()(用于换算弧长)。CurvePath 的解法是让 getLength() 直接取子曲线前缀和的末位值,绕开对 getPoint() 的依赖,从而打破循环。

相应地,updateArcLengths() 被重写为「置位 needsUpdate、清空 cacheLengths、重算一次 getCurveLengths()」。父类 Curve.js#L186-L197 的文档注释也明确要求:当某条子曲线的参数被修改后,除了对该子曲线调用 updateArcLengths(),还必须对所属的 CurvePath 调用一次,否则路径级缓存与全局 t → 弧长的换算会基于过期数据。

4.6 采样序列:getPoints()getSpacedPoints()

CurvePath 重写了这两个方法(继承自父类的同名方法仅对单条曲线做均匀 t 采样):

getSpacedPoints( divisions = 40 ) {

	const points = [];

	for ( let i = 0; i <= divisions; i ++ ) {

		points.push( this.getPoint( i / divisions ) );

	}

	if ( this.autoClose ) {

		points.push( points[ 0 ] );

	}

	return points;

}
  • getSpacedPoints(divisions = 40):沿整条路径按弧长均匀divisions + 1 个点(内部走 getPoint,即弧长参数化);autoClosetrue 时末尾补一个起点。注意默认分段数是 40,与父类 Curve#getSpacedPoints 的默认值 5 不同;
  • getPoints(divisions = 12)CurvePath.js#L199-L235):按子曲线分段采样,并根据曲线类型自适应调整每条子曲线的分辨率——
const resolution = curve.isEllipseCurve ? divisions * 2
	: ( curve.isLineCurve || curve.isLineCurve3 ) ? 1
		: curve.isSplineCurve ? divisions * curve.points.length
			: divisions;

即椭圆/圆弧曲线用 2×divisions(圆弧曲率高),直线只用 1 段(两段端点即可精确表示),样条曲线按 divisions × 控制点数 加密,其余默认 divisions。此外它会跳过与前一点重合的相邻重复点(if ( last && last.equals( point ) ) continue;),避免路径拼接点产生冗余顶点——这对后续生成 Line/ShapeGeometry 的顶点数据是实际收益。

4.7 序列化:copy() / toJSON() / fromJSON()

toJSON() {

	const data = super.toJSON();

	data.autoClose = this.autoClose;
	data.curves = [];

	for ( let i = 0, l = this.curves.length; i < l; i ++ ) {

		const curve = this.curves[ i ];
		data.curves.push( curve.toJSON() );

	}

	return data;

}

fromJSON( json ) {

	super.fromJSON( json );

	this.autoClose = json.autoClose;
	this.curves = [];

	for ( let i = 0, l = json.curves.length; i < l; i ++ ) {

		const curve = json.curves[ i ];
		this.curves.push( new Curves[ curve.type ]().fromJSON( curve ) );

	}

	return this;

}
  • toJSON 输出 { type: 'CurvePath', autoClose, curves: [...] },每条子曲线以自身 toJSON 结果内嵌;
  • fromJSON 依据每条子曲线数据中的 type 字段(如 'CubicBezierCurve')从 Curves 命名空间动态实例化对应类再反序列化——这也是为什么子曲线必须属于 Curves.js 汇总导出体系;
  • copy(source) 对每条子曲线执行 clone()深拷贝,并复制 autoClose

这套机制让 CurvePath 可以作为场景/对象图的一部分参与 JSON 序列化与对象复制,例如通过 Object3D 的序列化链路间接保存自定义曲线几何。

五、在曲线家族中的位置:Path、Shape 与 ShapePath

CurvePath 是「裸」的组合容器,只负责拼接与弧长换算;面向具体建模需求时,three.js 提供了功能更强的子类:

源码 相对 CurvePath 的增量
Path Path.js 提供类 Canvas 2D 的便捷 API:moveTolineToquadraticCurveTobezierCurveTosplineThruabsarc 等,内部自动把坐标增量转换为具体曲线对象并 addcurves
Shape Shape.js 继承 Path,支持 holes 孔洞,是 ShapeGeometry/ExtrudeGeometry 的标准输入
ShapePath src/extras/core/ShapePath.js 维护轮廓与孔洞的空间归属关系,用于解析复杂形状集合

Path.js 源码注释中的示例展示了这条继承链的完整用法(Path 即带便捷 API 的 CurvePath):

const path = new THREE.Path();

path.lineTo( 0, 0.8 );
path.quadraticCurveTo( 0, 1, 0.2, 1 );
path.lineTo( 1, 1 );

const points = path.getPoints();

const geometry = new THREE.BufferGeometry().setFromPoints( points );
const material = new THREE.LineBasicMaterial( { color: 0xffffff } );

const line = new THREE.Line( geometry, material );
scene.add( line );

选型建议:需要 Canvas 风格的增量式绘制 API 或最终生成形状几何时优先用 Path/Shape;需要手动控制曲线序列、混合 2D/3D 曲线、做运行时拼路与弧长查询(如沿路径移动对象、路径变形动画)时,直接使用 CurvePath 最轻量。

六、完整实战示例

下面示例组合直线与两条贝塞尔曲线,演示采样、弧长查询与闭合,全部 API 均与上文源码一致:

import * as THREE from 'three';

// 1. 组合路径
const path = new THREE.CurvePath();
path.add( new THREE.LineCurve(
	new THREE.Vector2( 0, 0 ),
	new THREE.Vector2( 1, 0 )
) );
path.add( new THREE.QuadraticBezierCurve(
	new THREE.Vector2( 1, 0 ),
	new THREE.Vector2( 1.5, 1.5 ),
	new THREE.Vector2( 0.5, 1 )
) );
path.add( new THREE.CubicBezierCurve(
	new THREE.Vector2( 0.5, 1 ),
	new THREE.Vector2( -0.5, 0.5 ),
	new THREE.Vector2( -0.5, -0.5 ),
	new THREE.Vector2( 0, 0 )
) );

// 2. 弧长信息
console.log( path.getLength() );      // 路径总长
console.log( path.getCurveLengths() ); // [L1, L1+L2, L1+L2+L3]

// 3. 全局弧长均匀采样
const points = path.getSpacedPoints( 64 );
console.log( points[ 0 ].x, points[ 0 ].y ); // (0, 0)
const mid = path.getPoint( 0.5 );           // 路径弧长中点位置

// 4. 修改子曲线参数后必须刷新缓存
path.curves[ 1 ].control = new THREE.Vector2( 2, 2 );
path.updateArcLengths();
// 若该曲线同时作为独立 Curve 使用,还应对其调用 updateArcLengths()

// 5. 渲染为折线
const geometry = new THREE.BufferGeometry().setFromPoints( points );
const line = new THREE.Line( geometry,
	new THREE.LineBasicMaterial( { color: 0xffffff } ) );
scene.add( line );

// 6. 生成闭合轮廓(追加一条回起点的线曲线)
path.closePath();
console.log( path.curves.length ); // 4

注意第 3 步中 getSpacedPoints 的均匀性是弧长意义上的:即使某条子曲线参数空间变化很快(曲线局部被「拉直」或「打弯」),采样点在整个路径上的物理间距依然均等——这正是 CurvePath#getPoint 内部先做弧长换算、再调用 getPointAt 的目的。

七、API 速查表

成员 签名 说明
构造器 new CurvePath() 初始化空路径,curves = []autoClose = false
.curves Array<Curve> 定义路径的子曲线数组
.autoClose boolean,默认 false 采样序列末尾是否自动补起点形成闭合
.add() add( curve ) : void 追加一条曲线
.closePath() closePath() : CurvePath 若首尾未连接则追加闭合线曲线;返回 this
.getCurveLengths() getCurveLengths() : Array<number> 子曲线累计弧长数组(带缓存)
.getPoint() getPoint( t, optionalTarget ) : Vector2 | Vector3 按全局弧长参数 t ∈ [0,1] 取路径上一点
.getLength() getLength() : number 路径总弧长,等于 getCurveLengths() 末位值
.updateArcLengths() updateArcLengths() : void 子曲线参数变化后必须调用以失效缓存
.getSpacedPoints() getSpacedPoints( divisions = 40 ) 弧长均匀采样,autoClose 时补闭合点
.getPoints() getPoints( divisions = 12 ) 按子曲线自适应分辨率采样,去重相邻点
.copy() / .toJSON() / .fromJSON() 深拷贝与 JSON 序列化/反序列化

八、关键参考路径

内容 相对路径
官方 API 文档(本文依据) docs/pages/CurvePath.html.md
CurvePath 实现 src/extras/core/CurvePath.js
父类 Curve(弧长缓存、UtoT 映射) src/extras/core/Curve.js
便捷子类 Path src/extras/core/Path.js
形状类 Shape / ShapePath src/extras/core/Shape.jssrc/extras/core/ShapePath.js
具体曲线类汇总 src/extras/curves/Curves.js
单元测试 test/unit/src/extras/core/CurvePath.tests.js
核心导出 src/Three.Core.js

小结

CurvePath 的设计核心可以概括为一句话:用前缀和弧长数组把多段异质曲线统一到一个全局弧长参数空间里getCurveLengths() 建立分段索引,getPoint(t) 通过「全局弧长 → 定位子曲线 → 换算局部参数 → getPointAt」四级换算实现全路径均匀采样,closePath()autoClose 分别提供「几何闭合」与「采样闭合」两种手段,而 getPoints() 的按类型自适应分辨率与 copy/toJSON 的递归序列化则补齐了工程落地所需的能力。由于 PathShape 均构建于其上,掌握 CurvePath 也就掌握了 three.js 从曲线到形状几何的整条数据链路。

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