WebGL 没有几何着色器,那 Cesium 的粗线是怎么画出来的?
从
gl.lineWidth失效说起,拆解 CesiumJS 1.141 → 1.145 的四条宽线实现路径,并修复一个至今空着、只需几行就能补上的缺口。
一、一个让人困惑的下午
你写下了这段再正常不过的代码:
js
gl.lineWidth(16);
gl.drawArrays(gl.LINES, 0, vertexCount);
然后屏幕上出现了一条 1 像素的细线。
不是 2 像素,不是 8 像素,是 1 像素。你以为是自己写错了,翻文档、换浏览器、查 StackOverflow,最后在 MDN 的角落里看到一句轻描淡写的话:
线宽大于 1 的实现取决于平台,多数桌面与移动 GPU 只支持 1。
这不是 bug,是"历史遗留的合法行为"。想验证很容易:
js
console.log(gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE)); // 多半是 [1, 1]
OpenGL 早年允许驱动用「把线画成细长矩形」来实现粗线,但斜接、端点、抗锯齿的语义一直含混。到了 OpenGL 3.2 Core Profile,大于 1 的线宽被正式标记为废弃;到了 ANGLE(Windows 上 Chrome 的 WebGL 后端,把 GLES 调用翻译成 D3D),干脆只实现 1。于是"加粗线条"这件在 Canvas 2D 里一行 lineWidth = 16 就能搞定的事,在 WebGL 里变成了一个需要自己造轮子的工程问题。
而 Cesium ------ 这个要在地球上画航迹、画国界、画管线、画模型轮廓的三维地球引擎 ------ 把这件事做到了极致。
二、先搞清楚:WebGL 到底有几级着色器
做图形的人看到"粗线",第一反应往往是:用几何着色器(GS)把 GL_LINES 吹成四边形。这是桌面 OpenGL 的经典解法,几十行 GS 就能得到宽度准确的线段,还能顺手处理斜接和端点。
这条路在 WebGL 上是断的。
完整的桌面 OpenGL 管线有五级:
顶点着色器 VS → 细分控制 TCS → 细分评估 TES → 几何着色器 GS → 片元着色器 FS
而 WebGL 2 基于的是 OpenGL ES 3.0,只有两级:
顶点着色器 VS → 片元着色器 FS
几何着色器和细分着色器要到 OpenGL ES 3.2 才被引入,而 WebGL 从未跟进;计算着色器(CS)同样缺席。这不是 Cesium 的取舍,是 WebGL 的天花板。
翻一下 Cesium 源码就可以确认这一点。ShaderProgram.js 的 createAndLinkProgram() 里,从头到尾只出现了两种 shader type:
js
// packages/engine/Source/Renderer/ShaderProgram.js
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
全库搜索 GEOMETRY_SHADER、TESS_、COMPUTE_SHADER,零命中。
顺带说一句版本的账。Cesium 的 Context.js 里 getWebGLContext() 默认请求 WebGL2 ,只在两种情况下回退 WebGL1:显式传 requestWebgl1: true,或浏览器不支持 WebGL2。WebGL1 目前仍在支持,但已在收紧 ------ 1.140 起 billboard/label 要求 WebGL2,或 WebGL1 + ANGLE_instanced_arrays + MAX_VERTEX_TEXTURE_IMAGE_UNITS > 0。另外,这个仓库里没有 WebGPU 后端 (全库搜不到 navigator.gpu)。
还有个容易被忽略的细节:Cesium 的着色器源码统一按 GLSL 3.00 ES 编写,ShaderSource.combineShader() 在 WebGL2 下前置 #version 300 es,WebGL1 下走 demodernizeShader.js 降级到 #version 100 ------ 用正则把 in 换成 attribute、texture 换成 texture2D、out_FragColor 换成 gl_FragColor。注意它只覆盖 Cesium 自己用到的语法子集,不是完整的转译器。
结论 :既然没有 GS,那"线变粗"这件事,必须在 CPU 端或 VS 端把线预先展开成三角形。Cesium 也正是这么做的。
三、Cesium 的三套宽线方案
方案 ① 屏幕空间四边形展开(主力)

这是 PolylineCollection 的做法,也是 three.js 的 Line2、Mapbox GL 的宽线所采用的思路。精华全在 Shaders/PolylineCommon.glsl 的 getPolylineWindowCoordinatesEC() 里。
每段线 4 个顶点(起点两侧 + 终点两侧),索引 (b, b+2, b+1) (b+1, b+2, b+3),用 TRIANGLES 绘制。每个顶点额外携带它的前一个和后一个邻点,然后 VS 里做五件事:
glsl
// 1) 端点及其前后邻点投到窗口坐标(像素)
vec2 pWC = toWindow(clipP);
vec2 prevWC = toWindow(clipPrev);
vec2 nextWC = toWindow(clipNext);
// 2) 本段方向:两端点处都指向 A→B
thisFwd = usePrev ? normalize(pWC - prevWC) : normalize(nextWC - pWC);
otherFwd = usePrev ? normalize(nextWC - pWC) : normalize(pWC - prevWC);
// 3) 斜接方向 = 两条左侧法线之和再归一化
leftWC = normalize(left(thisFwd) + left(otherFwd));
// 4) 斜接补偿 宽度 / sin(夹角),并做上限钳制
sinA = abs(u.x * leftWC.y - u.y * leftWC.x);
halfW = clamp(uHalfWidth / sinA, 0.0, uHalfWidth * miterLimit);
// 5) 像素空间偏移后,乘回 w 还原裁剪空间
gl_Position = viewportOrtho * vec4(pWC + leftWC * side * halfW, -zWin, 1.0) * clipP.w;
第 4 步是斜接(miter)的本质:拐角处要让两条线段的外边缘 也接上,偏移量必须放大 1 / sin(夹角)。夹角越尖,放大倍数越夸张,所以需要一个上限 ------ Cesium 钳制到 width * 2,相当于 miterLimit = 4。注意它超限时是把斜接削平,而不是像 Canvas 2D 那样降级成 bevel,这是个实现上的简化。
第 5 步的 * clipP.w 是全段最精妙的一笔。窗口坐标是像素,但 gl_Position 需要裁剪空间坐标,而裁剪空间要参与透视除法。viewportOrtho 输出的 w 就是输入向量的 w,所以乘上 clipP.w 之后,透视除法会把这个因子原样约掉 ------ NDC 保持不变,深度值却依然是透视正确的。
代价是:varying 从此变成透视插值 。横跨线宽的那个参数(Cesium 里叫 v_st.t)如果直接插值,在透视下会歪。这就是 czm_writeNonPerspective / czm_readNonPerspective 这对函数存在的理由:
glsl
// VS:写出去之前乘 w
v_st.t = czm_writeNonPerspective(clamp(expandDir, 0.0, 1.0), gl_Position.w);
// FS:读进来时再除回去(乘以 gl_FragCoord.w 即 1/w)
float t = czm_readNonPerspective(v_st.t, gl_FragCoord.w);
顺带提一下,这个函数里还有 clipLineSegmentToNearPlane(),用来避免线段跨越近平面时窗口坐标爆炸 ------ 这类边界处理才是工程实现和玩具 demo 的分水岭。
方案 ② NDC 空间展开(glTF 模型边)
Shaders/Model/EdgeVisibilityStageVS.glsl 用于 glTF 的边可见性渲染。同样的思路,但大幅简化:直接在 NDC 里偏移,没有近平面裁剪。
glsl
posClip.xy += perpNDC * lineWidth * clipPerPixel * 0.5 * a_edgeOffset;
方案 ③ 阴影体 + 片元裁剪(贴地折线)
GroundPolylinePrimitive 面对的是一个特殊难题:线要贴着地形起伏,没法简单地展开成四边形。它的解法是先建一个"墙"状几何,再在 FS 里用 czm_planeDistance() 算出片元到三个裁剪平面(右侧面、起点面、终点面)的距离,超宽就 discard。
四、1.145 里真正的新东西
读到这儿你可能会以为,BufferPolylineCollection 带来了什么新算法。并没有。
我把 1.141 和 1.145 的 PolylineCommon.glsl 逐字节 diff 过 ------ 只有换行符(CRLF/LF)的差异,算法一字未改。它真正的价值在三处工程改良;而 1.145 里真正的新东西,是另一套并存的方案。
改良一:顶点数从 4N 降到 2(N+1),省一半

这是最值得直接抄走的一条。
PolylineCollection 每段写 4 个顶点,于是内部顶点在相邻两段里各被写了一次 。BufferPolylineCollection 改成每个点只写 2 个展开顶点,strip 式索引:
js
// renderBufferPolylineCollection.js
if (!isLastSegment) {
indexArray[iOffset] = vOffset;
indexArray[iOffset + 1] = vOffset + 1;
indexArray[iOffset + 2] = vOffset + 2;
indexArray[iOffset + 3] = vOffset + 2;
indexArray[iOffset + 4] = vOffset + 1;
indexArray[iOffset + 5] = vOffset + 3;
}
for (let k = 0; k < 2; k++) { /* 每个顶点只写 2 次 */ }
1000 段的折线:4000 个顶点降到 2002 个,索引数完全不变。
数学依据是什么?斜接方向是两段的角平分线 ,所以 sinAngle 在 usePrevious 取真取假两种情况下恒等 ------ 那 4 次写入确实是冗余的。我在 20°/60°/90°/120°/170°/190°/260° 七种拐角下验证过,两种写法的顶点偏移差 < 1e-14。
有意思的是,Cesium 源码里对此还留着一句存疑的注释:
// TODO(donmccurdy): Diverging from PolylineCollection.js, which writes internal vertices to buffer 4x, not 2x. Not sure that's needed?
不需要。这个改动是对的。
改良二:gl_VertexID % 2 代替展开方向属性
glsl
float expandDir = gl_VertexID % 2 == 1 ? 1.0 : -1.0;
省掉一个顶点属性。代价是要求 WebGL2 / GLSL 300 es。
改良三:米制宽度用负号编码
想支持 widthUnits: "meters",最直接的办法是加一个 attribute 或 uniform 标记单位。Cesium 用了个更省的技巧 ------ 用符号编码:
js
// CPU:负值代表单位为米
const signedWidth = widthInMeters ? -material.width : material.width;
glsl
// GPU:负号既是标记,也自带转换
float width = abs(signedWidth);
if (signedWidth < 0.0) {
width /= max(czm_metersPerPixel(positionEC), czm_epsilon7);
}
零额外 attribute,czm_metersPerPixel 会在该顶点的深度处把米换算成像素 ------ 于是宽度随距离自然缩放。
真正的创新:VectorCommon.glsl 的 SDF 方案(1.144 引入,1.145 加抗锯齿)

这套思路和前面三套完全不同 ------ 零几何。
线段被打包进 RGBA 浮点纹理(1 texel 存一段,内容是 ax, ay, bx, by),然后在已有表面的片元着色器末尾 直接叠加。全库只有两个调用点:GlobeFS.glsl(地形)和 ModelVectorLookupStageFS.glsl(3D Tiles)。
四个关键设计:
① 网格加速用 CSR 格式。 gridSize = ceil(sqrt(N/16)),头部存 [gridW, gridH, end_0, end_1, ...],段按膨胀包围盒 分配到所有重叠的 cell,padding = max(0.35/gridSize, halfWidthInUv)。没有数据的 cell 用 1×1 占位纹理作哨兵。
② 线宽恒定靠雅可比矩阵。 在贴图的 UV 空间里画线,要保证屏幕宽度恒定,就得知道"UV 偏移一个单位,屏幕偏移多少像素":
glsl
mat2 screenFromUv = inverse(mat2(dFdx(uv), dFdy(uv)));
这一步天然处理了斜视压缩 ------ 地形被压扁时,UV 到屏幕的缩放比会自动变化。源码里有条明确注释提醒:dFdx/dFdy 必须在 uniform control flow 中调用。
③ 抗锯齿。 coverage = 1 - smoothstep(-0.5, 0.5, edgeDistance),可通过 scene.vectorProvider.antialias 关闭。
④ 只取最近段。 相邻段在共享顶点处会重叠,逐段合成会让接缝颜色失真(亮线 0.5 → 0.75 变亮,暗线 0.5 → 0.25 变暗)。取 min 距离既避免重复合成,又能提前 break。
而 min 距离这个选择带来了一个漂亮的副产品:距离场的并集就是 min,所以拐角和圆头端点天然正确,完全不需要任何斜接计算。 回想方案①里那一整套斜接几何 ------ 在这里一行都不需要写。
五、一个至今空着的缺口
我们来做一道简单的推理题。
BufferPolylineMaterialVS.glsl 里,VS 辛辛苦苦算出了横跨线宽的参数并传了下来:
glsl
v_st.t = czm_writeNonPerspective(clamp(expandDir, 0.0, 1.0), gl_Position.w);
那么 BufferPolylineMaterialFS.glsl 用它做了什么?
glsl
in vec4 v_pickColor;
in vec4 v_color;
void main()
{
if (v_color.a < 0.005) { discard; }
out_FragColor = czm_gammaCorrect(v_color);
czm_writeLogDepth();
}
什么都没做。 全文 13 行,只做 alpha 剔除和 gamma 校正,v_st 连声明都没有。
也就是说,BufferPolylineCollection 至今是硬边,抗锯齿只能靠画布 MSAA ------ 而 MSAA 在部分移动设备和离屏渲染场景下并不可用。
补上它只需要几行。既然 v_st.t 横跨线宽从 0 到 1,那么到中线的距离就是 abs(v_st.t - 0.5) * width:
glsl
// 更稳的做法:让 VS 直接传像素单位的偏移与半宽(均已非透视插值)
float d = abs(v_offPix);
float a = clamp(v_halfW + 0.5 - d, 0.0, 1.0);
if (a <= 0.0) discard;
out_FragColor = vec4(color, a);
这里有个细节:几何展开的半宽要额外留出 0.5 * dpr 的余量(feather),否则羽化带的外半边会被四边形边缘裁掉,边缘处 alpha 会卡在 0.5 ------ 抗锯齿做了一半。
我在 Demo 里把这条路径做成了「模式 C」,可以和硬边模式逐像素对比。
六、可借鉴性评估
| 做法 | 判断 | 说明 |
|---|---|---|
| 2(N+1) 顶点 + strip 索引 | 直接抄 | 纯收益,低风险,长折线省约 44% 顶点内存与 VS 调用 |
gl_VertexID % 2 取展开方向 |
可抄 | 要求 WebGL2 / GLSL 300 es |
| 负号编码宽度单位 | 视场景 | 省 attribute,但可读性和调试成本上升 |
| CSR 网格 + 段纹理 | 看数据形态 | 适合大量静态线贴已有表面;动态线每帧重建纹理反而更贵 |
| 雅可比恒定线宽 | 最值得单独借鉴 | 任何"在贴图空间里画固定屏幕宽度"的需求都能用 |
| SDF 抗锯齿 | 优于 MSAA | 不依赖画布设置,边缘质量可控 |
七、动手验证
读源码容易"以为自己懂了",所以我配了一个可交互实验台和一批数值验证脚本。Demo 里可以拖拽顶点、实时调线宽与 miterLimit,并叠加原生 GL_LINES (红色)做对照 ------ 你会看到无论 gl.lineWidth 设多大,它都是 1px。
四种渲染路径可以任意切换:
| 模式 | 对应实现 | 特点 |
|---|---|---|
| A · 硬边展开 | Cesium 1.141 / 1.145 原样 | 斜接补偿,硬边,靠 MSAA |
| B · 段距离场 | 增强版 | 抗锯齿 + 圆角连接 + 圆头端点 |
| C · 跨宽羽化 | 补上 1.145 的缺口 | 只用 v_st.t,最轻,不依赖 MSAA |
| D · 零几何 SDF | 1.145 VectorCommon.glsl |
无几何,min 距离并集,拐角圆头天然正确 |
顶点布局可切「4N」与「2(N+1)」,右上角实时显示顶点数、索引数与节省比例;宽度单位可切「像素」与「米」,切到透视视角旋转一下,能直观看到屏幕恒定宽度与米制随距离缩放的区别。
数值验证共三个脚本、合计 87 项断言,全部通过:
_verify.mjs(15 项):viewportOrtho的 NDC 映射、*w后 NDC 不变、90° 拐角斜接 =halfW·√2、miterLimit 钳制、索引构建。_verify_145.mjs(38 项):展开等价性、顶点规模、索引结构、米制换算、CSR 索引范围与空 cell 哨兵、雅可比往返一致性、SDF 覆盖单调性、网格 padding 主导项。_verify_layout.mjs(34 项):两种布局的三角形语义等价、strip 索引引用计数、七种夹角下usePrevious恒等、非透视插值还原、羽化几何覆盖。
其中我最想强调的是 _verify_layout.mjs 的第 3 组 ------ 它用数值证明了「内部顶点写 4 次属于冗余」,这条结论直接回应了 Cesium 源码里那句存疑的 TODO。
八、最后
回过头看,WebGL 宽线这件事的演进脉络其实很清晰:
gl.lineWidth不可用 ------ 平台限制,无解;- 几何着色器不可用 ------ WebGL 只有 VS + FS,无解;
- 于是只能在 VS 里把线吹成四边形,代价是斜接几何、近平面裁剪、非透视插值这一整套复杂度;
- Cesium 1.145 的做法是:老路径做减法 (顶点减半、属性减一),新路径换范式(干脆不要几何,在 FS 里用距离场画)。
第 4 步里的"换范式"最值得琢磨。当你发现一个问题的解法需要堆一大堆边界处理时(斜接、miterLimit、近平面裁剪、透视插值修正......),也许该问的不是"怎么把这些做对",而是"能不能换个地方算"。距离场把问题从"生成正确的几何"变成了"判断每个像素离线段多远" ------ 后者的复杂度是常数级的,而且拐角、端点、抗锯齿全都免费附送。
至于那个空着的 v_st.t,如果你正好在做基于 Cesium 的宽线渲染,这大概是目前投入产出比最高的一次改动。
相关源码位置 (CesiumJS 1.145,packages/engine/Source)
bash
Renderer/Context.js WebGL 上下文创建与版本回退
Renderer/ShaderProgram.js 只有 VS + FS 两级
Renderer/demodernizeShader.js GLSL 300 es → 100 降级
Shaders/PolylineCommon.glsl 屏幕空间展开 + 斜接 + 近平面裁剪
Shaders/BufferPolylineMaterialVS.glsl 1.145 折线 VS(gl_VertexID、负号编码)
Shaders/BufferPolylineMaterialFS.glsl 1.145 折线 FS(13 行,v_st.t 未使用)
Shaders/VectorCommon.glsl 零几何 SDF 方案
Scene/renderBufferPolylineCollection.js 顶点/索引构建(2(N+1) + strip)
Core/VectorPipeline.js CSR 网格与段纹理打包
Shaders/Model/EdgeVisibilityStageVS.glsl NDC 空间展开
Scene/GroundPolylinePrimitive.js 阴影体 + 片元裁剪
注:
BufferPolylineCollection标注为@experimental,API 可能在后续版本变动。