首页
/ three.js Curve 基类详解:参数化曲线插值、弧长等距采样与 Frenet 标架完整指南

three.js Curve 基类详解:参数化曲线插值、弧长等距采样与 Frenet 标架完整指南

2026-09-06 17:16:07作者:谭伦延

本篇围绕 three.js 的抽象基类 Curve 展开,系统讲解其插值模型(tu 双参数体系)、弧长缓存机制、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;
}

从源码结构看有三点值得注意:

  1. 缓存命中三条件:已有缓存、分段数匹配、且 needsUpdatefalse。任一条不满足就重新计算;
  2. 缓存重建后会自动把 needsUpdate 置回 false(L163),因此手动修改曲线参数后只需置一次 true
  3. 弧长是弦长累加的近似值,精度由 arcLengthDivisions 控制——这就是官方文档"曲线很大时增大 arcLengthDivisions"建议的由来。

.updateArcLengths()

官方文档强调:曲线参数每次变化后都必须调用它。源码实现为"置脏 + 立即重算":

// src/extras/core/Curve.js#L192-L197
updateArcLengths() {
    this.needsUpdate = true;
    this.getLengths();
}

还有一个容易被忽略的约束:如果变更后的曲线是 CurvePath 等组合曲线的一部分,必须同时对组合曲线调用 updateArcLengths(),否则外层曲线持有的弧长表仍是旧值。这一约定在 src/extras/core/CurvePath.jsgetPoint 实现中得到印证——它先按 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 标架(每段一个切向量、法向量、副法向量三元组),是 TubeGeometryExtrudeGeometry 这类"沿曲线扫掠截面"几何体的基础。参数:

  • segments(number):分段数;
  • closed(boolean,默认 false):曲线是否闭合。

Returns{ tangents, normals, binormals } 三个 Vector3 数组。

实现遵循经典的"平行传输"思路(Curve.js#L338-L450,源码注释引用了 Indiana 大学技术报告 TR425):

  1. 逐段求切线:对每个 u = i / segments 调用 getTangentAt,因此同样受益于弧长等距采样;
  2. 选定初始法向量:取第一个切向量中绝对值最小的分量所对应的坐标轴方向作为参考,cross 两次得到与切线正交的初始法向量——这种"避开平行方向"的选取策略避免了一般化法向量选择中的数值退化;
  3. 沿曲线传播:相邻切向量的叉积给出旋转轴,acos(t1·t2) 给出旋转角 θ,用旋转矩阵 makeRotationAxis 把上一段的法向量旋转到当前段,从而让标架"平滑演化"而不会随切线微小抖动而翻转;
  4. 闭合后处理:当 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.typedata.type 正是文档所说"用于序列化/反序列化时识别对象类型"的机制,具体曲线的控制点由子类追加;ObjectLoaderparse 流程在加载 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 计算"沿路径前进一段固定距离"的视点位置——getPointAtgetLength 配合正是 getUtoTmapping(distance) 能力的宏观体现。

实现正确性由单元测试覆盖,测试入口在 test/unit/three.source.unit.js 中统一导入,相关测试文件包括:

要点小结

  1. 区分 t 与 ugetPoint/getTangent/getPoints 工作在参数域,getPointAt/getTangentAt/getSpacedPoints 工作在弧长域,需要物理等距时一律用 *At 系列;
  2. 参数变更后的标准动作:修改曲线参数 → 调用 updateArcLengths()(或置 needsUpdate = true);若曲线嵌在 CurvePath 中,外层也要更新;
  3. 精度旋钮arcLengthDivisions(默认 200)控制弧长表密度,大尺度或高精度等距场景应适当调大;
  4. 性能提示:渲染循环中高频采样时传入 optionalTarget 复用向量,可避免每帧分配;
  5. 扩展方式:自定义曲线继承 Curve 并实现 getPoint(可选覆写 getTangent 提供精确导数),即可自动获得弧长、等距采样、Frenet 标架与序列化全部能力。
登录后查看全文
热门项目推荐
相关项目推荐