Three.js 中 RollerCoasterLiftersGeometry 深度解析:沿曲线程序化生成过山车支撑结构
RollerCoasterLiftersGeometry 是 three.js 官方 addons 中用于程序化生成过山车“支撑结构(lifters)”的工具类:给它一条轨道曲线和分段数,它就会沿曲线挤出横梁与斜撑柱,并在弯道处自动做出与列车一致的内倾(banking)。读完本文,你能独立把该类接入自己的场景,理解 curve / divisions 两个构造参数的真实语义、内部挤出与 banking 角度的计算方式,以及高/低两段支撑(横梁立柱 vs 单斜撑)的分支逻辑。
类层级、导入与文件位置
官方文档对它的定义是一句话:“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.json 的 exports 字段为准:
- 以 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) 处采样 |
两点值得注意的边界语义,均可在 构造器主循环 中验证:
- 采样不包含 t = 0。循环从
i = 1开始,curve.getPointAt(0)在 lifters 几何体中根本不会被调用;曲线起点附近缺少第一段支撑,这是与RollerCoasterGeometry(其prevPoint会取getPointAt(0))不同的细节。若曲线是闭合环(如官方示例),起点/终点首尾相接,缺口影响很小;若曲线是开放线,第一个分段会从t = 1/divisions才开始。 - 切线采样对闭合曲线取模回绕:banking 计算中前后采样点
( t ± bankDelta ) % 1 + 1 ) % 1保证t在 0/1 边界附近也能正确取样,因此闭合曲线在接缝处不会出现 banking 跳变。
构造完成后,几何体只写入两个 BufferAttribute(examples/jsm/misc/RollerCoaster.js):
| 属性 | 类型 | 项数 |
|---|---|---|
position |
BufferAttribute(Float32Array,itemSize 3) |
36 × divisions 个顶点分量 |
normal |
同上 | 同上 |
没有 index、没有 color、没有 uv。由此推出两条实用结论:
- 这是一个非索引三角形汤(triangle soup),每段固定 12 个三角面(3 个截面轮廓 × 每轮廓 4 个三角形),顶点与法线均为按面展开的平直着色(flat shading)效果,法线直接来自截面形状顶点方向,无需
computeVertexNormals(); - 材质必须使用纯色材质,例如官方示例用的
MeshPhongMaterial。若误用vertexColors: true,因为没有color属性,顶点色读取结果未定义/异常。
完整可运行示例:自定义曲线对象 + 支撑结构
examples/webxr_vr_rollercoaster.html 是 RollerCoasterLiftersGeometry 在仓库中唯一的实际调用点,值得完整剖析。它展示了“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.CatmullRomCurve3、THREE.CubicBezierCurve3或手写对象)都能传入; getPointAt内部把t乘以2π,把[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 );
这里有两个可直接复用的工程细节:
- 分段数按视觉密度分配:轨道用 1500 段求光滑,支撑立柱用 100 段已足够(立柱本来就是稀疏排布的),投影暗纹用 500 段。三者共用曲线但互不影响,
divisions只是各自内部采样密度; 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 )
];
tube1 与 tube3 形状相同(一条竖边加一个尖点),仅朝向相反(尖点朝 +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 × divisions与18 × 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 );
MeshLambertMaterial、MeshStandardMaterial 同样适用,关键约束只有“纯色材质”与“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 驱动局部坐标系、用四元数组合表达复合姿态、把法线随截面形状一起生成。
延伸阅读路径(均为当前仓库内文件):
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 StartedRust0626
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
