- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
本指南源于 webgl-fundamentals 项目 Q&A 系列中的经典问题:当三角形的部分顶点落在裁剪空间(clip space)之外时,如何在片段着色器中丢弃这些被裁剪的片段。文章将给出两种完整可运行的实现方案——基于 varying 插值的逐像素判定,以及更高效的整三角形剔除方案,并深入讲解裁剪空间的数学原理与 WebGL 渲染管线的数据流向,读者读完后可以在自己的 WebGL 项目中直接套用这两种着色器模板。
问题场景:为什么片段着色器"看不到"顶点
在 WebGL 中,一个三角形经过顶点着色器后,会被光栅化(rasterize)成一堆覆盖的像素,随后每个像素都会调用一次片段着色器来询问颜色。正如 WebGL 工作原理 中所讲,片段着色器默认只能拿到极少的逐像素信息:当前像素在画布/帧缓冲上的屏幕坐标(即gl_FragCoord),以及深度缓冲值。顶点本身并不会自动传到片段着色器。
因此,如果你在片段着色器中尝试"访问某个顶点的裁剪状态",会直接撞上数据隔离的墙。想要在片段着色器里拿到顶点信息,唯一的途径是在顶点着色器中把数据算好,通过 varying 传递并插值到片段着色器。
前置知识:如何判断一个顶点被裁剪
要"检测裁剪",先要明确"裁剪"的数学定义。顶点着色器输出的gl_Position处于裁剪空间(clip space),WebGL 之后会自动将xyz除以w,得到位于 -1 到 +1 之间的标准设备坐标(NDC)。这个自动除以w的行为,在 3D 透视教程 中有详细推导:gl_Position = vec4(position.xyz, zToDivideBy)与手动除法完全等价。
一个顶点只有同时满足以下条件才位于可见区域内(不会被裁剪):
-w <= x <= w -w <= y <= w -w <= z <= w用 GLSL 的内建函数可以把它写成一句布尔表达式:
bool vertexClipped(vec4 clipspace) { return any(lessThan(clipspace.xyz, -clipspace.www)) || any(greaterThan(clipspace.xyz, clipspace.www)); }lessThan/greaterThan对vec3逐分量比较,返回bvec3;any只要有一个分量为 true 就整体为 true。只要x、y、z中任意一个分量超出[-w, w]范围,这个顶点就被判定为裁剪。
方案一:用 varying 把裁剪标志插值给片段着色器
最简单直接的思路是:在顶点着色器中计算当前顶点是否被裁剪,把结果作为 varying 传出;varying 在光栅化时会被线性插值,因此只要三角形任一顶点被裁剪,插值结果必然大于 0,片段着色器据此discard。
完整的可运行示例见 示例 1:varying 插值判定,核心着色器代码如下:
// 顶点着色器 attribute vec4 position; uniform mat4 matrix; varying float clipped; void main() { gl_Position = matrix * position; clipped = ( any(lessThan(gl_Position.xyz, -gl_Position.www)) || any(greaterThan(gl_Position.xyz, gl_Position.www)) ) ? 1.0 : 0.0; }// 片段着色器 precision highp float; varying float clipped; void main() { if (clipped > 0.0) { discard; } gl_FragColor = gl_FrontFacing ? vec4(1, 0, 0, 1) : vec4(0, 0, 1, 1); }这段代码的运作原理非常巧妙:
- 顶点着色器中,被裁剪的顶点把
clipped设为1.0,未被裁剪的设为0.0; - 光栅化时,WebGL 会按 着色器与 GLSL 中描述的 varying 机制,对三角形三个顶点的值做插值。如果三个顶点全部在可见区域内,三角形内所有像素的
clipped都是0.0;只要任一顶点被裁剪,靠近该顶点的像素插值结果就会大于0.0; - 片段着色器中用
clipped > 0.0而不是== 1.0判断,正是利用了插值特性——绝大多数像素得到的都是介于 0 和 1 之间的中间值。
方案二:整三角形剔除,让被裁剪的三角形根本不参与光栅化
varying 方案虽然正确,但存在一个效率问题:它仍然会光栅化那些"部分被裁剪"的三角形,只是事后在片段着色器里逐像素discard。对于大三角形来说,可能要白跑成千上万个像素的片段着色器。
更高效的做法是:把三角形的三个顶点同时传给顶点着色器,只要其中任意一个被裁剪,就把gl_Position直接挪到屏幕外,让硬件在光栅化阶段就把整个三角形剔除掉。
完整可运行示例见 示例 2:整三角形剔除,核心着色器如下:
// 顶点着色器 attribute vec4 position; attribute vec4 position1; // 三角形的第二个顶点 attribute vec4 position2; // 三角形的第三个顶点 uniform mat4 matrix; bool vertexClipped(vec4 clipspace) { return any(lessThan(clipspace.xyz, -clipspace.www)) || any(greaterThan(clipspace.xyz, clipspace.www)); } void main() { gl_Position = matrix * position; vec4 clipPosition1 = matrix * position1; vec4 clipPosition2 = matrix * position2; bool clipped = vertexClipped(gl_Position) || vertexClipped(clipPosition1) || vertexClipped(clipPosition2); if (clipped) { gl_Position = vec4(vec3(2), 1); // 移到屏幕外的离屏值 } }// 片段着色器 precision highp float; void main() { gl_FragColor = gl_FrontFacing ? vec4(1, 0, 0, 1) : vec4(0, 0, 1, 1); }片段着色器里完全不需要discard了,因为被裁剪的三角形在光栅化之前就被移出屏幕。vec4(vec3(2), 1)中的2远超 NDC 的 [-1, 1] 范围,除以w之后仍然在屏幕外,不会产生任何可见像素。
方案二的代价:需要重建三角形顶点数据
整三角形剔除方案虽然省去了逐像素判定,但代价是数据量变大:顶点着色器每次调用都要同时收到三角形的全部 3 个顶点。示例 2 的 JavaScript 部分展示了如何用 twgl 工具库准备这样的数据:
const verts = twgl.primitives.deindexVertices( twgl.primitives.createSphereVertices(1, 8, 8)); // 复制一份位置数据,用于存放错位后的三角形顶点 const position1 = new Float32Array(verts.position); const position2 = new Float32Array(verts.position); // 每个三角形 3 个顶点(9 个 float),循环错位 for (let i = 0; i < position1.length; i += 9) { { // 0, 1, 2 => 1, 2, 0 const temp = position1.slice(i, i + 3); position1.set(position1.slice(i + 3, i + 9), i); position1.set(temp, i + 6); } { // 0, 1, 2 => 2, 0, 1 const temp = position2.slice(i + 6, i + 9); position2.set(position2.slice(i + 0, i + 6), i + 3); position2.set(temp, i); } } verts.position1 = position1; verts.position2 = position2; const bufferInfo = twgl.createBufferInfoFromArrays(gl, verts);这段代码的意图是:球体网格顶点先经过deindexVertices去索引化,变成每 3 个顶点一组、每组构成一个三角形的线性数组;然后复制两份位置数据,通过循环错位让position1保存"每个三角形的第二个顶点"、position2保存"每个三角形的第三个顶点"。这样顶点着色器的三次调用分别拿到(v0, v1, v2)、(v1, v2, v0)、(v2, v0, v1),任何一个顶点被裁剪时,三条边上的判定结果一致,三角形整体被移出屏幕。
两个示例都使用twgl完成其余样板代码(createProgramInfo、setBuffersAndAttributes、drawBufferInfo),渲染逻辑本身保持一致:每帧更新一个 60° 视场角的透视矩阵,让球体随时间旋转和平移,越过near/far裁剪面时即可直观看到两种方案的剔除效果。相关渲染参数可对照 3D 透视教程 中的perspective(fov, aspect, near, far)矩阵构造方式。
两种方案对比与选型建议
| 对比维度 | 方案一:varying 插值 | 方案二:整三角形剔除 |
|---|---|---|
| 实现位置 | 顶点着色器 + 片段着色器discard | 顶点着色器(片段着色器无额外逻辑) |
| 数据量 | 只需原始顶点位置 | 每个三角形需同时传入 3 个顶点 |
| 被裁剪三角形是否光栅化 | 是(事后逐像素丢弃) | 否(光栅化前移出屏幕) |
| 性能特征 | 像素多时开销大 | 整体剔除,省去逐像素检查 |
| 代码复杂度 | 低 | 中(需要重建顶点缓冲) |
选型建议:如果你的三角形数量少、屏幕像素多(例如少量大尺寸平面),方案二能明显减少片段着色器的工作量;如果你的顶点数据来自索引缓冲、不方便重构为"三角面三顶点平铺"布局,方案一改动最小、最易落地。此外,方案二的思路还可以进一步推广——把判定逻辑放到 CPU 端完成,则整个三角形连顶点着色器都不用进,但代价是把顶点数据每帧回读,这属于另一层面的取舍。
补充说明
- 两个示例的完整源代码均位于仓库中:示例 1 与 示例 2,可直接打开运行观察球体穿越近/远裁剪面时的剔除效果。
- 本篇文章对应的原始问题文档为 webgl-qna-how-to-detect-clipped-triangles-in-the-framgment-shader.md,更多 WebGL Q&A 收录于 webgl-qna.md 索引。
- 若你对 varying 插值机制、裁剪空间与
w分量的数学细节还不熟悉,建议先阅读 WebGL 工作原理、着色器与 GLSL 和 3D 透视教程,它们是理解本文两套方案的前提。
- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
相关推荐
OfflineIMAP Gmail同步完全教程:标签、过滤器和高级功能
OfflineIMAP Gmail同步完全教程:标签、过滤器和高级功能 OfflineIMAP是一款强大的邮件同步工具,能够帮助用户轻松实现Gmail邮件的本地
后端SkiaSharp 位图裁剪实战:在 .NET MAUI 中实现交互式裁剪矩形与照片拼图
SkiaSharp 位图裁剪实战:在 .NET MAUI 中实现交互式裁剪矩形与照片拼图 本指南基于 SkiaSharp 官方指南 https://link.g
图形学图像处理跨平台WebGL 的 Shader 与 GLSL 全面解析:顶点着色器、片段着色器与着色器语言实战指南
WebGL 的 Shader 与 GLSL 全面解析:顶点着色器、片段着色器与着色器语言实战指南 导读 本文是 webgl fundamentals 开源课程系
图形学
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考