three.js Shape 类详解:从 2D 轮廓定义到挤出与平面网格的完整路径
本文基于 three.js 官方文档 Shape 及其源码实现,系统讲解 THREE.Shape 的定位、构造方式、属性与方法,并结合 src/extras/core/Shape.js、src/geometries/ExtrudeGeometry.js、src/geometries/ShapeGeometry.js 与 src/extras/ShapeUtils.js 深入剖析轮廓点如何被采样、朝向如何被校正、带孔形状如何被三角化,帮助你在项目中稳定地把任意 2D 图形(含孔洞)转换为可渲染的 3D 几何体。
一、Shape 是什么:一条可挤出、可三角化的闭合 2D 轮廓
官方文档对 Shape 的定义是:使用路径(可选地带有孔洞)定义任意 2D 形状平面,可用于 ExtrudeGeometry 做挤出、ShapeGeometry 生成单面平面网格、提取轮廓点、或直接获取三角化后的面。
从源码结构看,它的类继承链为:
Curve → CurvePath → Path → Shape
对应实现文件依次为 src/extras/core/Curve.js、src/extras/core/CurvePath.js、src/extras/core/Path.js、src/extras/core/Shape.js。单元测试 test/unit/src/extras/core/Shape.tests.js 也显式断言了 Shape extends from Path。这条继承链决定了 Shape 的能力来源:
- CurvePath 层:
Shape内部持有this.curves数组,是一条"曲线序列",支持按弧长采样(getPoint(t)、getSpacedPoints()); - Path 层:提供与 2D Canvas API 相近的绘图方法(
moveTo、lineTo、bezierCurveTo等); - Shape 层:在此之上增加
holes(孔洞路径数组)、uuid,并新增extractPoints、getPointsHoles两个面向几何生成的 API。
在 src/extras/core/Shape.js 中,构造函数做的事情非常克制:
constructor( points ) {
super( points );
this.uuid = generateUUID();
this.type = 'Shape';
this.holes = [];
}
值得注意的是,Shape 并没有覆盖父类 Curve 的 getPoint(t)/getLength() 等接口,这些能力全部由 CurvePath 提供;Shape 真正新增的是"轮廓 + 孔洞"这一数据模型,这正是几何生成器(ExtrudeGeometry/ShapeGeometry)所消费的核心。
二、构造与文档示例:心形轮廓 + ExtrudeGeometry 挤出
构造函数
new Shape( points : Array.<Vector2> ) —— 构造一个新形状,points 为定义形状的二维点数组。若传入 points,父类 Path 会调用 setFromPoints( points ),其实现是:第一个点执行 moveTo,其余点依次 lineTo,即全部以直线段 LineCurve 连接(见 src/extras/core/Path.js#L66-L78)。这意味着:
// 由点云直接构造多边形形状(直线段连接)
const shape = new THREE.Shape( [
new THREE.Vector2( 0, 0 ),
new THREE.Vector2( 1, 0 ),
new THREE.Vector2( 1, 1 ),
new THREE.Vector2( 0, 1 )
] );
文档完整代码示例
官方文档给出的标准示例是用贝塞尔曲线画出心形,再挤出成实体。这里完整保留并补充说明:
const heartShape = new THREE.Shape();
heartShape.moveTo( 25, 25 );
heartShape.bezierCurveTo( 25, 25, 20, 0, 0, 0 );
heartShape.bezierCurveTo( - 30, 0, - 30, 35, - 30, 35 );
heartShape.bezierCurveTo( - 30, 55, - 10, 77, 25, 95 );
heartShape.bezierCurveTo( 60, 77, 80, 55, 80, 35 );
heartShape.bezierCurveTo( 80, 35, 80, 0, 50, 0 );
heartShape.bezierCurveTo( 35, 0, 25, 25, 25, 25 );
const extrudeSettings = {
depth: 8,
bevelEnabled: true,
bevelSegments: 2,
steps: 2,
bevelSize: 1,
bevelThickness: 1
};
const geometry = new THREE.ExtrudeGeometry( heartShape, extrudeSettings );
const mesh = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial() );
示例中的 6 段 bezierCurveTo 首尾闭合回到起点 (25, 25),构成一条闭合心形轮廓;extrudeSettings 中 depth: 8 控制挤出厚度,bevelEnabled/bevelSize/bevelThickness/bevelSegments 控制斜角。完整的挤出效果演示可参考仓库中的官方示例页 examples/webgl_geometry_extrude_shapes.html。
三、属性详解:holes 与 uuid
.holes : Array.
定义形状中的孔洞。文档明确要求:孔洞的定义必须使用与外轮廓相反的绕向(CW/CCW)。源码注释(src/extras/core/Shape.js#L56-L63)与文档一致:
"Hole definitions must use the opposite winding order (CW/CCW) than the outer shape."
这里的"绕向"由几何面积的正负来判定。src/extras/ShapeUtils.js 中:
ShapeUtils.area( contour )用鞋带公式计算有向面积(乘 0.5);ShapeUtils.isClockWise( pts )当且仅当area < 0时返回true。
因此"外轮廓逆时针、孔洞顺时针"就是文档所说的相反绕向的量化含义。典型的加孔写法是:
const shape = new THREE.Shape();
shape.moveTo( - 50, - 50 );
shape.lineTo( 50, - 50 );
shape.lineTo( 50, 50 );
shape.lineTo( - 50, 50 );
shape.closePath();
const hole = new THREE.Path();
hole.moveTo( - 10, - 10 );
hole.lineTo( - 10, 10 ); // 与外轮廓绕向相反
hole.lineTo( 10, 10 );
hole.lineTo( 10, - 10 );
hole.closePath();
shape.holes.push( hole );
.uuid : string(readonly)
形状的唯一标识,由 generateUUID() 在构造时生成。它服务于序列化链路:ShapeGeometry 的 toJSON 通过 shape.uuid 记录形状引用(见 src/geometries/ShapeGeometry.js#L209-L231),使几何体与形状可以在 JSON 中解耦存储。
四、方法详解:extractPoints 与 getPointsHoles
这两个方法是 Shape 相对 Path 的核心增量,也是两个几何生成器的统一入口。
.getPointsHoles( divisions : number ) : Array.<Array.>
"返回一个数组,其中每个元素代表一个孔洞轮廓的二维点列表。divisions 表示结果精细度。"
实现(src/extras/core/Shape.js#L74-L86)就是对每个 holes[i] 调用 getPoints( divisions ):
getPointsHoles( divisions ) {
const holesPts = [];
for ( let i = 0, l = this.holes.length; i < l; i ++ ) {
holesPts[ i ] = this.holes[ i ].getPoints( divisions );
}
return holesPts;
}
.extractPoints( divisions : number ) : Object
"返回一个对象,以二维点数组形式保存形状及其孔洞的轮廓数据",即 { shape: [...], holes: [[...], ...] }(src/extras/core/Shape.js#L97-L106)。
divisions 到底控制什么
采样发生在父类 CurvePath.getPoints()(src/extras/core/CurvePath.js#L199-L235)。它对每条子曲线按类型采用不同分辨率:
| 子曲线类型 | 实际采样分辨率 |
|---|---|
EllipseCurve(圆弧/椭圆) |
divisions * 2 |
LineCurve |
固定 1(端点即可) |
SplineCurve |
divisions * curve.points.length |
其他(如 CubicBezierCurve) |
divisions |
采样结果还会做去重(跳过与上一个点相同的点),并在 autoClose 为真时追加首点闭合。因此 divisions 越大,贝塞尔曲线与圆弧上的采样点越密,轮廓越平滑,但顶点数量也随之线性增长。
divisions 在下游几何生成器中的对应参数是 curveSegments,默认值为 12:
ShapeGeometry构造器签名为constructor( shapes, curveSegments = 12 )(src/geometries/ShapeGeometry.js#L32),内部调用shape.extractPoints( curveSegments );ExtrudeGeometry中options.curveSegments !== undefined ? options.curveSegments : 12(src/geometries/ExtrudeGeometry.js#L87)。
也就是说,文档 API 中的 divisions 与几何生成器的 curveSegments 是同一个采样精度参数,只是在不同层级的命名。
五、下游消费:ShapeGeometry 与 ExtrudeGeometry 如何使用轮廓点
ShapeGeometry:采样 → 朝向校正 → Earcut 三角化
ShapeGeometry 处理单个形状的完整流程(src/geometries/ShapeGeometry.js#L93-L159):
extractPoints( curveSegments )采样得到外轮廓点shapeVertices与孔洞点数组;- 朝向校正:若外轮廓不是顺时针则
reverse();若某个孔洞是顺时针则reverse()。这与文档"孔洞绕向必须与外轮廓相反"的要求互为表里——生成器会替你修正,但你手动构造时仍应遵循该约定; ShapeUtils.triangulateShape( shapeVertices, shapeHoles )三角化(src/extras/ShapeUtils.js#L50-L87):把外轮廓与所有孔洞展平为一条顶点数组,孔洞起点下标记入holeIndices,交给 Earcut 求三角形索引;- 逐顶点写入
position(z=0)、normal(0,0,1)、uv(直接使用世界坐标 x/y),逐三角形写入索引。
另外,当传入多个 Shape 时,ShapeGeometry 会为每个形状 addGroup,从而支持多材质(MultiMaterial)。
ExtrudeGeometry:同一条轮廓点管线 + 挤出
ExtrudeGeometry 的第一步与 ShapeGeometry 完全相同——extractPoints( curveSegments ),随后做同样的顺时针校正:外轮廓若为逆时针则反转,孔洞若为顺时针则反转。差异在于后续:顶点沿挤出方向复制出侧面与顶/底面,并支持 extrudePath 沿任意 3D 路径扫掠(此时强制禁用 bevel,见 src/geometries/ExtrudeGeometry.js#L106-L111)。
结论是:extractPoints 是"点提取"与"几何生成"之间的契约接口。任何自定义的 2D 轮廓生成逻辑(比如把测量数据转成 Shape)只要保证 extractPoints 可用,就能直接复用这套成熟的朝向校正与 Earcut 三角化管线。
六、如何绘制轮廓:从 Path 继承的绘图 API
Shape 继承自 Path,因此获得整套与 2D Canvas API 风格一致的构造方法(src/extras/core/Path.js):
| 方法 | 追加的子曲线类型 | 说明 |
|---|---|---|
moveTo( x, y ) |
— | 移动当前点 currentPoint,不产生曲线 |
setFromPoints( points ) |
LineCurve |
首点 moveTo,其余逐点 lineTo |
lineTo( x, y ) |
LineCurve |
直线段 |
quadraticCurveTo( aCPx, aCPy, aX, aY ) |
QuadraticBezierCurve |
二次贝塞尔 |
bezierCurveTo( aCP1x, aCP1y, aCP2x, aCP2y, aX, aY ) |
CubicBezierCurve |
三次贝塞尔(文档心形示例所用) |
splineThru( pts ) |
SplineCurve |
穿过给定点集的样条 |
arc( ... ) / absarc( ... ) |
EllipseCurve |
相对/绝对圆弧,圆心可相对当前点偏移 |
ellipse( ... ) / absellipse( ... ) |
EllipseCurve |
相对/绝对椭圆,支持旋转角 |
所有方法均返回 this,支持链式调用。两个容易踩坑的细节(见 src/extras/core/Path.js#L270-L294):
- 椭圆类方法会先检查起点是否与
currentPoint一致,不一致时自动补一条lineTo连接,保证曲线序列连续; absarc实际是absellipse的等半径特化,aStartAngle/aEndAngle以弧度计,aClockwise控制扫掠方向。
若形状未手动闭合,可利用 CurvePath 的 closePath()(src/extras/core/CurvePath.js#L56-L71):它比较首末曲线端点,不重合时补一条直线曲线闭合路径。
七、序列化:toJSON / fromJSON / copy
Shape 在父类序列化的基础上补充了 uuid 与 holes(src/extras/core/Shape.js#L108-L160):
toJSON():输出data.uuid,并把每个孔洞(Path)的 JSON 收集到data.holes数组;fromJSON( json ):恢复uuid,并对json.holes中每个条目构造new Path().fromJSON( hole )回填;copy( source ):先super.copy(复制曲线数组),再逐个clone()孔洞——孔洞是深拷贝,不会与源形状共享。
由于 CurvePath.toJSON 会递归序列化每条曲线(含 autoClose 标志,见 src/extras/core/CurvePath.js#L257-L291),Shape 的序列化是完整可逆的,这也是 SVGLoader 将加载结果写入场景并再次序列化时的依赖路径。
八、相关类速查:Path、CurvePath、ShapePath 的边界
同目录 src/extras/core/ 下的三个相关类常与 Shape 一起出现,用途边界值得厘清:
- Path:纯 2D 路径,不关心"内部/孔洞"语义,适合画线、做描边轮廓(如 Path 文档示例:路径采样成点集后用
THREE.Line渲染); - CurvePath:任意 2D/3D 曲线的串联基类,负责按弧长参数化采样(
getPoint(t)依据累计曲线长度定位子曲线,见 src/extras/core/CurvePath.js#L81-L120); - ShapePath:把一组
Path子路径转换为Shape[]的工具,主要用于字体与 SVG 场景。其toShapes()(src/extras/core/ShapePath.js#L146-L351)实现了比"手工 push holes"更智能的内外判定:- 对每条子路径采样、计算有向面积(
ShapeUtils.area),剔除退化路径; - 用"保证在内部"的点(包围盒中心或水平射线法求取,移植自 paper.js 的
getInteriorPoint算法)配合 even-odd 射线法做点在多边形内测试; - 按面积降序处理后,用 winding number +
fillRule(nonzero/evenodd,取自userData.style.fillRule,默认nonzero)判定每条子路径的角色:- 无容器、或容器本身是孔洞 → 新形状的外轮廓;
- 否则 → 容器的孔洞(
shape.holes.push( hole ))。
- 对每条子路径采样、计算有向面积(
从源码结构看,ShapePath 相当于 Shape 的"智能工厂":它解决的是自动判定"哪条子路径是洞、洞属于哪个外形"的问题;而直接使用 Shape 时,这个职责由开发者按文档的绕向约定承担。
九、行为验证:单元测试与文档覆盖范围
- test/unit/src/extras/core/Shape.tests.js:断言
Shape实例化、type === 'Shape'及继承自Path; - test/unit/src/extras/core/ShapePath.tests.js 与 test/unit/src/geometries/ShapeGeometry.tests.js:分别覆盖
ShapePath转换逻辑与ShapeGeometry的几何生成; - test/unit/src/extras/ShapeUtils.tests.js:覆盖面积、绕向判定与
triangulateShape。
官方文档 docs/pages/Shape.html.md 声明的 API 面(构造参数、holes、uuid、extractPoints、getPointsHoles)与当前源码 src/extras/core/Shape.js 完全一致,且源码 JSDoc 中还额外提供了 copy、toJSON、fromJSON 三个未在文档页面展开的方法,本文已一并补齐。
十、实践要点小结
- 绕向约定是契约:外轮廓逆时针、孔洞顺时针;即便如此,
ShapeGeometry/ExtrudeGeometry仍会在生成前用ShapeUtils.isClockWise做防御性校正,但手工处理轮廓点时应保持该约定,避免依赖隐式反转; - 精度由 divisions/curveSegments 控制:默认 12 段/曲线,圆弧按 2 倍分辨率采样;提高该值可平滑轮廓,但顶点数随之增长,应按轮廓复杂度权衡;
- 孔洞是
Path数组:shape.holes中的元素是Path而非Shape,绘制 API 与Shape完全相同; - 序列化可逆:
toJSON/fromJSON覆盖了曲线、currentPoint、uuid、holes全量状态,可安全用于场景持久化与ShapeGeometry的 JSON 往返; - 复杂来源交给 ShapePath:当轮廓来自 SVG/字体等"一堆子路径"而非人工构造时,优先使用
ShapePath.toShapes()自动完成内外角色分配,而不是手工判定绕向。
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 StartedRust0627
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