在大型 CAD、BIM 或装配模型中,一个常见需求是:
用户点击模型中的某个零件,系统需要准确识别对应的 CAD Entity,并对该零件执行高亮、隐藏、变色等操作。
如果每个零件都对应一个独立 Mesh,这个问题比较简单:
Mouse
↓
Raycaster
↓
Mesh
↓
CAD Entity
但大型 CAD 模型通常包含成千上万个零件。如果每个零件都创建独立 Mesh,会带来大量:
因此实际渲染系统通常会采用:
Geometry Merge
Batching
Instancing
等方式减少 Draw Call。
这时问题就变成:
多个 CAD 零件已经合并成一个大的
BufferGeometry,鼠标点击其中一个 Triangle 后,怎么知道它属于哪个零件?又如何只高亮这个零件?
这篇文章完整梳理这一过程。
假设 CAD 模型包含:
Part A
Part B
Part C
...
Part N
每个零件都有自己的 CAD Entity ID:
Part A → EntityId = 1001
Part B → EntityId = 1002
Part C → EntityId = 1003
最简单的渲染结构是:
Scene
├── Mesh A
├── Mesh B
├── Mesh C
└── ...
此时 Picking 非常容易。
例如 Three.js:
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects[0];
console.log(hit.object);
因为:
hit.object === Mesh B
所以可以直接得到:
Mesh B
↓
EntityId = 1002
但是当模型达到:
10,000 Parts
100,000 Parts
以后,这种结构可能产生大量 Draw Call。
于是往往需要合并 Geometry。
假设:
Part A → 3000 triangles
Part B → 5000 triangles
Part C → 2000 triangles
原始情况下:
Mesh A → DrawCall 1
Mesh B → DrawCall 2
Mesh C → DrawCall 3
如果材质等渲染状态相同,可以把它们合并:
Big BufferGeometry
Vertex Buffer
┌─────────┬──────────────┬──────────┐
│ Part A │ Part B │ Part C │
└─────────┴──────────────┴──────────┘
Index Buffer
┌─────────┬──────────────┬──────────┐
│ Part A │ Part B │ Part C │
└─────────┴──────────────┴──────────┘
最终:
Mesh A
Mesh B
Mesh C
↓
Big Mesh
↓
1 Draw Call
性能明显改善。
但是与此同时,我们失去了一个非常重要的信息:
Triangle → Part
因为 Raycaster 命中后:
hit.object
只会得到:
BigMesh
而不是:
Part B
因此:
Geometry 可以 Merge,但 CAD Entity 的语义不能丢失。
这是整个系统设计的核心。
一个比较典型的数据结构是:
interface RenderPart {
entityId: number;
indexStart: number;
indexCount: number;
}
例如:
const parts = [
{
entityId: 1001,
indexStart: 0,
indexCount: 3000,
},
{
entityId: 1002,
indexStart: 3000,
indexCount: 6000,
},
{
entityId: 1003,
indexStart: 9000,
indexCount: 4000,
},
];
对应:
Index Buffer
0 13000
│ │
├──── Part A ────┼──── Part B ─────┼ Part C ──┤
0 3000 9000 13000
这样即使所有 Geometry 被 Merge:
BigMesh
我们仍然可以根据 Triangle 所处的 Index Range 反推出:
Triangle
↓
Index Range
↓
EntityId
假设用户点击:
screenX = 500
screenY = 300
首先将屏幕坐标转换成 NDC。
WebGL 中:
NDC.x ∈ [-1, 1]
NDC.y ∈ [-1, 1]
转换大致为:
const x = (mouseX / width) * 2 - 1;
const y = -(mouseY / height) * 2 + 1;
例如:
Screen
(500, 300)
↓
NDC
(0.2, -0.1)
然后根据 Camera 构造 Ray。
概念上可以取 NDC 中近裁剪面和远裁剪面上的两个点:
Pnear = (x, y, -1)
Pfar = (x, y, 1)
再通过逆矩阵:
NDC
↓ inverse Projection
View Space
↓ inverse View
World Space
得到:
PnearWorld
PfarWorld
Ray 方向:
[ D = \frac{P_{far}-P_{near}} {|P_{far}-P_{near}|} ]
于是得到:
Ray {
origin,
direction
}
Three.js 中已经封装:
raycaster.setFromCamera(mouse, camera);
假设一个大型 CAD 模型包含:
50,000,000 triangles
如果每次鼠标点击都执行:
for each triangle:
RayTriangleIntersect(ray, triangle)
计算量显然过大。
因此通常需要一个空间加速结构: BVH,即 Bounding Volume Hierarchy。
BVH 可以理解为:
Root BBox
/ \
/ \
BBox A BBox B
/ \ / \
... ... ... ...
↓
Triangle
每个节点保存一组 Triangle 的包围盒。
Picking 时:
Ray
↓
Root BBox
如果:
Ray ∩ RootBBox = ∅
那么整个模型都不需要继续检查。
如果相交,则继续检查子节点:
Root
/ \
/ \
A B
/ \
intersect no intersect
↓
continue
这样能够快速排除大量 Triangle。
最终流程是:
Ray
↓
Ray / BBox Test
↓
BVH traversal
↓
少量 Candidate Triangle
↓
Ray / Triangle 精确求交
这就是典型的:
Broad Phase
↓
Narrow Phase
最终 BVH 会定位到少量 Triangle。
然后真正执行:
Ray vs Triangle
常见算法包括:
Möller–Trumbore
一个 Triangle:
V1
/\
/ \
/ \
V0------V2
Ray:
O + tD
如果相交,可以得到:
intersection point
distance
triangle index
barycentric coordinate
Three.js 的 Raycaster 通常会返回类似:
{
distance,
point,
face,
faceIndex,
object
}
对于 Merge 后的模型:
object
只能告诉我们:
BigMesh
真正重要的是:
faceIndex
或者 Triangle Index。
假设 Ray 命中了:
Triangle #1578
我们保存的 Part Range 是:
Part A:
triangle 0 ~ 999
Part B:
triangle 1000 ~ 2999
Part C:
triangle 3000 ~ 3999
那么:
1000 <= 1578 < 3000
所以:
Triangle #1578
↓
Part B
↓
EntityId = 1002
最终完成:
Screen Point
↓
Ray
↓
Triangle
↓
Render Part
↓
EntityId
↓
CAD Entity
最简单的方法是 Range Mapping:
interface PartRange {
entityId: number;
triangleStart: number;
triangleCount: number;
}
例如:
const ranges: PartRange[] = [
{
entityId: 1001,
triangleStart: 0,
triangleCount: 1000,
},
{
entityId: 1002,
triangleStart: 1000,
triangleCount: 2000,
},
];
查找:
function findEntity(triangleIndex: number): number | null {
for (const range of ranges) {
if (
triangleIndex >= range.triangleStart &&
triangleIndex < range.triangleStart + range.triangleCount
) {
return range.entityId;
}
}
return null;
}
当然,大量 Part 时不应该线性遍历。
因为 Range 已经排序,可以:
Binary Search
把复杂度降低到:
[ O(\log N) ]
还可以给 Vertex 增加一个自定义 Attribute:
position
normal
uv
partId
例如:
Vertex PartId
V0 1
V1 1
V2 1
V3 2
V4 2
V5 2
Three.js 可以使用:
geometry.setAttribute(
"partId",
new THREE.BufferAttribute(partIds, 1)
);
于是每个 Triangle 的 Vertex 都知道:
自己属于哪个 Part
这个信息除了可以辅助 Picking,更重要的是:
可以直接用于 Shader 高亮。
假设 Picking 已经得到:
EntityId = 1002
也就是:
Part B
接下来希望:
Part B → 黄色
其他 Part → 原来的颜色
这里有多种实现方式。
假设 Geometry 中有:
partId
Vertex Shader:
in float partId;
flat out float vPartId;
void main() {
vPartId = partId;
gl_Position =
projectionMatrix *
modelViewMatrix *
vec4(position, 1.0);
}
Fragment Shader:
flat in float vPartId;
uniform float selectedPartId;
void main() {
vec3 color = baseColor;
if (vPartId == selectedPartId) {
color = vec3(1.0, 1.0, 0.0);
}
gl_FragColor = vec4(color, 1.0);
}
CPU 侧只需要:
material.uniforms.selectedPartId.value = 1002;
于是:
Picking
↓
EntityId = 1002
↓
selectedPartId = 1002
↓
Shader
↓
partId == selectedPartId ?
↓
YES
↓
高亮
最重要的是:
这里不需要修改 Geometry 本身。
普通 Vertex Attribute 传递到 Fragment Shader 时,会进行插值。
例如:
Triangle
V0 id = 1
V1 id = 2
V2 id = 3
普通 varying 在 Triangle 内部可能产生:
1.2
1.8
2.4
但 ID 是一个离散值:
Part 1
Part 2
Part 3
显然不能插值。
因此在支持的 GLSL/WebGL2 场景中,应当使用:
flat
例如:
flat out uint vPartId;
Fragment Shader:
flat in uint vPartId;
这样整个 Primitive 使用一致的 ID。
单个选中时:
uniform float selectedPartId;
足够使用。
但如果用户框选:
10,000 个 Part
就不能简单写:
if (
id == selected[0] ||
id == selected[1] ||
...
)
更合理的方式是建立:
Part State Table
例如:
PartId State
0 Normal
1 Hidden
2 Selected
3 Normal
4 Selected
然后把这个表上传 GPU。
可以使用:
Texture
Buffer
Data Texture
概念上:
partId
↓
PartStateTexture
↓
state
↓
Normal / Selected / Hidden
Shader:
int state = getPartState(partId);
if (state == STATE_SELECTED) {
color = highlightColor;
}
这样 CPU 不需要修改大量 Vertex。
只需要修改:
PartState[1002] = Selected
然后把变化同步到 GPU。
另一种非常实用的方案是:
选中哪个 Part,就额外绘制一次这个 Part。
例如:
Original Big Mesh
+
Selection Overlay Mesh
Picking 得到:
Part B
我们已经知道:
Part B
indexStart = 3000
indexCount = 6000
那么可以让一个 Selection Pass 只绘制这一段 Index。
使用:
黄色
描边
透明
X-Ray
等特殊 Material。
整体效果:
Normal Mesh
↓
正常模型
Selection Mesh
↓
黄色高亮
两者叠加
优点:
缺点:
大型 CAD 系统还可以使用屏幕空间方案。
例如额外渲染一张:
Object ID Buffer
其中:
Part A → 1
Part B → 2
Part C → 3
对应不同颜色编码:
Part A → RGB(0,0,1)
Part B → RGB(0,0,2)
Part C → RGB(0,0,3)
用户点击:
(x, y)
读取对应 Pixel:
readPixels(x, y)
得到:
RGB(0,0,2)
解码:
PartId = 2
整个流程:
Scene
↓
Offscreen Framebuffer
↓
Render EntityId
↓
ID Buffer
Mouse
↓
readPixel
↓
EntityId
这就是:
GPU Picking / Color Picking / ID Picking。
两种方法各有特点。
Mouse
↓
Ray
↓
BVH
↓
Triangle
↓
EntityId
优点:
例如:
点击 Face
捕捉 Edge
捕捉 Vertex
测量
绘制辅助线
通常都需要几何信息。
Mouse
↓
Framebuffer Pixel
↓
EntityId
优点:
缺点是:
它通常只告诉你“屏幕这个像素属于哪个对象”。
如果接下来还要:
最近 Edge
最近 Vertex
Surface UV
3D Snap Point
还是需要进一步几何查询。
因此 CAD 系统往往不会只依赖一种方式。
实际 CAD Picking 很适合设计成:
Object Picking
↓
Part / Body
↓
Face
↓
Edge
↓
Vertex
例如第一阶段:
Ray
↓
Part BVH
↓
Part 1002
第二阶段:
Part 1002
↓
Triangle BVH
↓
Triangle
↓
FaceId
如果需要 Edge Snap:
Face
↓
Candidate Edges
↓
Ray / Screen Distance
↓
Edge
如果需要 Vertex Snap:
Edge
↓
Vertex
而不是:
一次性遍历整个模型的所有 Vertex / Edge / Triangle
这与几何算法中的 Broad Phase / Narrow Phase 是同一个思想。
对于真正的 CAD 系统,仅有:
Triangle → PartId
往往还不够。
因为用户可能需要:
选中 Face
删除 Face
倒圆 Edge
测量 Edge
修改 Hole
所以 Tessellation 时最好保存:
Triangle → FaceId
例如:
interface TriangleMetadata {
entityId: number;
faceId: number;
}
于是:
Ray
↓
Triangle #13451
↓
EntityId = 1002
FaceId = 37
↓
CAD Kernel
↓
TopoDS_Face / Internal Face Object
这样渲染系统与 BRep Kernel 就建立起桥梁:
BRep
│
│ Tessellation
▼
Triangle Mesh
│
│ metadata
▼
EntityId / FaceId
这对云 CAD 来说非常重要。
Triangle Mesh 主要用于显示 Face。
但 CAD 用户经常需要:
选 Edge
选 Vertex
可以有两类实现。
一种是单独生成:
Face Mesh
Edge Line Geometry
Vertex Point Geometry
对应:
Triangle BVH
Line BVH
Point Spatial Index
另一种是先通过 Triangle 找 Face:
Ray
↓
Triangle
↓
FaceId
然后从 CAD Topology 找候选:
Face
├── Edge1
├── Edge2
├── Edge3
└── ...
再计算:
Mouse / Ray
距离哪个 Edge 最近。
这样可以把搜索范围从:
整个 Model
缩小到:
一个 Face 周围
假设:
10,000 Parts
全部 Merge 成:
1 Big Mesh
确实可以把 Draw Call 大幅降低。
但同时带来:
Frustum Culling 粒度下降
Picking 复杂
隐藏 Part 复杂
修改 Part 复杂
局部更新复杂
LOD 复杂
例如原来:
Part A BBox
Part B BBox
Part C BBox
Camera 只能看到 Part A:
只画 A
如果全部 Merge:
BigMesh BBox
只要 BigMesh BBox 和 Frustum 有交集:
整个 BigMesh 都会进入绘制
所以实际系统通常采用:
Batch
而不是:
Whole Scene Merge
例如:
Scene
├── Batch 1
│ ├── Part A
│ ├── Part B
│ └── Part C
│
├── Batch 2
│ ├── Part D
│ ├── Part E
│ └── Part F
│
└── ...
Batch 可以根据:
空间位置
Material
LOD
模型层级
更新频率
进行划分。
目标是在:
Draw Call
↕
Culling 粒度
↕
Picking
↕
编辑能力
之间取得平衡。
如果很多零件 Geometry 完全一样:
Chair × 1000
Bolt × 5000
Window × 3000
更适合:
Instancing
数据结构:
Geometry × 1
Instance
├── matrix 0
├── matrix 1
├── matrix 2
└── ...
Three.js:
THREE.InstancedMesh
此时 Picking 后通常可以得到:
instanceId
然后建立:
instanceId
↓
EntityId
映射。
例如:
instance 0 → Entity 1001
instance 1 → Entity 1037
instance 2 → Entity 5921
因此:
Geometry Merge:
triangleIndex → EntityId
而:
Instancing:
instanceId → EntityId
两者的 Picking 映射方式不同。
一个大型 CAD 渲染系统可以采用:
CAD Model
│
├── Entity
│ ├── Solid
│ ├── Face
│ ├── Edge
│ └── Vertex
│
▼
Tessellation
│
├── Triangle
│ └── FaceId
│
▼
Render Model
│
├── Batch
│ ├── Geometry Buffer
│ ├── Entity Range
│ ├── Face Range
│ └── BVH
│
▼
GPU
Picking:
Mouse
↓
Ray
↓
Batch BVH
↓
Triangle BVH
↓
Triangle
↓
FaceId
↓
EntityId
↓
CAD Model
高亮:
EntityId
↓
Render State
↓
GPU State Texture / Uniform
↓
Shader
↓
Highlight
最终把 Picking + Highlight 串起来:
Mouse
│
▼
Screen Coordinate
│
▼
NDC
│
inverse ViewProjection
│
▼
Ray
│
▼
Scene BVH
│
▼
Batch / Part
│
▼
Triangle BVH
│
▼
Ray-Triangle Test
│
▼
Triangle Index
│
┌───────────┴────────────┐
▼ ▼
FaceId EntityId
│ │
▼ ▼
BRep Face CAD Entity
│
▼
Selection State
│
▼
GPU State
│
▼
Shader
│
▼
Highlight
对于大型 CAD 系统,这个功能背后的核心并不是某个具体 Three.js API,而是几个设计原则。
第一:
Render Geometry 和 CAD Entity 必须解耦,但必须存在稳定映射。
即:
Render Triangle
↕
FaceId / EntityId
↕
CAD Model
第二:
优化 Draw Call 时不能破坏业务对象身份。
所以:
Geometry Merge ≠ Entity Merge
几何可以合并:
Part A + Part B + Part C
↓
Big Geometry
但逻辑上仍然必须保留:
A
B
C
第三:
Picking 应当使用空间加速结构,而不是遍历所有 Triangle。
典型流程:
Ray
↓
BVH
↓
Candidate
↓
Exact Intersection
第四:
高亮状态尽量与 Geometry 数据分离。
不要为了一个 Part 高亮,就重新构建整个 Vertex Buffer。
更合理的是:
EntityId
↓
Render State
↓
Shader
第五:
不要把整个世界无脑 Merge 成一个 Mesh。
实际应根据:
空间
材质
LOD
更新频率
可编辑粒度
设计合理的 Batch。
大型 CAD 系统中的 Picking,本质上解决两个问题:
Screen
↓
Geometry
以及:
Geometry
↓
CAD Semantic Entity
完整链路可以概括为:
Mouse
↓
Ray
↓
BVH
↓
Triangle
↓
FaceId / EntityId
↓
CAD Entity
而高亮则是:
CAD Entity
↓
Selection State
↓
GPU Render State
↓
Shader
↓
Highlight
因此,一个比较合理的复杂 CAD Picking 架构应该同时具备:
空间索引
+
Triangle / Entity 映射
+
稳定的 EntityId
+
GPU Selection State
+
合理的 Geometry Batching
最终目标是在下面几项之间取得平衡:
Draw Call 数量
↕
GPU / CPU 性能
↕
Frustum Culling 粒度
↕
Picking 效率
↕
零件独立编辑能力
这也是大型 CAD 渲染系统和普通 Three.js 场景渲染之间一个非常重要的区别:
CAD 不仅要“把模型画出来”,还必须始终保持 Render Geometry 与底层几何拓扑、业务 Entity 之间的可追溯关系。
(完)