GIS
矢量与实体
前情回顾:06 相机与拾取 讲清了 RTE 高精度相机、Picking 颜色编码和球面插值飞行。当用户点击地图上的点、线、面时,这些矢量数据是怎么被 GPU 高效渲染的?本篇回答:GIS 矢量数据在 GPU 上的表达与渲染。
直觉问题
打开一个支持标绘的 Web 地图,新建一个场景:
- 在地图上点一个红色标记——这个标记本质上是什么几何体?是一个二维图片贴到三维球面上?还是一个三维模型?
- 沿着航线画一条绿色折线——折线是由无数小线段拼接而成的。如果航线从纽约到东京(跨越半个地球),折线需要多少个点?如果把每个点都传给 GPU 单独渲染,性能会怎样?
- 标记了一个危险区域(半透明红多边形)——这个多边形在球面上不是平的。它的几何定义和平面几何有什么不同?
- 场景中有 10 万个标记点——如果每个标记都是一个独立网格传给 GPU,GPU 开销会很大。有没有一种方法让 GPU 批量处理这些点?
读完本篇,你能回答:GPU 实例化为什么能大幅改善大量同类型实体的渲染性能?SDF 字体渲染和传统字体纹理有什么区别?Billboard 为什么叫永远面向相机?
核心概念白话讲
GIS 矢量数据模型:点、线、面
GIS 中所有的矢量数据都可以归结为三种几何基元:
| 类型 | 数学定义 | 用途 |
|---|---|---|
| 点 (Point) | 一个 (x, y, z) 或 (lon, lat) 坐标 | 标记位置、POI、图标 |
| 线 (LineString) | 有序点序列 [P_0, P_1, …, P_n] | 航线、轨迹、边界线 |
| 面 (Polygon) | 闭合点序列 + 可选内点 (holes) | 区域、多边形面、行政区划 |
NOTE
关键认知:矢量用坐标点序列定义形状,栅格用像素网格定义图像。矢量放大不失真,栅格放大会模糊。
ECS 架构:把实体拆成零件
传统面向对象(OOP):每个地图上的标记是一个完整对象——位置、颜色、大小、旋转全部封装在一起。当需要更新 10 万个标记的颜色时,CPU 要逐个对象访问,数据在内存中分散,cache 命中率低。
ECS(Entity-Component-System)把对象拆开:
- Entity:一个 ID(空壳,不携带数据)
- Component:纯数据(位置、颜色、大小),同类型连续存储
- System:纯逻辑,批量处理同类型 Component
为什么 ECS 适合大规模场景?
| 维度 | OOP | ECS |
|---|---|---|
| 数据布局 | 分散(每个对象独立 malloc) | 连续(SoA,Structure of Arrays) |
| CPU Cache | 命中率高 | 高(数组连续访问) |
| 批量更新 | 逐个遍历 | System 一次读取整个数组 |
| GPU 友好 | 需手动整理 | 可直接生成 instance buffer |
| 代码耦合 | 高(继承链深) | 低(组合优于继承) |
NOTE
SoA 为什么快? CPU 从内存读取数据时,会把目标地址附近 64 字节全部拉入 L1 Cache(Cache Line)。如果 Position 数组是连续的,CPU 一次 cache miss 后,后续 16 个 Position 都在 cache 里。而 OOP 的对象成员分散在不同内存页,每个对象都要重新寻址。
GPU 实例化:一个模板画 N 次
传统方式:10,000 次 draw call,每次传一次几何数据——CPU 大部分时间花在了跟 GPU 通信上。
实例化方式:1 次 draw call,传一个位置数组告诉 GPU 在这个位置画 10,000 次。
更加重要的是:每个实例可以有不同的属性
实例化不是”完全相同的 10,000 个克隆”。通过 Instance Attributes,每个实例可以有自己独立的数据:
模板几何:一个 Billboard 四边形(4 个顶点,2 个三角形)
Per-Instance Attributes(每个实例各不同):
- instance_position : vec3f -> 世界坐标
- instance_color : vec4f -> 颜色(RGBA)
- instance_scale : vec2f -> 宽高缩放
- instance_rotation : f32 -> 绕法线旋转
GPU 执行时(伪代码):
for each instance in instances:
for each vertex in template:
transform = instance.TRS * vertex.local
输出 vertex_color = instance.color
TIP
点题:实例化不是克隆 10,000 个”完全相同”的物体,而是让 GPU 用同一套几何模板,在每个实例自己的位置上画一次。每个实例的 instance_color、instance_scale 等属性不同,所以屏幕上的标记看起来颜色、大小各不相同——但底层只发起 1 次 draw call。
SDF/MSDF:字体的数学等高线图
传统字体渲染:把每个字符画成小图片,放大就模糊。
SDF (Signed Distance Field):存每个像素到字形边界的距离。距离 = 0 在边界,> 0 在内部,< 0 在外部。渲染时看距离值就知道像素颜色,放大也不模糊。
MSDF (Multi-channel SDF):用 R、G、B 三个通道分别存距离,解决 SDF 在锐角处的模糊问题。
Billboard:永远面向相机的纸片
Billboard 是一个始终面向相机的平面。标记点和文字标签用 Billboard,保证用户看到的是正面。球面 Billboard 所有轴自由旋转;圆柱面 Billboard 只能绕 Y 轴旋转(保持直立)。
原理与数学/机制
1. GPU 实例化数学
1.1 实例化矩阵:TRS
每个实例有变换矩阵把模板几何变换到世界位置:
Translation 矩阵 T:
Rotation 矩阵 R_y(alpha)(绕 Y 轴):
Scale 矩阵 S:
1.2 实例化 vs 非实例化:性能对比
| 场景 | 实体数量 | 非实例化 Draw Calls | 实例化 Draw Calls | 性能提升 |
|---|---|---|---|---|
| 机场标记点 | 10,000 | 10,000 | 1 | 10000x |
| 城市名称 | 5,000 | 5,000 | 1 | 5000x |
| 航线(200条x50点) | 200 | 200 | 1 | 200x |
| 行政区划面 | 300 | 300 | 1 | 300x |
IMPORTANT
核心洞见:实例化的瓶颈不在 GPU 计算能力,而在 CPU-GPU 通信开销。每次 draw call 都有固定 CPU 开销。实例化把 N 次 draw call 压缩到 1 次,消除了这个瓶颈。
2. SDF 字体渲染的数学原理
2.1 有向距离场的定义
对于字形图像,定义距离场 phi(p):
其中 p 是像素位置,Omega 是字形区域,partial Omega 是字形边界,d 是欧氏距离。
2.2 SDF 抗锯齿渲染
渲染时根据距离场判断颜色:
边缘自动线性插值,抗锯齿,放大不模糊。
2.3 MSDF:解决锐角问题的多通道方案
| 方案 | 通道 | 优点 | 缺点 |
|---|---|---|---|
| 传统纹理 | 1 (灰度) | 简单、兼容性好 | 放大模糊 |
| SDF | 1 (距离) | 连续、缩小好 | 锐角处失真 |
| MSDF | 3 (R/G/B) | 锐角清晰、连续 | 生成复杂、需预处理 |
3. Billboard 几何变换
3.1 Billboard 变换矩阵
设 C 为相机世界位置,B 为 Billboard 世界位置,U 为世界 up 向量 (0, 1, 0)。
Billboard 的 local-to-world 变换:
Billboard 的旋转矩阵就是新基底 [x’, y’, z’]:
NOTE
直观理解:想象你拿着一张纸,无论如何转身,这张纸都正对着你的眼睛。
3.2 球面 Billboard vs 圆柱面 Billboard
| 类型 | 旋转约束 | 适用场景 |
|---|---|---|
| 球面 Billboard | 所有轴自由旋转 | 点状图标、标签 |
| 圆柱面 Billboard | 只能绕世界 Y 轴旋转 | 人物、树木(保持直立) |
4. 矢量线在 GPU 上的渲染
矢量线不是简单地把相邻点连起来——那样线宽只有 1 像素,在远距离或斜视角下会消失。GPU 上的矢量线渲染需要解决两个核心问题:
4.1 线宽展开:从线段到多边形
将一条线段 (P_0, P_1) 展宽成四边形或更复杂的多边形mesh:
P0 ------------ P1 P0' -------- P1'
| 线宽=w | 线宽=w 展开成四边形
n 法线方向 P0'' -------- P1''
对于连接处(Join)还需要处理:
| Join 类型 | 描述 | 视觉效果 |
|---|---|---|
| Miter | 延长边到交点 | 锐角处可能无限长 |
| Bevel | 直接截断 | 稳定、不突兀 |
| Round | 圆弧过渡 | 最自然、最贵 |
4.2 Line SDF:GPU 上的抗锯齿线
更现代的做法是在 GPU 中用 SDF 画线:
- 将线段的每一点传入 GPU 作为实例化数据
- Fragment Shader 中计算当前像素到线段的距离
- 根据距离 smoothstep 得到半透明边缘
这种方案的优点:线宽是数学精确的,抗锯齿免费,拐角可以自动圆角化。
5. 球面多边形的渲染挑战
还记得直觉问题中的疑问吗——“多边形在球面上不是平的,它的几何定义和平面几何有什么不同?” 这里直接回答:
5.1 平面多边形 vs 球面多边形
| 特性 | 平面多边形 | 球面多边形 |
|---|---|---|
| 所在空间 | 欧几里得平面 | 球面表面 |
| 边 | 直线段 | 大圆弧(测地线) |
| 内角和 | = (n - 2) * 180 deg | > (n - 2) * 180 deg |
| GPU 渲染 | 直接 triangulate | 需细分成小三角形贴合球面 |
NOTE
为什么球面多边形的内角和更大? 想象在地球表面画一个三角形:从北极出发,沿经线到赤道,再沿赤道到另一条经线,最后回到北极。三条边都是大圆弧,但三个角都是 90 deg,内角和 = 270 deg > 180 deg。这就是球面几何与平面几何的本质区别。
5.2 GPU 上的处理策略
球面多边形不能直接作为一个平面多边形渲染,否则会出现以下问题:
- 边不贴球面:平面多边形的边是直线,而球面上最短路径是大圆弧
- 深度冲突:平面多边形的一部分可能切入球体内部,导致 z-fighting
解决方案——曲面细分(Tessellation):
- 将多边形的每条边沿大圆弧细分为若干短边
- 将细分后的多边形 triangulate 成小三角形
- 每个小三角形的顶点投影到球面上
- GPU 渲染这些小三角形,近似球面多边形
可视化对比与动手实验
实验 1:实例化 vs 非实例化性能
| 方案 | CPU 时间 | GPU 时间 | Draw Calls | 帧率 (60Hz) |
|---|---|---|---|---|
| 非实例化 | ~16ms | ~2ms | 10,000 | 掉帧 (约 15fps) |
| 实例化 | ~1ms | ~2ms | 1 | 流畅 (60fps) |
实验 2:SDF 字体放大对比
| 渲染方案 | 放大 2 倍 | 放大 5 倍 | 放大 10 倍 |
|---|---|---|---|
| 传统纹理图 | 轻微模糊 | 明显锯齿 | 像素化严重 |
| SDF | 清晰 | 清晰 | 轻微模糊 |
| MSDF | 清晰 | 清晰 | 清晰 |
实验 3:Billboard 视角对比
| 场景 | 无 Billboard | 有 Billboard |
|---|---|---|
| 俯瞰 | 正常 | 正常 |
| 侧视 | 图标倾斜 | 正对视线 |
| 仰视 | 图标倒转 | 正对视线 |
实验 4:矢量数据格式对比
| 格式 | 几何表达 | 属性支持 | 坐标系 | 优劣势 |
|---|---|---|---|---|
| GeoJSON | JSON | 完整 (properties) | WGS84 默认 | 可读、Web 友好、无拓扑 |
| Shapefile | 二进制 + dBASE | 完整 | 需指定 EPSG | 工业标准、多文件 |
| WKT/WKB | 文本/二进制 | 无(纯几何) | 需结合 SRID | 轻依附标准、无属性 |
| KML | XML | 完整 | WGS84 | Google Earth 原生、XML 臃肿 |
| TopoJSON | 拓扑编码 | 完整 | WGS84 | 共享边界、文件小、需解码 |
常见误区
WARNING
误区 1:实例化只能用于完全相同的实体 实例化支持每个实例有不同的变换(位置、旋转、缩放),甚至可以通过 instance attributes 传入每实例的颜色、大小等数据。关键是几何形状相同。
WARNING
误区 2:SDF 字体没有锯齿 SDF 的不模糊是指距离场本身是数学连续的。但渲染时采样分辨率有限,字体太小(< 8px)时依然会有锯齿。
WARNING
误区 3:Billboard 是最省性能的方案 Billboard 比 3D 模型简单,但每个 Billboard 依然需要一次 draw call(除非实例化)。10,000 个 Billboard 不实例化照样卡。
WARNING
误区 4:矢量线就是一系列点连起来 矢量线在 GPU 上不是简单连接相邻点,而是展宽成多边形带状区域或用 SDF 计算。否则线只有 1 像素宽,在不同视角下会消失。
延伸阅读与自测
延伸阅读
- OGC Simple Feature Access Standard:https://www.ogc.org/standards/sfa — GIS 矢量数据的权威标准
- GPU Instancing:https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext/drawArraysInstanced — WebGL2 实例化 API
- Signed Distance Fields:https://css-tricks.com/the-different-ways-to-generate-a-sdf/ — SDF 生成方法
- MSDF Font Rendering:https://github.com/Chlumsky/msdfgen — Viktor Chlumsky 的 MSDF 工具
- Line Rendering in WebGL:https://mattdesl.svbtle.com/drawing-lines-is-hard — WebGL 矢量线渲染的技术挑战
自测题
-
计算题:一个有 10,000 个三角形组成的 3D 模型,实例化 1000 次,GPU 实际处理的三角形数是多少?draw call 次数是多少?
-
概念辨析:为什么说实例化渲染的瓶颈是 CPU-GPU 通信而不是 GPU 计算能力?
-
数学推导:给定点 和世界 up 向量 U = (0, 1, 0),推导球面 Billboard 的 [x’, y’, z’] 基底。如果相机在 (0, 0, R),Billboard 在 (R, 0, 0),直接写出变换矩阵。
-
方案对比:在需要显示 10 万个动态文字标签的 3D 地图中(大小 12px~48px,UTF-8 中英文混合),你会选择传统纹理图集、SDF 还是 MSDF?为什么?
-
设计题:设计一个矢量标绘系统,支持点、线、面三种基元,支持 100,000 个点的实时渲染,点在球面上 Billboard 渲染,线宽在屏幕空间固定。写出 GPU 侧的伪代码和数据结构设计。
下一篇预告:08 渲染管线 将深入 GPU 渲染管线的核心——从 8 步帧循环到 Tone Mapping 色彩管理,再到 Rayleigh/Mie 大气散射的物理原理,带你理解”一张漂亮的 3D 地图是如何从一堆shader和纹理变成屏幕上的像素的”。