WebGL 最小程序(Smallest Programs):用最少代码完成测试与调试的实用技巧

原创2026-10-13 18:41:261,862 阅读
文章标签:图形学

WebGL 最小程序(Smallest Programs):用最少代码完成测试与调试的实用技巧

本篇指南以 webgl-fundamentals 仓库的 Smallest Programs 课程 为核心,系统讲解如何用最小规模的 WebGL 代码完成清屏、矩形裁剪、单点绘制与纹理验证等调试工作。读完本文,你将掌握 gl.clear、SCISSOR_TEST、gl.POINTS 与 gl_PointCoord 的组合运用,并能以极简代码快速排查渲染管线问题、制作 Stack Overflow MCVE(最小可复现示例)。

写在前面:这篇文章的两种用途

本文(webgl-smallest-programs.md)在官方课程中位置比较特殊,因为它同时服务于两个目的:

  1. 展示 WebGL 中真正意义上的"最小程序":这些技巧在测试某项功能、为 Stack Overflow 制作 MCVE(最小可复现示例)、或逐步缩小 bug 范围时极为实用;
  2. 训练"跳出常规思维框架"的能力:摆脱"创建缓冲区 → 设置属性 → 绘制"的固定套路,学会用更底层的视角理解渲染管线。同系列的 Drawing Without Data(无数据绘制) 也是这一思路的延续。

阅读本文前,建议先通读 WebGL 基础(fundamentals) 系列文章。

最小程序一:仅靠清屏(Just Clearing)

下面这段代码,是"真正做了点什么"的最小的 WebGL 程序:

const gl = document.querySelector('canvas').getContext('webgl');
gl.clearColor(1, 0, 0, 1);  // red
gl.clear(gl.COLOR_BUFFER_BIT);

它做的全部事情就是把画布清成红色,但它确实"产生了输出"。

可别小看这 3 行代码。假设你正在做 渲染到纹理(Render to Texture),把 1 个或多个 3D 物体渲染到纹理,再把结果贴到一个立方体上,结果屏幕上什么都看不到。这时就可以做一个极简的二分定位测试:停止用着色器渲染纹理,直接把纹理清成已知颜色:

gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferWithTexture)
gl.clearColor(1, 0, 1, 1);  // magenta
gl.clear(gl.COLOR_BUFFER_BIT);

然后再用这个纹理渲染立方体。如果立方体变成了品红色,说明"渲染到纹理"这一环没有问题,问题出在别处(比如纹理采样、uniform 设置或着色器本身);如果立方体依然看不到颜色,问题就锁定在纹理/帧缓冲链路本身。用一次可控的清屏,就能把"问题出在哪一段"快速切分出来,这正是最小程序的核心价值。

最小程序二:SCISSOR_TEST + gl.clear 绘制矩形

SCISSOR_TEST(剪刀测试)会把绘制和清屏都裁剪到画布(或当前帧缓冲)的某个子矩形范围内。启用它只需:

gl.enable(gl.SCISSOR_TEST);

然后用 gl.scissor 设置裁剪矩形。注意它的参数约定与 gl.viewport 完全一致,坐标以左下角为原点、单位为像素:

gl.scissor(x, y, width, height);

有了它,就可以完全绕开缓冲区、着色器和顶点属性,仅仅用 SCISSOR_TEST + gl.clear 来"画"矩形。仓库中完整的可运行示例见 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 次,每次随机生成矩形的位置、尺寸和颜色并依次清屏,最终在画布上叠加出 100 个随机色块。这个具体用法或许不算高频,但它是理解"WebGL 状态机 + 裁剪"的好例子:先设置状态(scissor 矩形、clearColor),再触发操作(clear),操作结果受当前状态约束——这一心智模型对后续所有 WebGL 调试都适用。

最小程序三:用一个大号 gl.POINTS 画一个点

在 WebGL 中最常见的做法是:创建缓冲区 → 把顶点数据放进缓冲区 → 写带 attribute 的着色器 → 配置 attribute 从缓冲区取数 → 绘制(可能还要设置 uniform 和纹理)。但有时候你只是想先看到点什么。

试试这对着色器:

// vertex shader
void main() {
  gl_Position = vec4(0, 0, 0, 1);  // center
  gl_PointSize = 120.0;
}
// fragment shader
precision mediump float;

void main() {
  gl_FragColor = vec4(1, 0, 0, 1);  // red
}

使用它的 JS 代码同样精简:

// setup GLSL program
const program = webglUtils.createProgramFromSources(gl, [vs, fs]);

gl.useProgram(program);

const offset = 0;
const count = 1;
gl.drawArrays(gl.POINTS, offset, count);

不需要创建缓冲区、不需要设置 uniform,就能在画布正中央得到一个 120 像素的红色点。完整的可运行示例见 webgl-simple-point.html。

兼容性备注:Safari 15 之前的版本未通过这一特性的 WebGL 一致性测试(point-no-attributes)。

关于 gl.POINTS 与 gl_PointSize 的关键细节

  • 向 gl.drawArrays 传入 gl.POINTS 时,必须在顶点着色器中把 gl_PointSize 设置为以像素为单位的点大小;
  • 不同 GPU/驱动支持的最大点尺寸不同,可以通过 gl.getParameter 查询范围:
const [minSize, maxSize] = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE);

WebGL 规范只要求最大尺寸为 1.0,幸运的是绝大多数 GPU 和驱动都支持更大的尺寸。因此写死点尺寸前最好查询 ALIASED_POINT_SIZE_RANGE,以免超出设备上限导致绘制异常。

点是如何"长"出来的

当顶点着色器执行完毕,gl_Position 会被转换到屏幕/画布空间(像素单位),然后围绕该位置向 4 个方向各扩展 ± gl_PointSize / 2,生成一个正方形。这个正方形就是点光栅化后的区域,理解这一点对后面用点来显示纹理非常关键。

进阶:在单个点上显示纹理(gl_PointCoord)

"谁会在乎一个点呢?"——点自带免费的纹理坐标。每个点在光栅化时都会自动生成纹理坐标,在片元着色器中通过内建变量 gl_PointCoord 访问。所以可以把纹理直接画到这个点上。

先修改片元着色器:

// fragment shader
precision mediump float;

uniform sampler2D tex;

void main() {
  gl_FragColor = texture2D(tex, gl_PointCoord.xy);
}

然后,为了保持"最小",用原始数据直接创建一张 2x2 纹理(原理详见 Data Textures(数据纹理)):

// 2x2 pixel data
const pixels = new Uint8Array([
  0xFF, 0x00, 0x00, 0xFF,  // red
  0x00, 0xFF, 0x00, 0xFF,  // green
  0x00, 0x00, 0xFF, 0xFF,  // blue
  0xFF, 0x00, 0xFF, 0xFF,  // magenta
]);
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
    gl.TEXTURE_2D,
    0,                 // level
    gl.RGBA,           // internal format
    2,                 // width
    2,                 // height
    0,                 // border
    gl.RGBA,           // format
    gl.UNSIGNED_BYTE,  // type
    pixels,            // data
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

为什么这里什么都不用再配置? 因为 WebGL 默认使用纹理单元 0,而 sampler uniform 的默认值也是 0——于是连"查找并设置 uniform"这一步都省掉了。完整示例见 webgl-simple-point-w-texture.html。

用单点纹理排查纹理相关问题

这是一个绝佳的纹理调试手段:仍然不碰缓冲区、不碰 attribute,也不用查找和设置任何 uniform。典型场景:

  • 加载的图片不显示:试试换上文着色器,看图片能否在点上显示出来?如果能,问题就不在"纹理上传/采样"环节;
  • 渲染到纹理后想查看纹理内容:常规做法是准备几何体 + 缓冲区 + attribute,但这里只需把纹理贴在这个大点上即可直接预览,极大简化了调试步骤。

进一步:多个"单点"绘制

把顶点着色器改成接收一个 attribute:

// vertex shader

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 to 1
  const clipspace = u * 1.6 - 0.8;  // -0.8 to +0.8
  gl.vertexAttrib2f(positionLoc, clipspace, clipspace);

  const offset = 0;
  const count = 1;
  gl.drawArrays(gl.POINTS, offset, count);
}

运行前把点缩小一些,并让点颜色可配置(这里换回了不带纹理的版本):

// vertex shader

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;
}

在 JS 侧查找颜色 uniform 的位置,并在循环中逐点设置:

// setup GLSL program
const program = webglUtils.createProgramFromSources(gl, [vs, fs]);
const positionLoc = gl.getAttribLocation(program, 'position');
const colorLoc = gl.getUniformLocation(program, 'color');

gl.useProgram(program);

const numPoints = 5;
for (let i = 0; i < numPoints; ++i) {
  const u = i / (numPoints - 1);    // 0 to 1
  const clipspace = u * 1.6 - 0.8;  // -0.8 to +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 个颜色渐变(红→品红方向)的点,而且依然没有设置任何缓冲区。完整示例见 webgl-simple-points.html。

一个重要提醒:这不是"画很多点"的正确方式

绝不是在 WebGL 中绘制大量点的推荐做法。要画很多点,应该为每个点设置一个位置 attribute、一个颜色 attribute,然后在单次 draw call 中一次性画完所有点。

但反过来,对于测试、调试、制作 MCVE 来说,这是把代码量压到最小的绝佳方式。例如:假设你正在做后处理、把中间结果渲染到多张纹理,想可视化它们,完全可以结合本节与上一节(带纹理的单点)的思路,为每张纹理画一个大点,无需任何复杂的缓冲区与 attribute 设置——非常适合调试。

最小程序背后的基础设施:webglUtils.createProgramFromSources

上面所有示例都依赖 webglUtils.createProgramFromSources(gl, <a href="https://link.gitcode.com/i/a64ffe7b5c5a09ed3367ffe0ca47452b" target="_blank">vs, fs]) 一行完成着色器编译与链接。它由仓库的 [webgl/resources/webgl-utils.js 提供:

function createProgramFromSources(
    gl, shaderSources, opt_attribs, opt_locations, opt_errorCallback) {
  const shaders = [];
  for (let ii = 0; ii < shaderSources.length; ++ii) {
    shaders.push(loadShader(
        gl, shaderSources[ii], gl[defaultShaderType[ii]], opt_errorCallback));
  }
  return createProgram(gl, shaders, opt_attribs, opt_locations, opt_errorCallback);
}

其内部流程(见 loadShader 与 createProgram):逐个 gl.createShader → gl.shaderSource → gl.compileShader,再 gl.createProgram 完成链接;发生错误时默认向控制台打印错误信息(可通过 opt_errorCallback 覆盖)。它自动按 defaultShaderType 约定"第一个字符串是顶点着色器、第二个是片元着色器",所以示例代码才能把两个字符串直接传入。

这套工具函数同样服务于其余所有示例页面(webgl-simple-point.html、webgl-simple-point-w-texture.html、webgl-simple-points.html 均引用了 resources/webgl-utils.js),属于仓库中所有示例共用的最小样板。

总结:最小程序的调试心法

把本文的四种招式串起来看,就形成了一套"无缓冲、无属性"的调试工具箱:

场景 最小手段 参考示例
快速判断"渲染到纹理"环节是否正常 绑定帧缓冲后 gl.clear 清成已知颜色 —
不写着色器就画出若干矩形 SCISSOR_TEST + gl.scissor + gl.clear webgl-simple-scissor.html
只画一个点、验证最简绘制链路 硬编码 gl_Position + gl_PointSize 的单点 gl.POINTS webgl-simple-point.html
快速预览一张纹理(含渲染到纹理的结果) texture2D(tex, gl_PointCoord.xy) 的大号单点 webgl-simple-point-w-texture.html
不建缓冲区就画少量点 vertexAttrib2f + 逐点 drawArrays(gl.POINTS, ...) webgl-simple-points.html

所有这些技巧的共同点,是刻意绕开"缓冲区 + attribute + 单次批量绘制"的常规链路,用状态机最底层的原语(clear、scissor、point)直接产生输出。它不会替代正式渲染路径,但能把"问题到底出在哪一段"快速暴露出来。下一次遇到"屏幕上什么都没有"时,不妨先退回到最小程序,把链路一段段点亮。

登录后查看全文
webgl-fundamentals