首页
/ Three.js 中 RollerCoasterLiftersGeometry 深度解析:沿曲线程序化生成过山车支撑结构

Three.js 中 RollerCoasterLiftersGeometry 深度解析:沿曲线程序化生成过山车支撑结构

2026-09-07 14:37:03作者:魏侃纯Zoe

RollerCoasterLiftersGeometry 是 three.js 官方 addons 中用于程序化生成过山车“支撑结构(lifters)”的工具类:给它一条轨道曲线和分段数,它就会沿曲线挤出横梁与斜撑柱,并在弯道处自动做出与列车一致的内倾(banking)。读完本文,你能独立把该类接入自己的场景,理解 curve / divisions 两个构造参数的真实语义、内部挤出与 banking 角度的计算方式,以及高/低两段支撑(横梁立柱 vs 单斜撑)的分支逻辑。

three.js webxr_vr_rollercoaster 示例截图:黄色轨道沿绿色山丘盘旋,白色支撑立柱从轨道下方延伸至地面

类层级、导入与文件位置

官方文档对它的定义是一句话:“A procedural roller coaster lifters geometry.”(程序化过山车支撑结构几何体),继承关系为:

EventDispatcher → BufferGeometry → RollerCoasterLiftersGeometry

该文档同时提示:RollerCoasterLiftersGeometry 是一个 addon(附加模块),不属于 three 核心包,必须显式导入,不能依赖核心入口的聚合导出。其源码位于 examples/jsm/misc/RollerCoaster.js,导入语句与官方文档一致:

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

导入路径 three/addons/... 的解析方式有两种,以当前仓库 package.jsonexports 字段为准:

  • 以 npm 包方式引入时,package.json 中配置了 "./addons/*": "./examples/jsm/*",因此 three/addons/misc/RollerCoaster.js 会映射到包内 examples/jsm/misc/RollerCoaster.js
  • 直接浏览仓库示例时,页面用 importmap 把 three/addons/ 映射到本地 examples/jsm/ 目录,例如 examples/webxr_vr_rollercoaster.html 中:
<script type="importmap">
  {
    "imports": {
      "three": "../build/three.module.js",
      "three/addons/": "./jsm/"
    }
  }
</script>

同一个源文件实际导出 5 个类(见文件末尾 export 语句,examples/jsm/misc/RollerCoaster.js):RollerCoasterGeometry(轨道本身)、RollerCoasterLiftersGeometry(支撑结构,即本文主题)、RollerCoasterShadowGeometry(地面投影暗纹)、SkyGeometry(天空片)、TreesGeometry(树木)。本文只展开 RollerCoasterLiftersGeometry,其余类在“配套几何体”一节中简要说明。

构造参数与几何体输出属性

new RollerCoasterLiftersGeometry( curve, divisions )

构造器接受两个参数,源码中的 JSDoc(examples/jsm/misc/RollerCoaster.js)与官方文档描述一致:

参数 类型 说明 源码中的实际要求
curve Curve 沿其生成几何体的曲线 只需具备 getPointAt( t )getTangentAt( t ) 两个方法,参数 t[0, 1] 区间;getTangentAt 必须返回归一化切线
divisions number 分段数,决定几何体精细度 生成 divisions 个支撑段、12 × divisions 个三角面;曲线仅在 t = i / divisions (i = 1..divisions) 处采样

两点值得注意的边界语义,均可在 构造器主循环 中验证:

  1. 采样不包含 t = 0。循环从 i = 1 开始,curve.getPointAt(0) 在 lifters 几何体中根本不会被调用;曲线起点附近缺少第一段支撑,这是与 RollerCoasterGeometry(其 prevPoint 会取 getPointAt(0))不同的细节。若曲线是闭合环(如官方示例),起点/终点首尾相接,缺口影响很小;若曲线是开放线,第一个分段会从 t = 1/divisions 才开始。
  2. 切线采样对闭合曲线取模回绕:banking 计算中前后采样点 ( t ± bankDelta ) % 1 + 1 ) % 1 保证 t 在 0/1 边界附近也能正确取样,因此闭合曲线在接缝处不会出现 banking 跳变。

构造完成后,几何体只写入两个 BufferAttributeexamples/jsm/misc/RollerCoaster.js):

属性 类型 项数
position BufferAttributeFloat32Array,itemSize 3) 36 × divisions 个顶点分量
normal 同上 同上

没有 index、没有 color、没有 uv。由此推出两条实用结论:

  • 这是一个非索引三角形汤(triangle soup),每段固定 12 个三角面(3 个截面轮廓 × 每轮廓 4 个三角形),顶点与法线均为按面展开的平直着色(flat shading)效果,法线直接来自截面形状顶点方向,无需 computeVertexNormals()
  • 材质必须使用纯色材质,例如官方示例用的 MeshPhongMaterial。若误用 vertexColors: true,因为没有 color 属性,顶点色读取结果未定义/异常。

完整可运行示例:自定义曲线对象 + 支撑结构

examples/webxr_vr_rollercoaster.htmlRollerCoasterLiftersGeometry 在仓库中唯一的实际调用点,值得完整剖析。它展示了“curve 参数到底需要什么”——示例并没有使用 THREE.Curve 子类,而是用闭包对象手写了两个方法(第 110-142 行):

const PI2 = Math.PI * 2;

const curve = ( function () {

  const vector = new THREE.Vector3();
  const vector2 = new THREE.Vector3();

  return {

    getPointAt: function ( t ) {

      t = t * PI2;

      const x = Math.sin( t * 3 ) * Math.cos( t * 4 ) * 50;
      const y = Math.sin( t * 10 ) * 2 + Math.cos( t * 17 ) * 2 + 5;
      const z = Math.sin( t ) * Math.sin( t * 4 ) * 50;

      return vector.set( x, y, z ).multiplyScalar( 2 );

    },

    getTangentAt: function ( t ) {

      const delta = 0.0001;
      const t1 = Math.max( 0, t - delta );
      const t2 = Math.min( 1, t + delta );

      return vector2.copy( this.getPointAt( t2 ) )
        .sub( this.getPointAt( t1 ) ).normalize();

    }

  };

} )();

要点:

  • 类内部所有曲线访问都走 getPointAt / getTangentAt(弧长参数化 API),所以任何实现了这两个方法的对象(THREE.CatmullRomCurve3THREE.CubicBezierCurve3 或手写对象)都能传入;
  • getPointAt 内部把 t 乘以 ,把 [0,1] 映射成整圈闭合轨道——这正是 lifters 生成循环对 t 取模回绕能够无缝工作的原因;
  • getTangentAt 用中心差分近似切线并 normalize(),满足源码对归一化切线的要求。

随后三个几何体共享同一条曲线,分段数各自独立(第 144-163 行):

geometry = new RollerCoasterGeometry( curve, 1500 );
material = new THREE.MeshPhongMaterial( { vertexColors: true } );
mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );

geometry = new RollerCoasterLiftersGeometry( curve, 100 );
material = new THREE.MeshPhongMaterial();
mesh = new THREE.Mesh( geometry, material );
mesh.position.y = 0.1;
scene.add( mesh );

geometry = new RollerCoasterShadowGeometry( curve, 500 );
material = new THREE.MeshBasicMaterial( {
  color: 0x305000, depthWrite: false, transparent: true
} );
mesh = new THREE.Mesh( geometry, material );
mesh.position.y = 0.1;
scene.add( mesh );

这里有两个可直接复用的工程细节:

  1. 分段数按视觉密度分配:轨道用 1500 段求光滑,支撑立柱用 100 段已足够(立柱本来就是稀疏排布的),投影暗纹用 500 段。三者共用曲线但互不影响,divisions 只是各自内部采样密度;
  2. mesh.position.y = 0.1 抬高 0.1 个单位:支撑底部与投影贴花都落在 y = 0 平面,若不抬高会与地面/贴花发生深度竞争(z-fighting),产生闪烁条纹。

源码剖析:三个十字截面与 extrudeShape 挤出

三组截面形状

构造器开头定义了三组 3 点轮廓(examples/jsm/misc/RollerCoaster.js),都是“十字形/星形”截面,坐标位于轨道切平面局部坐标系中(局部 +y 指向地面方向,局部 +z 为切线前进方向):

const tube1 = [
  new Vector3( 0, 0.05, - 0.05 ),
  new Vector3( 0, 0.05, 0.05 ),
  new Vector3( 0, - 0.05, 0 )
];

const tube2 = [
  new Vector3( - 0.05, 0, 0.05 ),
  new Vector3( - 0.05, 0, - 0.05 ),
  new Vector3( 0.05, 0 )
];

const tube3 = [
  new Vector3( 0.05, 0, - 0.05 ),
  new Vector3( 0.05, 0, 0.05 ),
  new Vector3( - 0.05, 0 )
];

tube1tube3 形状相同(一条竖边加一个尖点),仅朝向相反(尖点朝 +x / -x);tube2 是它们的旋转版本(尖点朝 +z,即轨道前进方向)。三组截面共同“拼装”出支撑结构:高架段用 tube1 做横梁、tube2/tube3 做左右斜撑;低区段只使用 tube3 做单根斜撑。

extrudeShape:截面轮廓 → 两三角形/边的挤出

核心挤出函数 extrudeShape( shape, fromPoint, toPoint )examples/jsm/misc/RollerCoaster.js)把截面轮廓沿 fromPoint → toPoint 连线“扫”出侧面:

function extrudeShape( shape, fromPoint, toPoint ) {

  for ( let j = 0, jl = shape.length; j < jl; j ++ ) {

    const point1 = shape[ j ];
    const point2 = shape[ ( j + 1 ) % jl ];

    vector1.copy( point1 );
    vector1.applyQuaternion( quaternion );
    vector1.add( fromPoint );

    vector2.copy( point2 );
    vector2.applyQuaternion( quaternion );
    vector2.add( fromPoint );

    vector3.copy( point2 );
    vector3.applyQuaternion( quaternion );
    vector3.add( toPoint );

    vector4.copy( point1 );
    vector4.applyQuaternion( quaternion );
    vector4.add( toPoint );

    vertices.push( vector1.x, vector1.y, vector1.z );
    vertices.push( vector2.x, vector2.y, vector2.z );
    vertices.push( vector4.x, vector4.y, vector4.z );

    vertices.push( vector2.x, vector2.y, vector2.z );
    vertices.push( vector3.x, vector3.y, vector3.z );
    vertices.push( vector4.x, vector4.y, vector4.z );

    // 法线:截面点方向经同一四元数旋转后归一化
    normal1.copy( point1 );
    normal1.applyQuaternion( quaternion );
    normal1.normalize();
    // ... normal2 / normal3 / normal4 同理
    normals.push( /* ... */ );
  }
}

其几何含义:

  • 对轮廓的第 j 条边(shape[j] → shape[(j+1) % jl]),取边两端在 fromPoint 处和 toPoint 处的 4 个角点,拆成两个三角形写入 vertices;3 点轮廓即产生 3 条边 × 2 = 6 个三角形。每个截面轮廓贡献 6 个三角形,所以每段的三角面总数为 3 个轮廓 × 6 = 18?注意源码实际调用方式:每段最多调用 3 次 extrudeShape,每次 6 个三角形,合计 18 个三角形;若只走低区分支则 6 个。前面“每段 12 个三角面”应按分支修正:高架段 18、低区段 6,总面数介于 6 × divisions18 × divisions 之间,取决于曲线高度分布;
  • 局部点先 applyQuaternion( quaternion ) 再平移到曲线点,等价于在“轨道切平面局部坐标系”里摆好截面,再整体放到世界空间;
  • 法线取截面点方向(copy( point1 ) 后旋转、归一化),因此每个三角面的法线都平行于该截面的径向方向——这正是一根“棱柱”应有的面法线,也解释了为什么输出天然呈平直(flat)外观。

姿态:切线定朝向,banking 定内倾

主循环(examples/jsm/misc/RollerCoaster.js)每段先算两个四元数:

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

  point.copy( curve.getPointAt( i / divisions ) );
  tangent.copy( curve.getTangentAt( i / divisions ) );

  const angle = Math.atan2( tangent.x, tangent.z );

  quaternion.setFromAxisAngle( up, angle );

  // banking

  const bankDelta = 0.01;
  const t = i / divisions;

  sample1.copy( curve.getTangentAt( ( ( t - bankDelta ) % 1 + 1 ) % 1 ) );
  sample2.copy( curve.getTangentAt( ( t + bankDelta ) % 1 ) );

  let headingChange = Math.atan2( sample2.x, sample2.z ) - Math.atan2( sample1.x, sample1.z );
  if ( headingChange > Math.PI ) headingChange -= Math.PI * 2;
  if ( headingChange < - Math.PI ) headingChange += Math.PI * 2;

  bankedQuaternion.copy( quaternion );
  rollQuaternion.setFromAxisAngle( tangent, - Math.atan( headingChange * 8 ) * 0.5 );
  bankedQuaternion.premultiply( rollQuaternion );
  • quaternion 只绕世界 up (0,1,0) 旋转 atan2( tangent.x, tangent.z ),使局部 +z 轴对准切线水平方向——它负责“朝向”,不含内倾;
  • banking 部分在 t ± 0.01 处采样两条切线,用 atan2(x, z) 求出航向角差 headingChange(并归一到 (-π, π] 防止跨 ±π 跳变),再按 -Math.atan( headingChange * 8 ) * 0.5 得到绕切线轴的侧滚角,premultiply 到朝向四元数上得到 bankedQuaternion。转弯越急(单位弧长内航向变化越大),侧倾越大;
  • 这段 banking 公式与 examples/webxr_vr_rollercoaster.html 中列车(train)每帧的姿态计算完全同源——这就是为什么列车过弯内倾时,轨道下的支撑柱也以同一角度内倾,视觉上保持“车体与轨道刚性一致”。

高度分支:横梁+双斜撑 vs 单斜撑

每段姿态确定后,代码按曲线点的高度 point.y 分成两条支路(examples/jsm/misc/RollerCoaster.js):

if ( point.y > 10 ) {

  // 横梁:轨道正下方 0.35 处,沿左右方向 ±0.75 横向伸出
  fromPoint.set( - 0.75, - 0.35, 0 );
  fromPoint.applyQuaternion( quaternion );
  fromPoint.add( point );

  toPoint.set( 0.75, - 0.35, 0 );
  toPoint.applyQuaternion( quaternion );
  toPoint.add( point );

  extrudeShape( tube1, fromPoint, toPoint );

  // 左侧斜撑:从轨道下方 0.3 处落到地面 ( -0.7, 0 )
  fromPoint.set( - 0.7, - 0.3, 0 );
  fromPoint.applyQuaternion( quaternion );
  fromPoint.add( point );

  toPoint.set( - 0.7, - point.y, 0 );
  toPoint.applyQuaternion( quaternion );
  toPoint.add( point );

  extrudeShape( tube2, fromPoint, toPoint );

  // 右侧斜撑:镜像到 ( 0.7, 0 )
  fromPoint.set( 0.7, - 0.3, 0 );
  fromPoint.applyQuaternion( quaternion );
  fromPoint.add( point );

  toPoint.set( 0.7, - point.y, 0 );
  toPoint.applyQuaternion( quaternion );
  toPoint.add( point );

  extrudeShape( tube3, fromPoint, toPoint );

} else {

  // 单根斜撑:轨道下方 0.2 处落到正下方地面
  fromPoint.set( 0, - 0.2, 0 );
  fromPoint.applyQuaternion( bankedQuaternion );
  fromPoint.add( point );

  toPoint.copy( fromPoint );
  toPoint.y = 0;

  extrudeShape( tube3, fromPoint, toPoint );

}

结构语义可以这样读:

  • 高架段(point.y > 10:生成 1 根横向横梁(局部 x 方向 ±0.75,距轨下 0.35)加 2 根斜撑柱(局部 x = ±0.7,从轨下 0.3 一直拉到 y = 0 的地面)。注意 toPoint.set( ±0.7, - point.y, 0 ) 中的 - point.y:局部 +y 指向地面,因此“下降到地面”的局部距离正好是曲线点的世界高度 point.y,撑柱底部会精确落在 y = 0 平面上——这也是为什么该类的“地面”被硬编码为 y = 0
  • 低区段(point.y <= 10:只生成 1 根短斜撑,从轨下 0.2 处落到正下方地面;
  • 姿态差异:横梁与撑柱上端使用不含 banking 的 quaternion(保证连接轨道的端点始终与轨道切平面对齐),而低区短撑整体使用 bankedQuaternion,让贴地短支撑在弯道处跟着内倾,与截图中的视觉效果一致;
  • 从源码结构看,y > 10 这个阈值隐含了作者对场景的假设:示例曲线 y = 5 + 波动 乘 2 后大部分点高 8~14 之间波动,10 恰好把“高架段”与“近地面段”分开。若换用你自己的曲线,需要确认曲线高度单位与 10 这个阈值匹配,否则支撑风格会整体偏移(全部高架或全部矮柱)。

实用细节与配套几何体

divisions 与规模

  • divisions 直接决定曲线采样次数与面数:每个采样段 6 或 18 个三角形,且几何体非索引,无法在 GPU 端共享顶点。官方示例取 100 段(600~1800 个三角形),对支撑结构这类远景元素完全够用;不要盲目沿用轨道的 1500 段;
  • 几何体在构造时一次性生成完毕,之后不再变化。曲线是静态参数——若想让支撑跟随动态曲线,只能每帧重建几何体(会产生 GC 压力,不推荐)。

材质与摆放清单

结合“无 index、无 color、法线已内置”三点,最小可用配置如下:

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

const geometry = new RollerCoasterLiftersGeometry( curve, 100 );
const material = new THREE.MeshPhongMaterial();   // 纯色,勿开 vertexColors
const lifters = new THREE.Mesh( geometry, material );
lifters.position.y = 0.1;                            // 避免与地面/贴花 z-fighting
scene.add( lifters );

MeshLambertMaterialMeshStandardMaterial 同样适用,关键约束只有“纯色材质”与“y 平面是地面”两条。

配套几何体:同一条曲线的完整过山车

RollerCoasterLiftersGeometry 在仓库中的定位是 examples/webxr_vr_rollercoaster.html 场景的配角,完整场景由同一份 examples/jsm/misc/RollerCoaster.js 提供三个几何体叠加而成:

作用 属性 示例中的分段数
RollerCoasterGeometry 轨道本体(双轨、阶梯、弯道自动 banking) position / normal / color 1500
RollerCoasterLiftersGeometry 支撑立柱(本文主题) position / normal 100
RollerCoasterShadowGeometry 轨道在地面的半透明暗纹投影(y = 0 平面) position 500

三者都只要求 curve 具备 getPointAt / getTangentAt,且 banking 公式同源,因此同一曲线生成的轨道、支撑、投影在姿态上天然自洽,无需逐一对齐。

小结与延伸阅读

RollerCoasterLiftersGeometry 用一个构造器参数(曲线 + 分段数)封装了完整的程序化支撑生成管线:切线朝向四元数 → 航向差分 banking 内倾 → 三组十字截面沿高/低两分支挤出 → 直接输出含面法线的非索引 BufferGeometry。其工程价值不在于“过山车”这个题材,而在于它演示了 three.js 中“手写曲线挤出几何体”的标准做法:用 getPointAt/getTangentAt 驱动局部坐标系、用四元数组合表达复合姿态、把法线随截面形状一起生成。

延伸阅读路径(均为当前仓库内文件):

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