WebGL 最小的程序:用最少代码完成测试、调试与 MCVE 构建
WebGL 最小的程序:用最少代码完成测试、调试与 MCVE 构建
本篇文章对应 webgl-fundamentals 教程体系中的「WebGL 最小的程序」一课(原文见 webgl/lessons/webgl-smallest-programs.md)。当你在 WebGL 项目中遇到"看不到任何东西"的困惑时,最快、最可靠的排查手段不是去翻几百行的渲染管线代码,而是把问题压缩到最少的代码里逐个验证:清屏是否工作、剪裁是否生效、一个点能否画出、纹理能否采样。本文带你从 3 行清屏代码出发,一步步写出"无需顶点缓冲、无需属性、无需 uniform 查找"的最小绘制程序,掌握这套用于测试、调试与构建 MCVE(最小可验证示例)的实战技能。
阅读前提:本文假设你已经读过从《WebGL 基础》开始的一系列教程。如果尚未阅读,建议先从该篇开始,以便理解后续出现的渲染到纹理、数据纹理、3D 纹理等概念。
一、这篇文章的两种用途
作者坦言不知道该把这篇教程归入哪个分类,因为它有两个截然不同的目的:
- 展示最小的 WebGL 程序——这些技巧在测试某个功能、为 Stack Overflow 编写 MCVE(Minimal, Complete, and Verifiable Example)、或者缩小 bug 排查范围时极其有用;
- 学会跳出框框思考——不要只记住"创建缓冲 → 填充顶点数据 → 设置属性 → 绘制"这一条通用套路,而要看到更宏观的画面。同类思路的延伸教程可见《WebGL 无数据绘制》,那里展示了只用顶点编号(vertex id)在着色器里"凭空"生成几何的创意做法。
下面我们从最简单的一步开始。
二、仅清屏:真正"做了事"的最小程序
下面是本仓库中公认"最小的、确实做了某事"的 WebGL 程序:
const gl = document.querySelector('canvas').getContext('webgl');
gl.clearColor(1, 0, 0, 1); // 红色
gl.clear(gl.COLOR_BUFFER_BIT);
这个程序所做的只是把画布清成红色,但它确实做了一件事——它证明了 WebGL 上下文创建成功、颜色缓冲区可用、清除操作生效。别小看这三行,仅凭它你就能实际测试一些东西。
实战案例:用清屏定位"渲染到纹理"的故障
假设你在做《渲染到纹理》里的例子:先把一个或多个 3D 物体渲染到纹理上,再把纹理渲染到一个立方体上,结果什么都看不见。
此时最简单的测试方法是:停掉用着色器渲染纹理的那部分,直接用清屏给纹理填充一个已知颜色:
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferWithTexture);
gl.clearColor(1, 0, 1, 1); // 品红色
gl.clear(gl.COLOR_BUFFER_BIT);
然后照常把帧缓冲里的纹理渲染出来。问自己两个问题:
- 立方体变成品红色了吗?
- 如果没有,说明问题不在"渲染到纹理"这一步,而在别处(比如纹理上传、绑定、采样设置、立方体本身)。
- 如果有,说明问题就出在你之前那套渲染纹理的着色器/几何体代码里。
这就是"分而治之"的调试思想:用三行代码把变量数降到最低,一次排除一大片怀疑区域。
技术细节:
gl.clearColor(r, g, b, a)的参数是 0~1 的浮点颜色;gl.clear(gl.COLOR_BUFFER_BIT)只清除颜色缓冲。仓库中对应的完整 HTML 示例是 webgl/webgl-render-to-texture.html 等一系列渲染到纹理的页面,你可以打开它们配合本文的排查思路对照验证。
三、SCISSOR_TEST + gl.clear:不用着色器也能画矩形
SCISSOR_TEST(剪裁测试)会把绘制和清除都限制在画布(或当前帧缓冲)的某个子矩形内。开启它只需一行:
gl.enable(gl.SCISSOR_TEST);
然后以左下角为原点、单位为像素设置剪裁矩形,参数与 gl.viewport 完全一致:
gl.scissor(x, y, width, height);
于是你就可以只用 SCISSOR_TEST 和 gl.clear 画出矩形——全程不需要任何着色器、缓冲或属性。仓库中的完整示例见 webgl/webgl-simple-scissor.html,其核心逻辑如下:
const gl = document.querySelector('#c').getContext('webgl');
gl.enable(gl.SCISSOR_TEST);
function drawRect(x, y, width, height, color) {
gl.scissor(x, y, width, height);
gl.clearColor(...color);
gl.clear(gl.COLOR_BUFFER_BIT);
}
for (let i = 0; i < 100; ++i) {
const x = rand(0, 300);
const y = rand(0, 150);
const width = rand(0, 300 - x);
const height = rand(0, 150 - y);
drawRect(x, y, width, height, [rand(1), rand(1), rand(1), 1]);
}
function rand(min, max) {
if (max === undefined) {
max = min;
min = 0;
}
return Math.random() * (max - min) + min;
}
运行后屏幕上会出现 100 个随机位置、随机大小、随机颜色的矩形(随机位置限定在 300×150 像素区域内,矩形宽度不超过 300 - x、高度不超过 150 - y,保证不越界)。作者也坦言:这个特定例子本身未必多有用,但知道有这条路可走总是好的——当你的调试目标只是"在某个区域刷个颜色"时,它比搭一套完整渲染管线快得多。
四、使用一个大大的 gl.POINTS
回顾大多数示例,WebGL 最常见的套路是:创建缓冲 → 把顶点数据放进缓冲 → 创建带 attribute 的着色器 → 配置 attribute 从缓冲取数 → 可能还要设置 uniform 和纹理 → 最后绘制。
但有时候你只是想测试:比如就想看看"有没有东西能被画出来"。
试试这一对着色器:
// 顶点着色器
void main() {
gl_Position = vec4(0, 0, 0, 1); // 中心
gl_PointSize = 120.0;
}
// 片元着色器
precision mediump float;
void main() {
gl_FragColor = vec4(1, 0, 0, 1); // 红色
}
以及使用它的代码(仓库完整示例见 webgl/webgl-simple-point.html):
// 设置 GLSL 程序
const program = webglUtils.createProgramFromSources(gl, [vs, fs]);
gl.useProgram(program);
const offset = 0;
const count = 1;
gl.drawArrays(gl.POINTS, offset, count);
没有要创建的缓冲、没有要设置的 uniform,我们就得到了画布正中央的一个点。webglUtils.createProgramFromSources 是仓库自带的工具函数(实现见 webgl/resources/webgl-utils.js):它按默认顺序把传入的着色器源码分别当作顶点着色器、片元着色器编译,再链接成一个 program,省去了每个示例里重复的样板代码(更详细的样板说明见《WebGL 样板代码》)。
gl.POINTS 的硬性要求与限制
关于 gl.POINTS,有几条必须知道的规则:
- 当把
gl.POINTS传给gl.drawArrays时,必须在顶点着色器中设置gl_PointSize(以像素为单位的点大小); - 不同的 GPU / 驱动有不同的最大点尺寸,可以这样查询:
const [minSize, maxSize] = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE);
- WebGL 规范只要求最大尺寸至少为 1.0。幸运的是,绝大多数 GPU 和驱动都支持更大的值;
- 顶点着色器退出后,你赋给
gl_Position的值会被转换为屏幕/画布空间中的像素位置,然后以该位置为中心、向四个方向各延伸gl_PointSize / 2,生成一个正方形; - 兼容性提示:Safari 15 之前的版本没有通过该特性的 WebGL 一致性测试。
补充:
gl.POINTS在屏幕外时如何被裁剪在各驱动间并不一致——规范(OpenGL ES)与桌面 OpenGL 的裁剪规则相反,部分驱动画、部分不画。如果你的场景依赖大号点或屏幕边缘裁剪的一致性,建议改用gl.TRIANGLES自己画四边形,这些问题都能同时消失。相关讨论可延伸至《WebGL 无数据绘制》底部的说明。
五、在单点上画纹理:免费的纹理坐标
你可能会想:谁稀罕画一个单点?——点是自带"免费"纹理坐标的。
每个点都会自动生成纹理坐标,片元着色器中通过内置特殊变量 gl_PointCoord 即可访问(关于纹理坐标的一般概念见《WebGL 3D 纹理》)。所以,我们直接在这个单点上画一张纹理。
先把片元着色器改成采样纹理:
// 片元着色器
precision mediump float;
uniform sampler2D tex;
void main() {
gl_FragColor = texture2D(tex, gl_PointCoord.xy);
}
为了保持简单,像《数据纹理》里那样用原始像素数据生成一张 2×2 纹理(仓库完整示例见 webgl/webgl-simple-point-w-texture.html):
// 2x2 像素数据
const pixels = new Uint8Array([
0xFF, 0x00, 0x00, 0xFF, // 红
0x00, 0xFF, 0x00, 0xFF, // 绿
0x00, 0x00, 0xFF, 0xFF, // 蓝
0xFF, 0x00, 0xFF, 0xFF, // 品红
]);
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D,
0, // mip 级别
gl.RGBA, // 内部格式
2, // 宽度
2, // 高度
0, // 边框
gl.RGBA, // 格式
gl.UNSIGNED_BYTE, // 数据类型
pixels, // 原始数据
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
这里有几个"免费"点值得注意:
- 纹理单元默认为 0,WebGL 默认采样纹理单元 0,所以不需要显式
gl.activeTexture; - uniform 默认值为 0,
uniform sampler2D tex默认绑定到纹理单元 0,因此不用查找和设置任何 uniform; - 采样过滤设为
gl.NEAREST,保证这张 2×2 纹理被放大到 120 像素的点和纹理像素之间不会做平滑插值,红/绿/蓝/品红四个象限清晰可辨。
实战案例:用单点排查纹理问题
这成为测试纹理相关问题的绝佳手段:
- 你仍然没有使用任何缓冲、任何属性,也不用查找和设置 uniform;
- 比如你加载了一张图片但它没有显示出来——换上上面的着色器,图片是否出现在单点上?
- 你渲染到了纹理、想查看纹理内容——通常要先靠缓冲和属性搭建几何体,但现在只需把纹理显示在这个单点上就能直接看到结果。
六、使用多个单独的 POINTS:逐点设置位置与颜色
在上一个例子的基础上做一处简单修改,就可以控制每个点的位置。把顶点着色器改成:
// 顶点着色器
attribute vec4 position;
void main() {
gl_Position = position;
gl_PointSize = 120.0;
}
attribute 的默认值是 0, 0, 0, 1,所以仅仅做了这个修改,前面的例子依然能正常工作;但我们现在获得了"想要把点放在哪就放在哪"的能力:
const program = webglUtils.createProgramFromSources(gl, [vs, fs]);
const positionLoc = gl.getAttribLocation(program, 'position');
const numPoints = 5;
for (let i = 0; i < numPoints; ++i) {
const u = i / (numPoints - 1); // 0 到 1
const clipspace = u * 1.6 - 0.8; // -0.8 到 +0.8
gl.vertexAttrib2f(positionLoc, clipspace, clipspace);
const offset = 0;
const count = 1;
gl.drawArrays(gl.POINTS, offset, count);
}
注意:这里依然没有创建任何缓冲,而是直接用 gl.vertexAttrib2f 给属性喂值。
运行之前,先把点改小一点,顺便把大小做成可配置的:
// 顶点着色器
attribute vec4 position;
uniform float size;
void main() {
gl_Position = position;
gl_PointSize = 20.0;
}
再让点的颜色也可配置(注意:这里切换回了没有纹理的代码):
precision mediump float;
uniform vec4 color;
void main() {
gl_FragColor = color;
}
然后查找颜色 uniform 的位置:
// 设置 GLSL 程序
const program = webglUtils.createProgramFromSources(gl, [vs, fs]);
const positionLoc = gl.getAttribLocation(program, 'position');
const colorLoc = gl.getUniformLocation(program, 'color');
最后在循环里使用它们(仓库完整示例见 webgl/webgl-simple-points.html):
gl.useProgram(program);
const numPoints = 5;
for (let i = 0; i < numPoints; ++i) {
const u = i / (numPoints - 1); // 0 到 1
const clipspace = u * 1.6 - 0.8; // -0.8 到 +0.8
gl.vertexAttrib2f(positionLoc, clipspace, clipspace);
gl.uniform4f(colorLoc, u, 0, 1 - u, 1);
const offset = 0;
const count = 1;
gl.drawArrays(gl.POINTS, offset, count);
}
现在我们得到 5 个点、5 种颜色(随 u 从 0 渐变到 1,红色分量递增、蓝色分量递减),而且仍然没有设置任何缓冲或属性。
重要提醒:这不是画大量点的方式
作者特别强调:这绝不是在 WebGL 里画大量点的正确方法。如果你要画成百上千个点,应该为每个点准备位置和颜色属性数据,放进缓冲,然后用一次 drawArrays 调用画完所有点。
但是! 对以下场景,这套"最小化代码"的思路是绝佳选择:
- 测试:验证某条渲染路径是否通;
- 调试:逐步定位问题所在;
- 编写 MCVE:把代码压缩到最小、最易被他人复现和回答。
再举一个例子:假设你在为后处理效果把画面渲染到若干纹理,想可视化它们——只需要把"多个单点"这个例子和前面"带纹理的单点"那个例子组合起来,为每张纹理画一个巨大的点即可,完全不需要复杂的缓冲和属性设置,调试起来非常方便。
七、思路延伸:从"最小程序"到"无数据绘制"
这套"最小化"的思想可以走得更远。既然 WebGL 只关心你的顶点着色器最终给 gl_Position 赋了裁剪坐标值、片元着色器给 gl_FragColor 赋了颜色,它根本不关心你用什么方式产生这些值。于是就有了《WebGL 无数据绘制》里的做法:不传位置数据,只传一个顶点编号(vertex id),在着色器里用数学运算生成圆形排列的点、雨滴效果,甚至纯靠着色器把三角形拼成圆环——仓库对应的示例有 webgl/webgl-no-data-point-circle.html、webgl/webgl-no-data-point-rain.html、webgl/webgl-no-data-triangles-circle.html 等。
当然,这类技巧并不常见,极端复杂的着色器计算会损害性能;需要性能时,应尽量在初始化阶段预计算并传入数据。但作为一种思维训练和调试利器,它与本文介绍的最小程序一脉相承:先把问题缩小到极致,再逐一攻破。下一步,你还可以继续阅读《WebGL 的 Shadertoy 着色器》感受"最小输入 + 着色器输出一切"的另一个极端。
八、速查总结
| 目标 | 最小手段 | 对应示例 |
|---|---|---|
| 验证清屏/上下文 | gl.clearColor + gl.clear(gl.COLOR_BUFFER_BIT) |
— |
| 给帧缓冲纹理刷已知颜色 | gl.bindFramebuffer + 清屏 |
webgl/webgl-render-to-texture.html |
| 不写着色器画矩形 | gl.enable(gl.SCISSOR_TEST) + gl.scissor + 清屏 |
webgl/webgl-simple-scissor.html |
| 画一个点 | 空顶点着色器 + gl_PointSize + gl.drawArrays(gl.POINTS, 0, 1) |
webgl/webgl-simple-point.html |
| 在单点上显示纹理 | gl_PointCoord + texture2D |
webgl/webgl-simple-point-w-texture.html |
| 画多个单点并着色 | gl.vertexAttrib2f + gl.uniform4f |
webgl/webgl-simple-points.html |
调试 WebGL 时请记住这条原则:一切渲染问题的排查,都可以从把代码压缩到最小开始。 先证明最小的环节是好的,再逐步把变量加回去,直到问题重现——这就是最小程序(Smallest Programs)的终极价值。