为世界添彩 - WebGL 中的颜色与着色器变量

135 阅读7分钟

欢迎回来!在第一部分,我们已经了解了 WebGL 的“渲染管线”,以及两个核心的“工人”——顶点着色器与片元着色器。我们通过 attribute 变量,成功地将顶点坐标从 JavaScript “投喂”给了顶点着色器。

今天的目标是:不仅仅告诉 GPU 在哪里画,还要告诉它画什么颜色

新的问题:如何传递颜色?

你可能会想:“很简单,在片元着色器里直接改颜色不就行了?”

// 片元着色器 (旧代码)
void main() {
  // gl_FragColor = vec4(1.0, 0.0, 0.5, 1.0); // 紫红色
  gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0); // 现在改成绿色!
}

没错,但这依然是给整个图形上同一个颜色。我们想要的是一个五彩斑斓的三角形,比如顶角是红色,左下角是绿色,右下角是蓝色。

这意味着,颜色数据也必须是跟顶点一一对应的。既然位置是每个顶点都不同的 attribute,那颜色自然也可以!

打通任督二脉:Varying 变量

好,假设我们成功地把每个顶点的颜色,像坐标一样,通过另一个 attribute 传给了顶点着色器

但这里有个关键问题:顶点着色器只负责处理顶点(三个角),而片元着色器负责处理图形内部的每一个像素。顶点着色器知道三个角的颜色,它怎么告诉片元着色器“中间那些像素该涂什么颜色”呢?

答案就是今天的主角:varying 变量

你可以把 varying 想象成一座桥梁,连接着顶点着色器和片元着色器。它的工作机制非常神奇:

  1. 你在顶点着色器里,给一个 varying 变量赋值(比如,把从 attribute 接收到的顶点颜色赋给它)。
  2. GPU 开始绘制图形。当它填充两个顶点之间的像素时,它会自动地、线性地“插值”这个 varying 变量。
  3. 在片元着色器里,你接收这个 varying 变量。此时你收到的值,已经是 GPU 为你平滑计算好的、当前像素点“应该有”的值。

听起来有点抽象?看这张图:

我们只定义了三个角的颜色,中间所有像素的颜色都是 GPU 通过 varying 变量自动“渐变”出来的。这就是 WebGL 中创造平滑渐变的秘密!

GLSL 变量家族小结

现在,我们认识了 GLSL 中负责“通信”的三种主要变量类型:

  1. attribute从 JavaScript 到顶点着色器。用于传递每个顶点都不同的数据,如位置、颜色、纹理坐标等。只能在顶点着色器中使用。
  2. varying从顶点着色器到片元着色器。用于传递经过插值计算的数据。必须在两个着色器中成对声明。
  3. uniform:(我们下一篇会用到) 从 JavaScript 到两个着色器。用于传递对所有顶点都相同的数据,比如一个全局的变换矩阵或光照颜色。

搞清楚它们的职责,是掌握 GLSL 的关键。

开工!改造我们的代码

理论讲完了,我们来动手修改上一篇的代码。

1. 更新顶点着色器

我们需要它接收颜色数据,并把它传递给片元着色器。

// 新增一个 attribute 来接收颜色数据
attribute vec4 a_color;
// 新增一个 varying 变量作为桥梁
varying vec4 v_color;

void main() {
  gl_Position = vec4(a_position, 0.0, 1.0);
  // 将接收到的颜色直接传递给 varying 变量
  v_color = a_color;
}

2. 更新片元着色器

它不再使用写死的颜色,而是接收从顶点着色器传来的、经过插值的颜色。

precision mediump float;

// 声明同名的 varying 变量来接收数据
varying vec4 v_color;

void main() {
  // 使用插值后的颜色作为当前像素的颜色
  gl_FragColor = v_color;
}

3. 大改版:JavaScript

JavaScript 的工作要多一些,因为它现在需要管理并发送两份数据(位置和颜色)。

一个常见的、性能更好的做法是数据交错 (Interleaving)。我们不再创建两个独立的数组,而是把一个顶点所有的数据(位置、颜色)都放在一起,存进一个大数组和同一个 Buffer 中。

[X1, Y1, R1, G1, B1, A1, X2, Y2, R2, G2, B2, A2, ...]

这样做的好处是数据更紧凑,GPU 读取效率更高。但这也意味着,我们需要更精确地告诉 WebGL 如何从这一个 Buffer 里,分别解析出位置和颜色。这就是 gl.vertexAttribPointer 函数中 strideoffset 参数大显身手的时候了。

  • stride:告诉 WebGL “一整套顶点数据”有多长(占多少字节)。简单说,就是跳多远才能到下一组数据的开头。
  • offset:告诉 WebGL 当前这个 attribute 的数据,在一套数据里是从哪里(第几个字节)开始的。
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>WebGL 教程 2:彩色三角形</title>
    <style>
        body { background-color: #333; color: #eee; text-align: center; }
        canvas { background-color: #000; border: 1px solid #555; }
    </style>
</head>
<body onload="main()">
    <h1>为世界添彩 - WebGL 中的颜色!</h1>
    <canvas id="webgl-canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器代码 (已更新) -->
    <script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 a_position;
        // 新增:接收顶点的颜色 a_color (RGBA)
        attribute vec4 a_color;

        // 新增:varying 变量,用于将颜色传递给片元着色器
        varying vec4 v_color;

        void main() {
            gl_Position = vec4(a_position, 0.0, 1.0);
            // 将从 attribute 接收到的颜色,赋值给 varying 变量
            v_color = a_color;
        }
    </script>

    <!-- 片元着色器代码 (已更新) -->
    <script id="fragment-shader" type="x-shader/x-fragment">
        precision mediump float;

        // 新增:接收从顶点着色器传来的、已插值的颜色
        varying vec4 v_color;

        void main() {
            // 使用这个插值后的颜色作为像素的最终颜色
            gl_FragColor = v_color;
        }
    </script>

    <script>
        function main() {
            // ... (步骤 1, 2, 3 与上一篇相同:获取上下文、编译链接着色器) ...
            const canvas = document.getElementById('webgl-canvas');
            const gl = canvas.getContext('webgl');
            if (!gl) { alert('WebGL not supported!'); return; }

            const vertexShaderSource = document.getElementById('vertex-shader').text;
            const fragmentShaderSource = document.getElementById('fragment-shader').text;
            const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
            const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
            const program = createProgram(gl, vertexShader, fragmentShader);

            // 4. 找到 attribute 的位置
            const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
            // 新增:找到 a_color 的位置
            const colorAttributeLocation = gl.getAttribLocation(program, "a_color");

            // 5. 创建 Buffer,并存入交错的数据
            const positionBuffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

            // 数据交错:[X1, Y1, R1, G1, B1,  X2, Y2, R2, G2, B2, ... ]
            // 顶点1 (红色), 顶点2 (绿色), 顶点3 (蓝色)
            const positionsAndColors = [
                 0.0,  0.5,    1.0, 0.0, 0.0, // 顶点1: 坐标(0, 0.5), 颜色(R=1, G=0, B=0)
                -0.5, -0.5,    0.0, 1.0, 0.0, // 顶点2: 坐标(-0.5, -0.5), 颜色(R=0, G=1, B=0)
                 0.5, -0.5,    0.0, 0.0, 1.0  // 顶点3: 坐标(0.5, -0.5), 颜色(R=0, G=0, B=1)
            ];
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positionsAndColors), gl.STATIC_DRAW);
            
            // ... (步骤 6, 7 与上一篇相同:清空画布、启用程序) ...
            gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
            gl.clearColor(0.1, 0.1, 0.1, 1.0);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.useProgram(program);
            
            // 8. 告诉 WebGL 如何从唯一的 Buffer 中解析出两份数据
            
            // 启用两个 attribute
            gl.enableVertexAttribArray(positionAttributeLocation);
            gl.enableVertexAttribArray(colorAttributeLocation);

            // 再次绑定 Buffer (好习惯)
            gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

            const FSIZE = (new Float32Array()).BYTES_PER_ELEMENT; // 每个浮点数占4个字节
            const STRIDE = 5 * FSIZE; // 一套完整顶点数据(X,Y,R,G,B)的总字节数

            // ---- 指示 a_position 如何解析数据 ----
            const positionSize = 2; // 每个顶点的位置由 2 个分量组成
            const positionType = gl.FLOAT;
            const positionNormalize = false;
            const positionOffset = 0 * FSIZE; // 位置数据从一套数据的开头(偏移量0)开始
            gl.vertexAttribPointer(
                positionAttributeLocation,
                positionSize,
                positionType,
                positionNormalize,
                STRIDE, // Stride: 告诉它下一组位置数据在多远之后
                positionOffset // Offset: 告诉它这组数据从哪里开始
            );

            // ---- 指示 a_color 如何解析数据 ----
            const colorSize = 3; // 每个顶点的颜色由 3 个分量组成 (RGB)
            const colorType = gl.FLOAT;
            const colorNormalize = false;
            const colorOffset = 2 * FSIZE; // 颜色数据从偏移量2个浮点数大小之后开始
            gl.vertexAttribPointer(
                colorAttributeLocation,
                colorSize,
                colorType,
                colorNormalize,
                STRIDE, // Stride: 同样是一套完整顶点数据的长度
                colorOffset // Offset: 告诉它颜色数据在位置数据之后
            );

            // 9. 绘制!
            const primitiveType = gl.TRIANGLES;
            const drawOffset = 0;
            const count = 3;
            gl.drawArrays(primitiveType, drawOffset, count);
        }

        // --- 辅助函数 (与上一篇相同) ---
        function createShader(gl, type, source) {
            const shader = gl.createShader(type);
            gl.shaderSource(shader, source);
            gl.compileShader(shader);
            if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
            console.error("Shader compile error:", gl.getShaderInfoLog(shader));
            gl.deleteShader(shader);
        }
        function createProgram(gl, vertexShader, fragmentShader) {
            const program = gl.createProgram();
            gl.attachShader(program, vertexShader);
            gl.attachShader(program, fragmentShader);
            gl.linkProgram(program);
            if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
            console.error("Program link error:", gl.getProgramInfoLog(program));
            gl.deleteProgram(program);
        }
    </script>
</body>
</html>

总结与展望

现在,你的浏览器里应该出现了一个色彩斑斓的三角形,从红色的顶点平滑地过渡到绿色和蓝色。非常漂亮,不是吗?

今天,我们掌握了 WebGL 中数据流转的又一关键环节:

  • 学会了使用 varying 变量,在顶点着色器和片元着色器之间架起了沟通的桥梁
  • 理解了 GPU 是如何自动插值 varying 变量,从而创造出平滑的渐变效果。
  • 实践了数据交错的技巧,以及如何使用 strideoffset 参数,让 WebGL 从同一个 Buffer 中精确地解析出多种 attribute 数据

我们的三角形已经有了形状和颜色,但它还是一个“死”的东西。在下一篇文章中,我们将学习 2D 图形学中最核心的概念之一——矩阵变换。我们将引入第三位变量家族成员 uniform,并通过它让我们的三角形动起来:平移、旋转、缩放!

敬请期待 《第 3 篇:让图形动起来 - WebGL 2D 变换》