WebGL编程指南之颜色和纹理

278 阅读25分钟

前言

该章节将通过以下三个问题展开讨论

1、 将顶点的其它数据——如颜色等——传入顶点着色器

2、发生在顶点着色器和片元着色器之间的从图形到片元的转化,又称为图元光栅化

3、将图像(或称纹理)映射到图形或三维对象的表面上

1 绑定多个缓冲区

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <style>
    #canvas {
      width: 600px;
      height: 600px;
      position: absolute;
      top: calc(50% - 300px);
      left: calc(50% - 300px);
      background-color: black;
    }
  </style>
  <body>
    <canvas id="canvas"></canvas>
    <script>
      // 通过元素获取二维图形的绘图上下文
      const canvas = document.getElementById("canvas");
      const gl = canvas.getContext("webgl");

      // 定义顶点着色器
      const vertexShaderSource = `
    attribute vec4 a_Position;
    attribute float a_PointSize;
    void main() {
        gl_Position = a_Position; // 设置点的坐标
        gl_PointSize = a_PointSize;      // 设置点的大小
    }
`;

      // 定义片段着色器
      const fragmentShaderSource = `
    void main() {
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 设置点的颜色为红色
    }
`;

      // 编译着色器函数
      function createShader(gl, type, source) {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        return shader;
      }

      // 创建和链接着色器程序
      const vertexShader = createShader(
        gl,
        gl.VERTEX_SHADER,
        vertexShaderSource
      );
      const fragmentShader = createShader(
        gl,
        gl.FRAGMENT_SHADER,
        fragmentShaderSource
      );
      const program = gl.createProgram();
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      gl.linkProgram(program);
      gl.useProgram(program);

      // 设置顶点数据
      const vertices = new Float32Array([0.0, 0.5, -0.5, -0.5, 0.5, -0.5]);
      // 创建缓冲区,給a_Position使用
      const buffer1 = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer1);
      gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

      // 连接顶点着色器的a_Position变量
      // 获取a_Position变量存储位置
      const a_Position = gl.getAttribLocation(program, "a_Position");
      gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
      gl.enableVertexAttribArray(a_Position);

      // 创建缓冲区,給gl_PointSize使用
      const buffer2 = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer2);
      // 设置顶点数据
      const sizes = new Float32Array([
        10.0,
        20.0,
        30.0, // 在画布中心绘制一个点
      ]);
      gl.bufferData(gl.ARRAY_BUFFER, sizes, gl.STATIC_DRAW);

      // 连接顶点着色器的gl_PointSize变量
      // 获取gl_PointSize变量存储位置
      const a_PointSize = gl.getAttribLocation(program, "a_PointSize");
      gl.vertexAttribPointer(a_PointSize, 1, gl.FLOAT, false, 0, 0);
      gl.enableVertexAttribArray(a_PointSize);

      // 设置背景颜色并清空
      gl.clearColor(0.0, 0.0, 0.0, 1.0);
      gl.clear(gl.COLOR_BUFFER_BIT);

      // 绘制点 这里只获取一个顶点
      gl.drawArrays(gl.POINTS, 0, 3);
    </script>
  </body>
</html>

效果图

顶点着色器是如何执行缓冲区数据的,存在多个缓冲区如何指定?

gl.vertexAttribPointer(a_PointSize, 1, gl.FLOAT, false, 0, 0)将指向当前绑定的缓冲区,之前的缓冲区会被暂存起来,当调用drawArrays时,就会缓冲区中获取数据。

利用两个缓冲区来分别为顶的顶点a_Position和大小a_PointSize分配数据,有带你麻烦,可以将a_Position和大小a_PointSize的数据写入同一个缓冲区:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <style>
    #canvas {
      width: 600px;
      height: 600px;
      position: absolute;
      top: calc(50% - 300px);
      left: calc(50% - 300px);
      background-color: black;
    }
  </style>
  <body>
    <canvas id="canvas"></canvas>
    <script>
      // 通过元素获取二维图形的绘图上下文
      const canvas = document.getElementById("canvas");
      const gl = canvas.getContext("webgl");

      // 定义顶点着色器
      const vertexShaderSource = `
    attribute vec4 a_Position;
    attribute float a_PointSize;
    void main() {
        gl_Position = a_Position; // 设置点的坐标
        gl_PointSize = a_PointSize;      // 设置点的大小
    }
`;

      // 定义片段着色器
      const fragmentShaderSource = `
    void main() {
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 设置点的颜色为红色
    }
`;

      // 编译着色器函数
      function createShader(gl, type, source) {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        return shader;
      }

      // 创建和链接着色器程序
      const vertexShader = createShader(
        gl,
        gl.VERTEX_SHADER,
        vertexShaderSource
      );
      const fragmentShader = createShader(
        gl,
        gl.FRAGMENT_SHADER,
        fragmentShaderSource
      );
      const program = gl.createProgram();
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      gl.linkProgram(program);
      gl.useProgram(program);

      // 设置顶点数据
      const vertices = new Float32Array([
        0.0, 0.5, 10.0, -0.5, -0.5, 20.0, 0.5, -0.5, 30.0,
      ]);
      // 创建缓冲区,給a_Position使用
      const buffer1 = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer1);
      gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

      // 连接顶点着色器的a_Position变量
      // 获取a_Position变量存储位置
      const a_Position = gl.getAttribLocation(program, "a_Position");
      gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 3 * 4, 0);
      gl.enableVertexAttribArray(a_Position);

      // 连接顶点着色器的gl_PointSize变量
      // 获取gl_PointSize变量存储位置
      const a_PointSize = gl.getAttribLocation(program, "a_PointSize");
      gl.vertexAttribPointer(a_PointSize, 1, gl.FLOAT, false, 3 * 4, 2 * 4);
      gl.enableVertexAttribArray(a_PointSize);

      // 设置背景颜色并清空
      gl.clearColor(0.0, 0.0, 0.0, 1.0);
      gl.clear(gl.COLOR_BUFFER_BIT);

      // 绘制点 这里只获取一个顶点
      gl.drawArrays(gl.POINTS, 0, 3);
    </script>
  </body>
</html>

gl.vertexAttribPointer

location变量存储位置
size变量分量个数,示例中点的分量为2
type数据格式
normalize是否将非浮点数的数据归一化到[0, 1]或者[-1, 1]
stride指定相邻顶点之间的字节数(Float32Array为4字节)
offset从缓冲区的何处开始

2 彩色三角形

这里先抛出一个问题:前面的三角形如何被决定的,为什么是红色?

前面绘制三角形的实例, gl.drawArrays(gl.TRIANGLES, 0, 3);声明了绘制一个三角形,用到了三个顶点,那么在顶点着色器中会陆续冲缓冲区中获取三个顶点数据,有这三个顶点组成一个三角形?在片元着色器中,我们指定了rgb红色给gl_FragColor,他是怎么填充三角形的?

顶点坐标、图元装配、光栅化、执行片元着色器过程

顶点着色器和片段着色器之间有这两个步骤

图形装配过程:这一步的任务就是将孤立的顶点坐标装配成几何图形。集合图形的类型由drawArrays参数指定;

光栅化过程:这一步的任务就是将装配好的几何图形转化为片元;

当执行顶点着色器的时候,每从缓冲区获取到一个顶点,就会将顶点存在装配区,获取三次,就会将三个顶点数据存在装配区,由装配区的顶点装配出一个三角形。

光栅化结束之后,程序开始逐片元调用着色器,有多少个片元就会调用多少次。对于每个片元片元着色器计算出该片元的颜色,并写入颜色缓冲区。

光栅化过程生成的片元都是带有坐标信息的,调用片元着色器时,这些坐标信息也随着片元传递过去。

代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <style>
    #canvas {
      width: 600px;
      height: 600px;
      position: absolute;
      top: calc(50% - 300px);
      left: calc(50% - 300px);
      background-color: black;
    }
  </style>
  <body>
    <canvas id="canvas"></canvas>
    <script>
      // 通过元素获取二维图形的绘图上下文
      const canvas = document.getElementById("canvas");
      const gl = canvas.getContext("webgl");

      // 定义顶点着色器
      var VSHADER_SOURCE =
        "attribute vec4 a_Position;\n" +
        "void main() {\n" +
        "  gl_Position = a_Position;\n" +
        "}\n";

      // 定义片段着色器
      var FSHADER_SOURCE =
        "precision mediump float;\n" +
        "uniform float u_Width;\n" +
        "uniform float u_Height;\n" +
        "void main() {\n" +
        "  gl_FragColor = vec4(gl_FragCoord.x/u_Width, 0.0, gl_FragCoord.y/u_Height, 1.0);\n" +
        "}\n";

      // 编译着色器函数
      function createShader(gl, type, source) {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        return shader;
      }

      // 创建和链接着色器程序
      const vertexShader = createShader(gl, gl.VERTEX_SHADER, VSHADER_SOURCE);
      const fragmentShader = createShader(
        gl,
        gl.FRAGMENT_SHADER,
        FSHADER_SOURCE
      );
      const program = gl.createProgram();
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      gl.linkProgram(program);
      gl.useProgram(program);

      // 设置顶点数据
      const vertices = new Float32Array([0, 0.5, -0.5, -0.5, 0.5, -0.5]);
      // 创建缓冲区,給a_Position使用
      const buffer1 = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer1);
      gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

      // 连接顶点着色器的a_Position变量
      // 获取a_Position变量存储位置
      const a_Position = gl.getAttribLocation(program, "a_Position");
      gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
      gl.enableVertexAttribArray(a_Position);

      var u_Width = gl.getUniformLocation(program, "u_Width");
      var u_Height = gl.getUniformLocation(program, "u_Height");
      // 颜色缓冲区宽度
      gl.uniform1f(u_Width, gl.drawingBufferWidth);
      // 颜色缓冲区高度 
      gl.uniform1f(u_Height, gl.drawingBufferHeight);

      // 设置背景颜色并清空
      gl.clearColor(0.0, 0.0, 0.0, 1.0);
      gl.clear(gl.COLOR_BUFFER_BIT);

      // 绘制点 这里只获取一个顶点
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    </script>
  </body>
</html>

效果图

2.1 varying变量的作用和内插过程

代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <style>
    #canvas {
      width: 600px;
      height: 600px;
      position: absolute;
      top: calc(50% - 300px);
      left: calc(50% - 300px);
      background-color: black;
    }
  </style>
  <body>
    <canvas id="canvas"></canvas>
    <script>
      // 通过元素获取二维图形的绘图上下文
      const canvas = document.getElementById("canvas");
      const gl = canvas.getContext("webgl");

      // 定义顶点着色器
      var VSHADER_SOURCE =
        "attribute vec4 a_Position;\n" +
        "attribute vec4 a_Color;\n" +
        "varying vec4 v_Color;\n" +
        "void main() {\n" +
        "  gl_Position = a_Position;\n" +
        "  v_Color = a_Color;\n" +
        "}\n";

      // Fragment shader program
      var FSHADER_SOURCE =
        "precision mediump float;\n" +
        "varying vec4 v_Color;\n" +
        "void main() {\n" +
        "  gl_FragColor = v_Color;\n" +
        "}\n";

      // 编译着色器函数
      function createShader(gl, type, source) {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        return shader;
      }

      // 创建和链接着色器程序
      const vertexShader = createShader(gl, gl.VERTEX_SHADER, VSHADER_SOURCE);
      const fragmentShader = createShader(
        gl,
        gl.FRAGMENT_SHADER,
        FSHADER_SOURCE
      );
      const program = gl.createProgram();
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      gl.linkProgram(program);
      gl.useProgram(program);

      // 设置顶点数据
      const vertices = new Float32Array([
        // 顶点数据和color
        0.0, 0.5, 1.0, 0.0, 0.0, -0.5, -0.5, 0.0, 1.0, 0.0, 0.5, -0.5, 0.0, 0.0,
        1.0,
      ]);
      // 创建缓冲区,給a_Position使用
      const buffer1 = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer1);
      gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

      // 连接顶点着色器的a_Position变量
      // 获取a_Position变量存储位置
      const a_Position = gl.getAttribLocation(program, "a_Position");
      gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 4 * 5, 0);
      gl.enableVertexAttribArray(a_Position);

      var a_Color = gl.getAttribLocation(program, "a_Color");
      gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, 4 * 5, 4 * 2);
      gl.enableVertexAttribArray(a_Color);

      // 设置背景颜色并清空
      gl.clearColor(0.0, 0.0, 0.0, 1.0);
      gl.clear(gl.COLOR_BUFFER_BIT);

      // 绘制点 这里只获取一个顶点
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    </script>
  </body>
</html>

效果

为什么只是给三个顶点指定了颜色,最后会生成一个彩色三角形?

事实上,我们把顶点的颜色复制给了顶点着色器的varying变量v_Color,它的值被传给了片元着色器中的同名、同类型的变量(既片元着色器中也有verying变量v_Color),准确的说,顶点着色器中的变量v_Color在传入片元着色器之前经过了内插过程,所以片元着色器中的v_Color和顶点着色器中的v_Color并不是一回事,彩色三角形各个片元的颜色都是通过顶点颜色内插出来的。

考虑一条线段

RGBA的R值从1.0降低到0.0,而这个B的值从0.0上升到1.0,线上的所有片元的颜色都被计算出来,这个过程就被称为内插过程

2.1.1 varying变量的作用

在 WebGL 中,varying 类型的变量用于在 顶点着色器片段着色器 之间传递数据。具体来说,varying 变量从顶点着色器输出,然后在光栅化过程中,它的值会在顶点之间进行插值,最终传递给片段着色器中的对应变量,用于片段级别的操作。

变量的作用和工作机制

  1. 在顶点着色器中定义输出:在顶点着色器中,变量通常用于存储与每个顶点相关联的属性值(如颜色、纹理坐标、法线等),并将其作为输出传递给片段着色器。
  2. 插值过程:当顶点着色器将顶点传递给光栅化阶段时,WebGL 会对变量进行插值,从而为三角形内的每个片段(像素)生成一个基于顶点间插值的值。例如,如果顶点着色器输出的颜色是顶点 A、B、C 的颜色,那么在片段着色器中,三角形内的每个像素的颜色会是根据顶点颜色计算出来的插值结果。
  3. 在片段着色器中使用插值结果:片段着色器中使用相同名称的变量来接收光栅化阶段插值后的值,进而对每个片段进行进一步的颜色或光照计算。

变量的定义

  • 顶点着色器变量在顶点着色器中用于输出。例如,输出颜色或纹理坐标。
  • 片段着色器变量在片段着色器中用于输入,用以获取经过插值后的数据。

当顶点着色器与片断着色器中有类型和命名都相同的varying变量,顶点着色器赋值给该变量的值就会自动被传入片断着色器。

2.1.2 顶点着色器到片段着色器的插值概述

在 WebGL(以及大部分基于 GPU 的图形渲染管线中),着色器分为多个阶段:

  1. 顶点着色器:处理每个顶点的属性(如位置、颜色等),输出顶点的变换后位置及相关属性。
  2. 光栅化阶段:将顶点组成的几何体(如三角形)划分为片段(Fragment),并对顶点属性(如颜色、法线、纹理坐标等)进行插值。
  3. 片段着色器:处理每个片段的属性,并计算出每个片段的最终颜色。

在三角形光栅化过程中,顶点着色器为三角形的每个顶点输出属性(如颜色),而在片段着色器中,这些属性会根据片段(像素)在三角形内部的位置,通过插值算法计算出来。

插值过程的核心

最常用的插值方式是 透视校正线性插值(Perspective-correct interpolation),它能够在透视投影下正确地插值片段数据。对于每一个片段,颜色值会基于三角形三个顶点的颜色进行加权平均。

假设三角形的三个顶点分别是 v0、v1、v2,它们对应的颜色分别是 C0、C1、C2。在三角形内部任意一点(片段)的位置可以用 重心坐标(Barycentric Coordinates)表示为 λ0, λ1, λ2,这些坐标是通过片段与三个顶点的相对位置计算出来的。

片段颜色 CCC 可以通过以下公式计算:

C=λ0C0+λ1C1+λ2C2C

这里:

  • λ0+λ1+λ2=1 (重心坐标的性质)
  • λ0, λ1, λ2 是片段到每个顶点的权重,决定了颜色的插值方式。

透视校正插值 vs 线性插值

  1. 线性插值(Linear Interpolation)

在没有透视投影的情况下,WebGL 默认会对颜色属性进行 线性插值。即,片段的颜色值是直接基于屏幕空间中顶点颜色的线性权重计算的。在这种情况下,插值过程不考虑投影矩阵的影响。

  1. 透视校正插值(Perspective-Correct Interpolation)

在透视投影的场景下,直接进行线性插值会导致视觉错误,例如颜色变形或失真。为了解决这个问题,WebGL 采用了 透视校正插值。它会先将属性值除以顶点的齐次坐标 w 分量(从投影矩阵计算得到),再进行插值,然后在片段着色器中将插值后的结果重新乘以片段的 w

这是因为透视投影会导致距离远的对象在屏幕空间中看起来更小,因此片段的插值必须考虑这种透视效果,否则就会出现视觉上的错误。

3 在矩形表面贴上图像

纹理映射:将一张图片贴在一个几何图形表面

在WebGL中,进行纹理映射,需要进行以下几步

  1. 准备图像
  2. 为几何图形配置纹理映射方式
  3. 加载纹理图像,对齐进行一些配置,以在WebGL中使用
  4. 在片段着色器中将相应的纹素从纹理中抽取出来,并将纹素的颜色赋给片元

3.1 纹理坐标

纹理坐标是纹理图像上的坐标,通过纹理坐标可以在纹理图像上获取纹素颜色,在WebGL中使用s和t或者u和v来描述纹理坐标。纹理图像坐标由(-1,-1)到(1,1)。

u_Sampler意为取样器,纹理像素是有大小的,取样处的纹理坐标很可能并不是落在某个像素中心,所以取样通常并不是直接取纹理图像某个像素的颜色,而是通过附件的若干个像素共同计算得到。

代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <style>
    #canvas {
      width: 600px;
      height: 600px;
      position: absolute;
      top: calc(50% - 300px);
      left: calc(50% - 300px);
      background-color: black;
    }
  </style>
  <body>
    <canvas id="canvas"></canvas>
    <script>
      // 定义顶点着色器
      var VSHADER_SOURCE =
        "attribute vec4 a_Position;\n" +
        "attribute vec2 a_TexCoord;\n" +
        "varying vec2 v_TexCoord;\n" +
        "void main() {\n" +
        "  gl_Position = a_Position;\n" +
        "  v_TexCoord = a_TexCoord;\n" +
        "}\n";

      // Fragment shader program
      var FSHADER_SOURCE =
        "#ifdef GL_ES\n" +
        "precision mediump float;\n" +
        "#endif\n" +
        "uniform sampler2D u_Sampler;\n" +
        "varying vec2 v_TexCoord;\n" +
        "void main() {\n" +
        "  gl_FragColor = texture2D(u_Sampler, v_TexCoord);\n" +
        "}\n";

      function main() {
        const canvas = document.getElementById("canvas");
        const gl = canvas.getContext("webgl");

        // 初始化着色器
        initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE);

        //  初始化buffer
        var n = initVertexBuffers(gl);

        // 清除canvas
        gl.clearColor(0.0, 0.0, 0.0, 1.0);

        // 设置纹理
        initTextures(gl, n);
      }

      function createProgram(gl, vshader, fshader) {
        var vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
        var fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
        var program = gl.createProgram();
        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);
        gl.linkProgram(program);
        var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
        return program;
      }

      function initShaders(gl, vshader, fshader) {
        var program = createProgram(gl, vshader, fshader);
        gl.useProgram(program);
        gl.program = program;
        return true;
      }

      function loadShader(gl, type, source) {
        var shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
        return shader;
      }

      function initVertexBuffers(gl) {
        var verticesTexCoords = new Float32Array([
          //顶点坐标, 纹理坐标
          -0.5, 0.5, 0.0, 1.0, -0.5, -0.5, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 0.5,
          -0.5, 1.0, 0.0,
        ]);
        var n = 4;

        var vertexTexCoordBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexTexCoordBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, verticesTexCoords, gl.STATIC_DRAW);
        var FSIZE = verticesTexCoords.BYTES_PER_ELEMENT;
        var a_Position = gl.getAttribLocation(gl.program, "a_Position");
        gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0);
        gl.enableVertexAttribArray(a_Position);

        // 将纹理坐标分配给a_TexCoord,并开启他
        var a_TexCoord = gl.getAttribLocation(gl.program, "a_TexCoord");
        gl.vertexAttribPointer(
          a_TexCoord,
          2,
          gl.FLOAT,
          false,
          FSIZE * 4,
          FSIZE * 2
        );
        gl.enableVertexAttribArray(a_TexCoord);

        return n;
      }

      function initTextures(gl, n) {
        var texture = gl.createTexture();
        // 获取u_Sampler存储位置
        var u_Sampler = gl.getUniformLocation(gl.program, "u_Sampler");

        var image = new Image();
        // 注册图像加载时间的响应函数
        image.onload = function () {
          loadTexture(gl, n, texture, u_Sampler, image);
        };
        // 浏览器开始加载图像
        image.src = "./asset/OIP.jpg";
        return true;
      }

      function loadTexture(gl, n, texture, u_Sampler, image) {
        // 对纹理图像进行y轴反转:图片跟纹理y轴方向不一样
        // pixelStorei 用于描述像素存储模式的函数
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
        // 开启 0号纹理单元
        // WebGL通过一种纹理单元的机制来同时使用多个纹理,每个纹理都有一个单眼编号来管理一张纹理图像,默认支持8个纹理单元
        gl.activeTexture(gl.TEXTURE0);
        // 向target绑定纹理对象
        gl.bindTexture(gl.TEXTURE_2D, texture);
        // 配置纹理参数
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        // 将纹理图像分配给纹理对象
        gl.texImage2D(
          gl.TEXTURE_2D,
          0,
          gl.RGBA,
          gl.RGBA,
          gl.UNSIGNED_BYTE,
          image
        );
        // 将0号纹理传递给着色器
        gl.uniform1i(u_Sampler, 0);

        gl.clear(gl.COLOR_BUFFER_BIT);

        gl.drawArrays(gl.TRIANGLE_STRIP, 0, n);
      }

      main();
    </script>
  </body>
</html>

效果图

在 WebGL 中,当你使用纹理时,纹理的坐标 (s, t)(也称为 UV 坐标)通常位于 [0, 1] 的范围内。但在某些情况下,纹理坐标可能超出这个范围,需要指定纹理如何处理这些坐标。WebGL 提供了三种常用的纹理重复模式,它们是通过方法设置的。具体来说,它们控制了当纹理坐标超出 [0, 1] 范围时,纹理是如何被映射的。

注意: WebGL加载图像时,由于没有开启本地服务器,检测不到域名,会判断为跨域,因此需要开启本地开发服务器,可以直接采用vite脚手架搭建一个本地开发服务器,将index.html复制过去。

3.2 超出纹理坐标[0, 1]的情况

WebGL支持2 的幂次方纹理,比如256,当使用非 2 的幂次方纹理时,WebGL 会强制应用 gl.CLAMP_TO_EDGE,即使你设置了 gl.REPEAT。因此在这种情况下,纹理会按照 gl.CLAMP_TO_EDGE 的行为处理,而不会重复显示。

3.2.1 纹理映射时手动设置的坐标超过 [0, 1]

在 WebGL 中,纹理坐标(通常称为 UV 坐标)被定义在 [0, 1] 的范围内。然而,如果程序在定义顶点或片段时给定的纹理坐标超过了这个范围,就会导致坐标超出。常见的情况如下:

  • 纹理平铺效果: 为了让纹理重复,程序员可能故意将纹理坐标设置为超过 [0, 1],例如设置为 (1.5, 1.5) 或者 (-0.5, -0.5),以使纹理在某个表面上重复多次。
  • 手动设置的 UV 坐标:
var verticesTexCoords = new Float32Array([
  -0.5, 1.5,   // 顶点1的纹理坐标
  1.5, -0.5,   // 顶点2的纹理坐标
]);

3.2.2 模型或几何体的大小比例导致纹理坐标超出

当几何体被缩放、旋转或移动时,应用于该几何体的纹理坐标可能会超出 [0, 1] 范围。例如,假设你有一个二维的矩形,你将其缩放或拉伸到比原始尺寸更大。此时,尽管原始的纹理坐标在 [0, 1] 之间,但由于几何体的变化,纹理坐标映射的区域会超出这个范围。

  • 几何体放大: 当几何体被拉伸时,纹理坐标会跟随几何体变化,从而可能超过 [0, 1]。
  • 例如在缩放的矩形上使用纹理:
var scalingMatrix = mat4.create();
mat4.scale(scalingMatrix, scalingMatrix, [2.0, 2.0, 1.0]); // 放大 2 倍

3.2.3 程序对纹理进行平铺操作

在许多情况下,程序需要让纹理在大面积表面上重复出现,比如地板、墙壁等。为了实现这个效果,程序员可以手动增大纹理坐标,使其超出 [0, 1],以便在整个表面上均匀分布。例如,地面上的地砖图案需要重复数次。

  • 地板平铺纹理: 在地面或墙面上进行平铺时,纹理坐标可能会被设置为大于 1,以便重复多次。
var verticesTexCoords = new Float32Array([
  0, 0,   // 左下角的纹理坐标
  3, 0,   // 右下角的纹理坐标,重复3次
  0, 3,   // 左上角的纹理坐标,重复3次
  3, 3,   // 右上角的纹理坐标,重复3次
]);

3.2.4 纹理坐标插值产生的超出

在 WebGL 中,顶点着色器和片段着色器之间会对顶点属性进行插值。如果顶点着色器中给定的纹理坐标在 [0, 1] 范围内,但由于几何形状或插值的原因,在片段着色器阶段的纹理坐标可能超出 [0, 1]。

  • 插值超出: 例如,如果四个顶点的纹理坐标为 (0, 0), (2, 0), (0, 2), (2, 2),在插值过程中,中间的坐标可能会超出 [0, 1]。
  • 插值示例:
varying vec2 v_TexCoord;
// 顶点着色器中,给某个顶点分配大于1的纹理坐标
v_TexCoord = a_TexCoord;

3.2.5 应用某种变换(旋转、缩放等)

在 WebGL 中应用几何变换,如旋转、缩放、平移等操作时,纹理坐标可能会超出 [0, 1]。例如,当你将一个四边形旋转到某个角度时,原本在 [0, 1] 之间的纹理坐标可能被旋转到超出这个范围。

  • 旋转变换: 旋转几何体会导致部分纹理坐标超出。
var rotationMatrix = mat4.create();
mat4.rotateZ(rotationMatrix, rotationMatrix, Math.PI / 4); // 旋转 45 度

3.2.6 纹理坐标自动生成或采样时

当 WebGL 进行一些高级的纹理操作时,比如通过计算法线贴图或进行环境映射,纹理坐标有时会自动生成,并可能超出 [0, 1] 的范围。

  • 自动生成的纹理坐标: 在环境映射或其他高级纹理映射技术中,程序会自动生成纹理坐标,这些坐标经常会超过 [0, 1]。

3.2.7 总结

纹理坐标会在以下情况超出 [0, 1]:

  • 手动设置的纹理坐标超过 [0, 1]。
  • 模型几何体的缩放或平铺导致坐标超过 [0, 1]。
  • 进行插值计算时,插值结果可能超出 [0, 1]。
  • 对几何体应用变换(如缩放、旋转等)。
  • 通过某些自动生成的方式获得的纹理坐标。

这些超出的纹理坐标在 WebGL 中通过 gl.REPEAT、gl.MIRRORED_REPEAT和gl.CLAMP_TO_EDGE等纹理包装模式来处理。

3.3 gl.texParameteri参数详解

3.3.1 参数描述

targetgl.TEXTURE_2D或者gl.TEXTURE_CUBE_MAP
pname(纹理参数)接受四种类型参数
gl.TEXTURE_WRAP_S
gl.TEXTURE_WRAP_T
gl.TEXTURE_MAG_FILTER
gl.TEXTURE_MIN_FILTER
param(纹理参数值)参数值

可以分配给 gl.TEXTURE_MAG_FILTER 和 gl.TEXTURE_MIN_FILTER 的值

gl.NEAREST和gl.LINEAR

可以分配给 gl.TEXTURE_WRAP_S 和 gl.TEXTURE_WRAP_T 的值

gl.REPEAT、gl.MIRRORED_REPEA和gl.CLAMP_TO_EDGE

在WebGL 中,当你使用纹理时,纹理的坐标 (s, t)(也称为 UV 坐标)通常位于 [0, 1] 的范围内。但在某些情况下,纹理坐标可能超出这个范围,需要指定纹理如何处理这些坐标。WebGL 提供了三种常用的纹理重复模式,它们是通过 gl.texParameteri() 方法设置的。具体来说,它们控制了当纹理坐标超出 [0, 1] 范围时,纹理是如何被映射的。

3.3.2. gl.REPEAT

  • 描述: 当纹理坐标超出 [0, 1] 时,纹理将重复平铺。这个模式会忽略超出的整数部分,仅保留小数部分。这种方式可以让纹理不断重复,从而创建无缝平铺的效果。
  • 适用场景: 当你希望纹理能够在多个方向上无限重复时使用,比如在地面、墙壁等大面积表面平铺纹理。
  • 示例: 如果纹理坐标 s = 1.5,实际采样的坐标将是 s = 0.5,因为 1.5 - 1 = 0.5,即忽略了整数部分。
  • 效果示意
0   1   2
────┼───┼───→ s
[image] [image] [image]
  • 代码设置:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); // s方向重复
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); // t方向重复

3.3.3 gl.MIRRORED_REPEAT

  • 描述: 当纹理坐标超出 [0, 1] 时,纹理会以镜像形式重复平铺。奇数次超出的部分会反转显示,类似镜像效果。
  • 适用场景: 当需要平铺纹理但希望边界部分产生镜像效果时,可以使用该模式,例如在生成地形时避免接缝过于突兀。
  • 示例: 如果纹理坐标 s = 1.5,实际采样的坐标将是 s = 0.5,但如果 s = 2.5,实际采样的坐标将是 s = 0.5,因为 2 表示一次完整的重复,剩余部分根据镜像规则处理。
  • 效果示意
0   1   2
────┼───┼───→ s
[image] [mirrored-image] [image]
  • 代码设置:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT); // s方向镜像重复
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT); // t方向镜像重复

3.3.4. gl.CLAMP_TO_EDGE

  • 描述: 当纹理坐标超出 [0, 1] 时,WebGL 会使用纹理的边缘颜色来填充,而不是重复纹理。这意味着所有超过 1 的坐标会使用纹理右边缘的颜色,小于 0 的坐标会使用纹理左边缘的颜色。这个模式会阻止纹理的重复或镜像效果。
  • 适用场景: 该模式在需要纹理平滑过渡到背景时很有用,比如天空盒或需要避免边界重复的场景。
  • 示例: 如果纹理坐标 s = 1.2,实际采样的坐标将被夹到 s = 1.0,也就是使用纹理的最右边缘部分。
  • 效果示意
0   1
────┼───→ s
[image][edge color]
  • 代码设置:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); // s方向夹取边缘
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // t方向夹取边缘

3.3.5 gl.NEAREST(最近点采样)

gl.NEAREST 是最简单的纹理过滤模式。它选择与当前像素最接近的纹理像素(texel)作为结果,不进行插值。

  • 特点:
    • 直接选择最接近的纹素。
    • 无需进行任何颜色混合或插值。
    • 效果: 图像会显得比较块状或像素化,特别是在放大时明显,因为纹理直接使用最近的纹素颜色。
  • 适用场景:
    • 使用像素艺术风格时,保留清晰的像素边缘。
    • 对性能要求较高的场景,因为 gl.NEAREST 计算速度快,不需要额外插值计算。
  • 示例:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  • 示例效果:
    • 在放大图像时,纹理看起来有明显的像素块,边缘锐利。
    • 在缩小时,图像看起来可能有跳跃感或明显失真。

3.3.6 gl.LINEAR(双线性插值)

gl.LINEAR 使用双线性插值进行纹理采样。它会考虑附近四个纹理像素(texel),并对它们的颜色进行加权平均,以计算最终颜色值。

  • 特点:
    • 对周围的多个纹素进行插值计算,最终生成平滑的颜色过渡。
    • 效果: 图像的边缘会显得更加平滑,减少了像素化效果,但可能会导致模糊。
  • 适用场景:
    • 场景要求较高的视觉质量,特别是在纹理被放大或缩小时。
    • 渲染较复杂的纹理,如照片或高清图像时,gl.LINEAR 能提供更好的视觉效果。
  • 示例:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  • 示例效果:
    • 在放大图像时,纹理会显得更柔和,没有明显的像素块,边缘平滑。
    • 在缩小时,图像不会出现跳跃,但由于插值,会显得有点模糊。
3.3.6.1 对比 gl.NEARESTgl.LINEAR
过滤模式描述优点缺点应用场景
gl.NEAREST最近点采样快速、适合像素风格图像像素化、边缘生硬像素艺术、低分辨率图形、高性能需求
gl.LINEAR双线性插值边缘平滑,过渡自然图像可能模糊,性能稍差高清图像、需要平滑过渡的场景
3.3.6.2 纹理过滤应用场景
  • 放大过滤(Magnification Filter):当一个纹理在屏幕上被放大时,如何决定屏幕像素颜色。可以设置为 gl.NEARESTgl.LINEAR
  • 缩小过滤(Minification Filter):当一个纹理在屏幕上被缩小时,如何决定屏幕像素颜色。对于缩小过滤,还可以结合使用多重贴图(Mipmap),即预先存储多个不同缩放级别的纹理图像。

示例代码

// 放大过滤使用 gl.LINEAR,缩小过滤使用 gl.NEAREST
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
3.3.6.3 总结
  • gl.NEAREST:最近邻采样,图像效果锐利但可能显得粗糙、像素化,适合像素风格或高性能要求的场景。
  • gl.LINEAR:双线性插值采样,图像效果平滑、柔和,适合高清纹理或对图像质量要求较高的场景。

在实际应用中,可以根据具体的图像效果需求和性能要求,选择合适的过滤模式。

4 使用多幅纹理

WebGL支持多幅纹理,纹理单元就是为了这个目的设计的。

代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <style>
    #canvas {
      width: 600px;
      height: 600px;
      position: absolute;
      top: calc(50% - 300px);
      left: calc(50% - 300px);
      background-color: black;
    }
  </style>
  <body>
    <canvas id="canvas"></canvas>
    <script>
      // 定义顶点着色器
      var VSHADER_SOURCE =
        "attribute vec4 a_Position;\n" +
        "attribute vec2 a_TexCoord;\n" +
        "varying vec2 v_TexCoord;\n" +
        "void main() {\n" +
        "  gl_Position = a_Position;\n" +
        "  v_TexCoord = a_TexCoord;\n" +
        "}\n";

      // 片段着色器
      var FSHADER_SOURCE =
        "#ifdef GL_ES\n" +
        "precision mediump float;\n" +
        "#endif\n" +
        "uniform sampler2D u_Sampler0;\n" +
        "uniform sampler2D u_Sampler1;\n" +
        "varying vec2 v_TexCoord;\n" +
        "void main() {\n" +
        "  vec4 color0 = texture2D(u_Sampler0, v_TexCoord);\n" +
        "  vec4 color1 = texture2D(u_Sampler1, v_TexCoord);\n" +
        "  gl_FragColor = color0 * color1;\n" +
        "}\n";
      function main() {
        const canvas = document.getElementById("canvas");
        const gl = canvas.getContext("webgl");

        // 初始化着色器
        initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE);

        //  初始化buffer
        var n = initVertexBuffers(gl);

        // 清除canvas
        gl.clearColor(0.0, 0.0, 0.0, 1.0);

        // 设置纹理
        initTextures(gl, n);
      }

      function createProgram(gl, vshader, fshader) {
        var vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
        var fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
        var program = gl.createProgram();
        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);
        gl.linkProgram(program);
        var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
        return program;
      }

      function initShaders(gl, vshader, fshader) {
        var program = createProgram(gl, vshader, fshader);
        gl.useProgram(program);
        gl.program = program;
        return true;
      }

      function loadShader(gl, type, source) {
        var shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
        return shader;
      }

      function initVertexBuffers(gl) {
        var verticesTexCoords = new Float32Array([
          //顶点坐标, 纹理坐标
          -0.5, 0.5, 0.0, 1.0, -0.5, -0.5, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 0.5,
          -0.5, 1.0, 0.0,
        ]);
        var n = 4;

        var vertexTexCoordBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexTexCoordBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, verticesTexCoords, gl.STATIC_DRAW);
        var FSIZE = verticesTexCoords.BYTES_PER_ELEMENT;
        var a_Position = gl.getAttribLocation(gl.program, "a_Position");
        gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0);
        gl.enableVertexAttribArray(a_Position);

        // 将纹理坐标分配给a_TexCoord,并开启他
        var a_TexCoord = gl.getAttribLocation(gl.program, "a_TexCoord");
        gl.vertexAttribPointer(
          a_TexCoord,
          2,
          gl.FLOAT,
          false,
          FSIZE * 4,
          FSIZE * 2
        );
        gl.enableVertexAttribArray(a_TexCoord);

        return n;
      }
      let g_texUnit0 = false;
      let g_texUnit1 = false;
      function initTextures(gl, n) {
        var texture0 = gl.createTexture();
        var texture1 = gl.createTexture();
        // 获取u_Sampler存储位置
        var u_Sampler0 = gl.getUniformLocation(gl.program, "u_Sampler0");
        var u_Sampler1 = gl.getUniformLocation(gl.program, "u_Sampler1");

        var image0 = new Image();
        var image1 = new Image();
        // 注册图像加载时间的响应函数
        image0.onload = function () {
          loadTexture(gl, n, texture0, u_Sampler0, image0, 0);
        };
        image1.onload = function () {
          loadTexture(gl, n, texture1, u_Sampler1, image1, 1);
        };
        // 浏览器开始加载图像
        image0.src = "./asset/OIP.jpg";
        image1.src = "./asset/OIP1.jpg";
        return true;
      }

      function loadTexture(gl, n, texture, u_Sampler, image, texUnit) {
        // 对纹理图像进行y轴反转:图片跟纹理y轴方向不一样
        // pixelStorei 用于描述像素存储模式的函数
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
        // 开启 0号纹理单元
        // WebGL通过一种纹理单元的机制来同时使用多个纹理,每个纹理都有一个单眼编号来管理一张纹理图像,默认支持8个纹理单元
        if (texUnit == 0) {
          gl.activeTexture(gl.TEXTURE0);
          g_texUnit0 = true;
        } else {
          gl.activeTexture(gl.TEXTURE1);
          g_texUnit1 = true;
        }
        // 向target绑定纹理对象
        gl.bindTexture(gl.TEXTURE_2D, texture);
        // 配置纹理参数
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        // 将纹理图像分配给纹理对象
        gl.texImage2D(
          gl.TEXTURE_2D,
          0,
          gl.RGBA,
          gl.RGBA,
          gl.UNSIGNED_BYTE,
          image
        );
        // 将0号纹理传递给着色器
        gl.uniform1i(u_Sampler, 0);

        gl.clear(gl.COLOR_BUFFER_BIT);

        // 图片的加载是异步的,所以这里提供了两个全局变量来记录图片加载状态,待所有图片加载完毕之后再渲染视图
        if (g_texUnit0 && g_texUnit1) {
          gl.drawArrays(gl.TRIANGLE_STRIP, 0, n);
        }
      }

      main();
    </script>
  </body>
</html>

效果图