WebGL 没有几何着色器,那 Cesium 的粗线是怎么画出来的?

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.jscreateAndLinkProgram() 里,从头到尾只出现了两种 shader type:

js 复制代码
// packages/engine/Source/Renderer/ShaderProgram.js
const vertexShader   = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

全库搜索 GEOMETRY_SHADERTESS_COMPUTE_SHADER,零命中。

顺带说一句版本的账。Cesium 的 Context.jsgetWebGLContext() 默认请求 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 换成 attributetexture 换成 texture2Dout_FragColor 换成 gl_FragColor。注意它只覆盖 Cesium 自己用到的语法子集,不是完整的转译器

结论 :既然没有 GS,那"线变粗"这件事,必须在 CPU 端或 VS 端把线预先展开成三角形。Cesium 也正是这么做的。


三、Cesium 的三套宽线方案

方案 ① 屏幕空间四边形展开(主力)

这是 PolylineCollection 的做法,也是 three.js 的 Line2、Mapbox GL 的宽线所采用的思路。精华全在 Shaders/PolylineCommon.glslgetPolylineWindowCoordinatesEC() 里。

每段线 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 个,索引数完全不变

数学依据是什么?斜接方向是两段的角平分线 ,所以 sinAngleusePrevious 取真取假两种情况下恒等 ------ 那 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 宽线这件事的演进脉络其实很清晰:

  1. gl.lineWidth 不可用 ------ 平台限制,无解;
  2. 几何着色器不可用 ------ WebGL 只有 VS + FS,无解;
  3. 于是只能在 VS 里把线吹成四边形,代价是斜接几何、近平面裁剪、非透视插值这一整套复杂度;
  4. 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 可能在后续版本变动。

相关推荐
用户61595868000222 天前
Cesium 入门系列(四):区域高亮显示的两种实现方案
cesium
用户61595868000222 天前
Cesium 入门(三):BaseLayerPicker 底图切换与国产地图接入
cesium
用户61595868000222 天前
Cesium 入门(二):Geocoder 搜索框参数详解与天地图搜索接入
cesium
灵境(虚幻知音)3 天前
Cesium动态轨迹性能瓶颈深度拆解:翼带与尾迹的底层优化实践
性能优化·cesium·3d引擎·afsim·翼带·尾迹·自定义着色器
fxshy5 天前
WebGIS 游戏化实践:基于 Cesium + Vue3 实现全球飞行模拟系统:从球体坐标、飞行动力学到地形碰撞实战
游戏·vue3·cesium·webgis·飞行模拟
用户83134859306986 天前
Cesium实现动态流动火烧云晚霞效果(可用slider调整火烧云浓度)
vue.js·webgl·cesium
CBX8 天前
Cesium 入门实战:GeoJSON 数据加载与地区边界可视化
cesium
毕安格 - BimAngle14 天前
国家电网 GIM 格式模型一键输出 3D Tiles (for Cesium) 和 glTF/glb 更新时间:2026-08-18
3d·gis·cesium·gltf·glb·3d tiles·gim
探索前端15 天前
3dtiles加载时被地形遮挡问题研究及处理思路
前端·3d·cesium