WebGL 最小的程序:用最少代码完成测试、调试与 MCVE 构建

原创2026-10-13 13:47:381,743 阅读
文章标签:图形学

WebGL 最小的程序:用最少代码完成测试、调试与 MCVE 构建

本篇文章对应 webgl-fundamentals 教程体系中的「WebGL 最小的程序」一课(原文见 webgl/lessons/webgl-smallest-programs.md)。当你在 WebGL 项目中遇到"看不到任何东西"的困惑时,最快、最可靠的排查手段不是去翻几百行的渲染管线代码,而是把问题压缩到最少的代码里逐个验证:清屏是否工作、剪裁是否生效、一个点能否画出、纹理能否采样。本文带你从 3 行清屏代码出发,一步步写出"无需顶点缓冲、无需属性、无需 uniform 查找"的最小绘制程序,掌握这套用于测试、调试与构建 MCVE(最小可验证示例)的实战技能。

阅读前提:本文假设你已经读过从《WebGL 基础》开始的一系列教程。如果尚未阅读,建议先从该篇开始,以便理解后续出现的渲染到纹理、数据纹理、3D 纹理等概念。

一、这篇文章的两种用途

作者坦言不知道该把这篇教程归入哪个分类,因为它有两个截然不同的目的:

  1. 展示最小的 WebGL 程序——这些技巧在测试某个功能、为 Stack Overflow 编写 MCVE(Minimal, Complete, and Verifiable Example)、或者缩小 bug 排查范围时极其有用;
  2. 学会跳出框框思考——不要只记住"创建缓冲 → 填充顶点数据 → 设置属性 → 绘制"这一条通用套路,而要看到更宏观的画面。同类思路的延伸教程可见《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)的终极价值。

登录后查看全文
webgl-fundamentals