日期:2025年6月17日标签:Computer Graphics

复杂 CAD 零件系统中的 Picking 与高亮实现 #

在大型 CAD、BIM 或装配模型中,一个常见需求是:

用户点击模型中的某个零件,系统需要准确识别对应的 CAD Entity,并对该零件执行高亮、隐藏、变色等操作。

如果每个零件都对应一个独立 Mesh,这个问题比较简单:

Mouse
  ↓
Raycaster
  ↓
Mesh
  ↓
CAD Entity

但大型 CAD 模型通常包含成千上万个零件。如果每个零件都创建独立 Mesh,会带来大量:

  • JavaScript 对象;
  • Draw Call;
  • Matrix 更新;
  • Material 状态切换;
  • 场景树遍历成本。

因此实际渲染系统通常会采用:

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。


二、为什么需要 Geometry Merge #

假设:

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 的语义不能丢失。

这是整个系统设计的核心。


三、渲染层必须保留 EntityId 映射 #

一个比较典型的数据结构是:

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

四、Picking 的第一步:从鼠标生成 Ray #

假设用户点击:

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);

五、为什么不能遍历所有 Triangle #

假设一个大型 CAD 模型包含:

50,000,000 triangles

如果每次鼠标点击都执行:

for each triangle:
    RayTriangleIntersect(ray, triangle)

计算量显然过大。

因此通常需要一个空间加速结构: BVH,即 Bounding Volume Hierarchy。


六、利用 BVH 加速 Picking #

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

七、Ray / Triangle 求交 #

最终 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。


八、从 Triangle 找到 Part #

假设 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) ]


十、另一种方式:把 PartId 直接存进 Geometry #

还可以给 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 之后如何高亮 Part #

假设 Picking 已经得到:

EntityId = 1002

也就是:

Part B

接下来希望:

Part B → 黄色
其他 Part → 原来的颜色

这里有多种实现方式。


十二、方案一:Shader 根据 PartId 高亮 #

假设 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 本身。


十三、为什么 PartId 应该使用 flat #

普通 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。


十四、如果同时选中很多 Part 怎么办 #

单个选中时:

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。


十五、方案二:Overlay Mesh 高亮 #

另一种非常实用的方案是:

选中哪个 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
     ↓
黄色高亮

两者叠加

优点:

  • 实现简单;
  • 不需要修改主材质;
  • 很适合描边;
  • 很容易做独立 Selection Pass。

缺点:

  • 增加 Draw Call;
  • 大量选中对象时成本上升。

十六、方案三:Selection Mask / ID Buffer #

大型 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。


十七、CPU Ray Picking 和 GPU Picking 如何选择 #

两种方法各有特点。

CPU Ray + BVH #

Mouse
 ↓
Ray
 ↓
BVH
 ↓
Triangle
 ↓
EntityId

优点:

  • 可以得到真实 3D Position;
  • 可以得到 Triangle;
  • 可以得到法向;
  • 可以继续做 Snap;
  • 非常适合 CAD。

例如:

点击 Face
捕捉 Edge
捕捉 Vertex
测量
绘制辅助线

通常都需要几何信息。


GPU ID Picking #

Mouse
 ↓
Framebuffer Pixel
 ↓
EntityId

优点:

  • 不需要 Ray/Triangle 大规模计算;
  • 适合纯 Object Selection;
  • 与最终屏幕视觉结果一致。

缺点是:

它通常只告诉你“屏幕这个像素属于哪个对象”。

如果接下来还要:

最近 Edge
最近 Vertex
Surface UV
3D Snap Point

还是需要进一步几何查询。

因此 CAD 系统往往不会只依赖一种方式。


十八、CAD Picking 通常是分层的 #

实际 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 是同一个思想。


十九、渲染 Triangle 如何映射回 BRep Face #

对于真正的 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 来说非常重要。


二十、Edge 和 Vertex Picking 怎么办 #

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 周围

二十一、Geometry Merge 并不是越大越好 #

假设:

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
      ↕
编辑能力

之间取得平衡。


二十二、Instancing 又是另一种情况 #

如果很多零件 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 Render Model #

一个大型 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 之间的可追溯关系。

(完)

目录