在 WebGL 中使用纹理

既然我们的示例程序已经有了旋转的 3D 立方体,那我们就用纹理来映射它,而不是让它的面是纯色。

加载纹理

首先要做的是添加代码来加载纹理。在我们的例子中,我们将使用一个单一的纹理,映射到我们旋转立方体的所有六个面上,但相同的技术可以用于任何数量的纹理。

注意:重要的是要注意,纹理的加载遵循 跨域规则;也就是说,您只能从您的内容获得 CORS 批准的站点加载纹理。有关详细信息,请参阅 下面的跨域纹理

注意:将这两个函数添加到您的“webgl-demo.js”脚本中

js
//
// Initialize a texture and load an image.
// When the image finished loading copy it into the texture.
//
function loadTexture(gl, url) {
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);

  // Because images have to be downloaded over the internet
  // they might take a moment until they are ready.
  // Until then put a single pixel in the texture so we can
  // use it immediately. When the image has finished downloading
  // we'll update the texture with the contents of the image.
  const level = 0;
  const internalFormat = gl.RGBA;
  const width = 1;
  const height = 1;
  const border = 0;
  const srcFormat = gl.RGBA;
  const srcType = gl.UNSIGNED_BYTE;
  const pixel = new Uint8Array([0, 0, 255, 255]); // opaque blue
  gl.texImage2D(
    gl.TEXTURE_2D,
    level,
    internalFormat,
    width,
    height,
    border,
    srcFormat,
    srcType,
    pixel,
  );

  const image = new Image();
  image.onload = () => {
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(
      gl.TEXTURE_2D,
      level,
      internalFormat,
      srcFormat,
      srcType,
      image,
    );

    // WebGL1 has different requirements for power of 2 images
    // vs. non power of 2 images so check if the image is a
    // power of 2 in both dimensions.
    if (isPowerOf2(image.width) && isPowerOf2(image.height)) {
      // Yes, it's a power of 2. Generate mips.
      gl.generateMipmap(gl.TEXTURE_2D);
    } else {
      // No, it's not a power of 2. Turn off mips and set
      // wrapping to clamp to edge
      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);
    }
  };
  image.src = url;

  return texture;
}

function isPowerOf2(value) {
  return (value & (value - 1)) === 0;
}

loadTexture() 例程首先通过调用 WebGL 的 createTexture() 函数来创建一个 WebGL 纹理对象 texture。然后它使用 texImage2D() 上传单个蓝色像素。即使可能需要一些时间才能下载我们的图像,这也能使纹理立即可用作纯蓝色。

要从图像文件加载纹理,它会创建一个 Image 对象并将 src 设置为我们希望用作纹理的图像的 URL。一旦图像下载完成,就会调用分配给 image.onload 的函数。届时,我们将再次调用 texImage2D(),这次使用图像作为纹理的来源。之后,我们将根据下载的图像的两个维度是否都是 2 的幂来设置纹理的过滤和环绕方式。

WebGL1 只能使用非 2 的幂纹理,并且过滤设置为 NEARESTLINEAR,并且无法为它们生成 mipmap。它们的环绕模式也必须设置为 CLAMP_TO_EDGE。另一方面,如果纹理的两个维度都是 2 的幂,那么 WebGL 可以进行更高质量的过滤,可以使用 mipmap,并且可以将环绕模式设置为 REPEATMIRRORED_REPEAT

重复纹理的一个例子是将几块砖的图像平铺以覆盖砖墙。

可以使用 texParameteri() 禁用 mipmapping 和 UV 重复。这将允许使用非 2 的幂 (NPOT) 纹理,但会牺牲 mipmapping、UV 环绕、UV 平铺以及您对设备如何处理纹理的控制。

js
// gl.NEAREST is also allowed, instead of gl.LINEAR, as neither mipmap.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// Prevents s-coordinate wrapping (repeating).
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
// Prevents t-coordinate wrapping (repeating).
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

同样,使用这些参数,兼容的 WebGL 设备将自动接受该纹理的任何分辨率(在其最大尺寸范围内)。如果不执行上述配置,WebGL 会要求 NPOT 纹理的所有采样都失败,返回透明黑色:rgb(0 0 0 / 0%)

要加载图像,请在我们的 main() 函数中添加一个对 loadTexture() 函数的调用。这可以添加到 initBuffers(gl) 调用之后。

但也要注意:浏览器按从上到下的顺序复制加载图像的像素——从左上角开始;但 WebGL 希望像素按从下到上的顺序排列——从左下角开始。(有关更多详细信息,请参阅 为什么我的 WebGL 纹理是颠倒的?。)

因此,为了防止生成的图像纹理在渲染时方向不正确,我们还需要调用 pixelStorei(),并将 gl.UNPACK_FLIP_Y_WEBGL 参数设置为 true——这样像素就会被翻转为 WebGL 所期望的从下到上的顺序。

注意:将以下代码添加到您的 main() 函数中,紧跟在 initBuffers() 调用之后

js
// Load texture
const texture = loadTexture(gl, "cubetexture.png");
// Flip image pixels into the bottom-to-top order that WebGL expects.
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

注意:最后,将 cubetexture.png 文件下载到与您的 JavaScript 文件相同的本地目录。

将纹理映射到面上

此时,纹理已加载并准备就绪。但在使用它之前,我们需要建立纹理坐标与我们立方体面的顶点之间的映射关系。这将替换 initBuffers() 中所有先前存在的用于为立方体每个面配置颜色的代码。

注意:将此函数添加到您的“init-buffer.js”模块中

js
function initTextureBuffer(gl) {
  const textureCoordBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer);

  const textureCoordinates = [
    // Front
    0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
    // Back
    0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
    // Top
    0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
    // Bottom
    0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
    // Right
    0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
    // Left
    0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
  ];

  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array(textureCoordinates),
    gl.STATIC_DRAW,
  );

  return textureCoordBuffer;
}

首先,此代码创建一个 WebGL 缓冲区,我们将在此缓冲区中存储每个面的纹理坐标,然后我们将该缓冲区绑定为我们将要写入的数组。

textureCoordinates 数组定义了与每个面上的每个顶点对应的纹理坐标。请注意,纹理坐标的范围是 0.0 到 1.0;纹理的尺寸在纹理映射的目的是被归一化到 0.0 到 1.0 的范围内,而不管它们的实际大小。

设置好纹理映射数组后,我们就将数组传递到缓冲区中,以便 WebGL 可以准备好使用该数据。

然后我们返回新的缓冲区。

接下来,我们需要更新 initBuffers() 以创建并返回纹理坐标缓冲区,而不是颜色缓冲区。

注意:在您的“init-buffers.js”模块的 initBuffers() 函数中,将对 initColorBuffer() 的调用替换为以下行

js
const textureCoordBuffer = initTextureBuffer(gl);

注意:在您的“init-buffers.js”模块的 initBuffers() 函数中,将 return 语句替换为以下内容

js
return {
  position: positionBuffer,
  textureCoord: textureCoordBuffer,
  indices: indexBuffer,
};

更新着色器

着色器程序也需要更新,以便使用纹理而不是纯色。

顶点着色器

我们需要替换顶点着色器,以便它能获取纹理坐标数据而不是获取颜色数据。

注意:在您的 main() 函数中,像这样更新 vsSource 声明

js
const vsSource = `
    attribute vec4 aVertexPosition;
    attribute vec2 aTextureCoord;

    uniform mat4 uModelViewMatrix;
    uniform mat4 uProjectionMatrix;

    varying highp vec2 vTextureCoord;

    void main(void) {
      gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
      vTextureCoord = aTextureCoord;
    }
  `;

这里的关键变化是,我们获取的是纹理坐标并将其传递给片段着色器,而不是获取顶点颜色;这将指示纹理中与顶点对应的位置。

片段着色器

片段着色器同样需要更新。

注意:在您的 main() 函数中,像这样更新 fsSource 声明

js
const fsSource = `
    varying highp vec2 vTextureCoord;

    uniform sampler2D uSampler;

    void main(void) {
      gl_FragColor = texture2D(uSampler, vTextureCoord);
    }
  `;

片段的颜色不是通过分配颜色值来确定的,而是通过根据 vTextureCoord 的值(该值与颜色一样,在顶点之间进行插值)来获取 纹素(即纹理中的像素)来计算的。

属性和统一变量位置

由于我们更改了一个属性并添加了一个统一变量,因此我们需要查找它们的位置。

注意:在您的 main() 函数中,像这样更新 programInfo 声明

js
const programInfo = {
  program: shaderProgram,
  attribLocations: {
    vertexPosition: gl.getAttribLocation(shaderProgram, "aVertexPosition"),
    textureCoord: gl.getAttribLocation(shaderProgram, "aTextureCoord"),
  },
  uniformLocations: {
    projectionMatrix: gl.getUniformLocation(shaderProgram, "uProjectionMatrix"),
    modelViewMatrix: gl.getUniformLocation(shaderProgram, "uModelViewMatrix"),
    uSampler: gl.getUniformLocation(shaderProgram, "uSampler"),
  },
};

绘制纹理化的立方体

drawScene() 函数的更改很简单。

注意:在您的“draw-scene.js”模块的 drawScene() 函数中,添加以下函数

js
// tell webgl how to pull out the texture coordinates from buffer
function setTextureAttribute(gl, buffers, programInfo) {
  const num = 2; // every coordinate composed of 2 values
  const type = gl.FLOAT; // the data in the buffer is 32-bit float
  const normalize = false; // don't normalize
  const stride = 0; // how many bytes to get from one set to the next
  const offset = 0; // how many bytes inside the buffer to start from
  gl.bindBuffer(gl.ARRAY_BUFFER, buffers.textureCoord);
  gl.vertexAttribPointer(
    programInfo.attribLocations.textureCoord,
    num,
    type,
    normalize,
    stride,
    offset,
  );
  gl.enableVertexAttribArray(programInfo.attribLocations.textureCoord);
}

注意:在您的“draw-scene.js”模块的 drawScene() 函数中,将对 setColorAttribute() 的调用替换为以下行

js
setTextureAttribute(gl, buffers, programInfo);

然后添加代码来指定要映射到面上的纹理。

注意:在您的 drawScene() 函数中,在两次调用 gl.uniformMatrix4fv() 之后,立即添加以下代码

js
// Tell WebGL we want to affect texture unit 0
gl.activeTexture(gl.TEXTURE0);

// Bind the texture to texture unit 0
gl.bindTexture(gl.TEXTURE_2D, texture);

// Tell the shader we bound the texture to texture unit 0
gl.uniform1i(programInfo.uniformLocations.uSampler, 0);

WebGL 提供了至少 8 个纹理单元;第一个是 gl.TEXTURE0。我们告诉 WebGL 我们要影响单元 0。然后我们调用 bindTexture(),它将纹理绑定到纹理单元 0 的 TEXTURE_2D 绑定点。然后我们告诉着色器 uSampler 使用纹理单元 0。

最后,将 texture 添加为 drawScene() 函数的参数,包括定义它的地方和调用它的地方。

更新您 drawScene() 函数的声明以添加新参数

js
function drawScene(gl, programInfo, buffers, texture, cubeRotation) {
  // …
}

更新您 main() 函数中调用 drawScene() 的地方

js
drawScene(gl, programInfo, buffers, texture, cubeRotation);

至此,旋转的立方体应该准备就绪。

查看完整代码 | 在新页面打开此演示

跨域纹理

WebGL 纹理的加载受跨域访问控制的约束。为了让您的内容加载另一个域的纹理,需要获得 CORS 批准。有关 CORS 的详细信息,请参阅 HTTP 访问控制

由于 WebGL 现在要求纹理必须从安全上下文加载,因此您不能在 WebGL 中使用从 file:/// URL 加载的纹理。这意味着您需要一个安全的 Web 服务器来测试和部署您的代码。有关本地测试,请参阅我们的指南 如何设置本地测试服务器?

有关如何使用 CORS 批准的图像作为 WebGL 纹理的解释,请参阅这篇 hacks.mozilla.org 文章

被标记(仅写入)的 2D 画布不能用作 WebGL 纹理。例如,当一个跨域图像被绘制到 2D <canvas> 上时,该画布会被标记。