图形编程中的纹理,是一个很大的话题,涉及到的知识面非常多,有硬件的,也有软件的,有实时渲染技术,也有标准的实现等非常多可以讨论的。

受制于个人学识浅薄,本文只能浅表性地列举 WebGL 和 WebGPU 中它们创建、数据传递和着色器中大致的用法,格式差异,顺便捞一捞压缩纹理的资料。

1. WebGL 中的纹理

1.1. 创建二维纹理与设置采样参数

创建纹理对象 texture,并将其绑定:

const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)

此时这个对象只是一个空的 WebGLTexture,还没有发生数据传递。

WebGL 没有采样器 API,纹理采样参数的设置是通过调用 gl.texParameteri() 方法完成的:

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.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)

采样参数是 gl.TEXTURE_WRAP_Sgl.TEXTURE_WRAP_Tgl.TEXTURE_MIN_FILTERgl.TEXTURE_MAG_FILTER,这四个采样参数的值分别是 gl.CLAMP_TO_EDGEgl.CLAMP_TO_EDGEgl.NEARESTgl.NEAREST,具体含义就不细说了,我认为这方面的资料还是蛮多的。

1.2. 纹理数据写入与拷贝

首先,是纹理数据的写入。

使用 gl.texImage2D() 方法将内存中的数据写入至纹理中,流向是 CPU → GPU

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)

这个函数有非常多种重载,可以自行查阅 MDN 或 WebGL 有关规范。

上述函数调用传递的 imageImage 类型的,也即 HTMLImageElement;其它的重载可以使用的数据来源还可以是:

  • ArrayBufferViewUint8ArrayUint16ArrayUint32ArrayFloat32Array
  • ImageData
  • HTMLImageElement/HTMLCanvasElement/HTMLVideoElement
  • ImageBitmap

不同数据来源有对应的数据写入方法。

其次,是纹理的拷贝。

WebGL 2.0 使用 gl.blitFramebuffer() 方法,以帧缓冲对象为媒介,拷贝附着在两类附件上的关联纹理对象。

下面为拷贝 renderableFramebuffer 的颜色附件的简单示例代码:

const renderableFramebuffer = gl.createFramebuffer();
const colorFramebuffer = gl.createFramebuffer();

// ... 一系列绑定和设置 ...

gl.bindFramebuffer(gl.READ_FRAMEBUFFER, renderableFramebuffer);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, colorFramebuffer);

// ... 执行绘制 ...

gl.blitFramebuffer(    
  0, 0, FRAMEBUFFER_SIZE.x, FRAMEBUFFER_SIZE.y,    
  0, 0, FRAMEBUFFER_SIZE.x, FRAMEBUFFER_SIZE.y,    
  gl.COLOR_BUFFER_BIT, gl.NEAREST
);

WebGL 2.0 允许将 FBO 额外绑定到可读帧缓冲(gl.READ_FRAMEBUFFER)或绘制帧缓冲(gl.DRAW_FRAMEBUFFER),WebGL 1.0 只能绑定至单个帧缓冲 gl.FRAMEBUFFER.

WebGL 1.0 没那么便利,就只能自己封装比较麻烦一点的做法了,提供如下思路:

  • 把目标纹理附着到一个 FBO 上,利用一个 WebGLProgram 把源纹理通过着色器渲染进 FBO
  • 把源纹理附着到一个 FBO 上,利用 gl.copyTexImage2D()gl.copyTexSubImage2D() 方法拷贝到目标纹理
  • 把源纹理附着到一个 FBO 上或直接绘制到 canvas 上,使用 gl.readPixels() 读取渲染结果,然后使用 gl.texImage2D() 将像素数据写入目标纹理(这个方法看起来很蠢,虽然技术上行得通)

1.3. 着色器中的纹理

如何在片元着色器代码中对纹理进行采样,获取该顶点对应的纹理颜色呢?

很简单,获取顶点着色器发送过来的插值后的片元纹理坐标 v_texCoord,然后对纹理对象进行采样即可。

uniform sampler2D u_textureSampler;
varying vec2 v_texCoord;

void main() {
  gl_FragColor = texture2D(u_textureSampler, v_texCoord);
}

关于如何通过 uniform 传递纹理到着色器中,还请查阅我之前发过的 Uniform 一文。

1.4. 纹理对象 vs 渲染缓冲对象

很多国内外的文章有介绍这两个东西,它们通常出现在离屏渲染容器 - 帧缓冲对象的关联附件上。

感兴趣 FBO / RBO 主题的可以翻翻我不久之前的文章。

纹理与渲染缓冲,即 WebGLTextureWebGLRenderbuffer,其实最大的区别就是纹理允许再次通过 uniform 的形式传给下一个渲染通道的着色器,进行纹理采样。有资料说这两个是存在性能差异的,但是我认为那点差异还不如认真设计好架构。

  • 如果你使用 MRT(无论是通过扩展还是直接使用 WebGL 2.0)技术,建议优先选择渲染缓冲对象,但是其实用哪个都无所谓;
  • 如果你要使用 WebGL 2.0 的 MSAA,那你得用渲染缓冲;
  • 如果你要把 draw 的结果再次传递给下一个渲染通道,那么你得用纹理对象;
  • 对于读像素,用哪个都无所谓,看你用的是 WebGL 1.0 还是 WebGL 2.0,都有对应的方法。

1.5. 立方体六面纹理

这东西虽然是给立方体的六个面贴图用的“特殊”纹理,但是非常合适做环境贴图,对应的数据传递函数、着色器采样函数都略有不同。

// 注意第一个参数,既然有 6 面,就有六个值,这里是 X 轴正方向的面
gl.texImage2D(
  gl.TEXTURE_CUBE_MAP_POSITIVE_X, 
  0, 
  gl.RGBA, 
  gl.RGBA, 
  gl.UNSIGNED_BYTE, 
  imagePositiveX)

// 为立方体纹理创建 Mipmap
gl.generateMipmap(gl.TEXTURE_CUBE_MAP)

// 设置采样参数
gl.texParameteri(
  gl.TEXTURE_CUBE_MAP, 
  gl.TEXTURE_MIN_FILTER, 
  gl.LINEAR_MIPMAP_LINEAR)

在着色器中:

// 顶点着色器
attribute vec4 a_position;
uniform mat4 u_vpMatrix;
varying vec3 v_normal;

void main() {
  gl_Position = u_vpMatrix * a_position;
  // 因为位置是以几何中心为原点的,可以用顶点坐标作为法向量
  v_normal = normalize(a_position.xyz);
}

// 片元着色器
precision mediump float; // 从顶点着色器传入
varying vec3 v_normal; // 纹理
uniform samplerCube u_texture; 

void main() {   
  gl_FragColor = textureCube(u_texture, normalize(v_normal));
}

这方面资料其实也不少,网上搜索可以轻易找到。

1.6. WebGL 2.0 的变化

WebGL 2.0 增加了若干内容,资料可以在 WebGL2Fundamentals 找到,这里简单列举。

  • 在着色器中使用 textureSize() 函数获取纹理大小
  • 在着色器中使用 texelFetch() 直接获取指定坐标的纹素
  • 支持了更多纹理格式
  • 支持了 3D 纹理(而不是立方体六面纹理)
  • 支持纹理数组(每个元素都是一个单独的纹理)
  • 支持长宽大小是非 2 次幂的纹理
  • 支持若干压缩纹理格式
  • 支持深度纹理(WebGL 1.0 要调用扩展才能用)
  • 加入 WebGLSampler 对象的支持
  • ...

除此之外,GLSL 升级到 300 后,原来的 texture2D()textureCube() 纹理采样函数全部改为了 texture() 函数,详见文末参考资料的迁移文章。

1.7. Mipmapping 技术

裁剪空间里的顶点构成的形状,其实是近大远小的,这点没什么问题。对于远处的物体,透视投影变换完成后会比较小,这就没必要对这个“小”的部分使用“大”的部分一样清晰的纹理了。

Mipmap 能解决这个问题,幸运的是,WebGL 只需简单的方法调用就可以创建 Mipmap,无需操心太多。

gl.generateMipmap(gl.TEXTURE_2D)

在参考资料中,你可以在 《WebGL纹理详解之三:纹理尺寸与Mipmapping》一文中见到不错的解释,还可以看到 gl.texImage2D() 的第二个参数 level 的具体用法。

2. WebGPU 中的纹理

WebGPU 将纹理分成 GPUTextureGPUTextureView 两种对象。

2.1. GPUTexture 的创建

调用 device.createTexture() 即可创建一个纹理对象,你可以通过传参指定它的用途、格式、维度等属性。它扮演的更多时候是一个数据容器,也就是纹素的容器。

// 普通贴图
const texture = device.createTexture({
  size: [512, 512, 1],
  format: 'rgba8unorm',
  usage: GPUTextureUsage.TEXTURE_BINDING 
      | GPUTextureUsage.COPY_DST
      | GPUTextureUsage.RENDER_ATTACHMENT,
})

// 深度纹理
const depthTexture = device.createTexture({
  size: [800, 600],
  format: 'depth24plus',
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
})

// 从 canvas 中获取纹理
const gpuContext = canvas.getContext('webgpu')
const canvasTexture = gpuContext.getCurrentTexture()

上面介绍了三种创建纹理的方式,前两种类似,格式和用途略有不同;最后一个是来自 Canvas 的。

注意一点,有一些纹理格式并不是默认就支持的。如果需要特定格式,有可能还要在请求设备对象时,附上功能列表(requiredFeatures

2.2. 纹理数据写入与拷贝

知道创建纹理对象,还要知道如何往其中写入来自 JavaScript 运行时的图像资源。

首先,介绍纹理数据写入。

有两个手段可以向纹理对象写入数据:

  • 使用 ImageBitmap API(globalThis.createImageBitmap()
  • 使用解码后的 RGBA 数组

对于第一种,使用队列对象的 copyExternalImageToTexture() 方法,配合浏览器自带的 API,在队列时间轴上完成外部数据拷入纹理对象:

const diffuseTexture = device.createTexture({ /* ... */ })

/** 方法一 借助 HTMLImageElement 解码 **/
const img = document.createElement('img')
img.src = require('/assets/diffuse.png')
await img.decode()
const imageBitmap = await createImageBitmap(img)
/** 方法一 **/

/** 方法二 使用 Blob **/
const blob = await fetch(url).then((r) => r.blob())
const imageBitmap = await createImageBitmap(blob)
/** 方法二 **/

device.queue.copyExternalImageToTexture(
  { source: imageBitmap },
  { texture: diffuseTexture },
  [imageBitmap.width, imageBitmap.height]
)

上述例子提供了两种思路,第一种借助浏览器的 img 元素,也即 Image 来完成图像的网络请求、解码;第二种借助 Blob API;随后,使用 Image(HTMLImageElement)/Blob 对象创建一个 ImageBitmap,并进入队列中完成数据拷贝。

对于第二种,使用队列对象的 writeTexture() 方法,在队列时间轴上完成外部数据拷入纹理对象:

const imgRGBAUint8Array = await fetchAndParseImageToRGBATypedArray('/assets/diffuse.png')
const arrayBuffer = imgRGBAUint8Array.buffer

device.queue.writeTexture(
  { 
    bytePerRow: 4 * 512, // 每行多少字节
    rowsPerImage: 512 // 这个图像有多少行
  },
  arrayBuffer,
  { texture: diffuseTexture },
  [512, 512, 1]
)

第二种方法相对来说比较消耗性能,因为需要浏览器 API(例如借助 canvas 绘图再取数据)或其它手段(如 wasm 等)解码图像二进制至 RGBA 数组,不太适合每帧操作。

其次,介绍纹理拷贝。

与 WebGL 需要使用 FBO 或重新渲染不同,WebGPU 原生就在指令编码器上提供了纹理复制操作有关的 API:使用 commandEncoder.copyTextureToTexture() 可以完成纹理之间的拷贝,使用 commandEncoder.copyBufferToTexture()commandEncoder.copyTextureToBuffer() 可以在缓冲对象和纹理对象之间的拷贝(以便读取纹素数据)。

以纹理间的拷贝为例:

commandEncoder.copyTextureToTexture({
  texture: mipmapTexture,
  mipLevel: 4,
}, {
  texture: destTexture,
  mipLevel: 5,
}, [512, 512, 1])

这个例子将 Mipmap 纹理的第 4 级拷贝至目标纹理对象的第 5 级,纹理的大小是 512 × 512,需要注意 mipmapTexturedestTextureusage,复制源需要有 GPUTextureUsage.COPY_SRC,复制目标要有 GPUTextureUsage.COPY_DST.

既然发生在指令编码器上,那就意味着操作纹理时,与普通的渲染通道、计算通道是平级的 —— 换句话说,拷贝纹理的行为,必须在渲染通道之前或之后进行。

2.3. 纹理视图

因官方文档在我写这篇文章前,都没有给出纹理视图对象的描述,所以下面的描述是我根据 WebGPU 中关于纹理方面的 API 猜测的。

当 CPU 需要使用纹理时,譬如进行纹理数据的写入,或者纹理对象之间的拷贝,会直接在队列上进行,而且传参给的就是 GPUTexture 本身;而 GPU 需要使用纹理时,例如资源绑定组绑定一个纹理,或者渲染通道的附件需要使用容器时,通常传参给的是 GPUTextureView;所以,我猜测:

  • 纹理对象适用于 CPU 侧操作
  • 纹理视图对象为 GPU 提供操作真正纹理数据的一个窗口

创建纹理视图其实很简单,它通过调用纹理对象本身的 createView() 方法创建:

const view = texture.createView()

// 在渲染通道的颜色附件中
const renderPassDescriptor = {
  colorAttachments: [
    {
      view: canvasTexture.createView(),
      // ...
    }
  ]
}

纹理视图对象是可以传递参数对象的,类型是 GPUTextureViewDescriptor,当然这个参数对象是可选的。这个参数对象可以更具体描述纹理视图。

譬如,立方体纹理创建视图时,需要明确指定其维度(dimension)参数等参数:

const cubeTextureView = cubeTexture.createView({
  dimension: 'cube',
  arrayLayerCount: 6,
})

2.4. 着色器中的纹理与采样器

与 WebGL 使用的阉割版 GLSL 相比,WGSL 提供的类型就多多了。

WebGL 1.0 中的采样参数与 WebGL 2.0 姗姗来迟的 WebGLSampler 类型,在 WebGPU 和 WGSL 中统一为具体的变量类型,即 WebGPU 对应 GPUSampler,WGSL 对应 samplersampler_comparision 类型。

WGSL 中的纹理类型有十几种,纹理类型与纹理视图的 dimension 参数是紧密相关的,参考 WebGPU Spec - TextureView Creation

而纹理相关的函数也跟随着增多了许多,且各有用途,有最常规的纹理采样函数 textureSample,读取单个纹素的 textureLoad 函数,获取纹理尺寸的 textureDimensions(等价于 WebGL 2.0 的 textureSize),向存储型纹理写纹素的 textureStore 等,每个函数又有若干种重载。

最基本的用法,使用二维 f32 纹理对象、采样器、纹理坐标进行采样:

@group(0) @binding(1) var mySampler: sampler;
@group(0) @binding(2) var myTexture: texture_2d<f32>;

@stage(fragment)
fn main(@location(0) fragUV: vec2<f32>) -> @location(0) vec4<f32> {
    return textureSample(myTexture, mySampler, fragUV);
}

2.5. WebGPU 中的 Mipmapping

鉴于纹理技术本身的复杂性,官方在 GitHub issue 386 中关于自动生成 Mipmap 的 API 有激烈的讨论,目前倾向于不实现,把 Mipmap 的生成实现交给社区。

WebGPU 保留了 Mipmap 的支持,但是没有像 WebGL 一样提供简便的 gl.generateMipmap(gl.TEXTURE_2D) 调用方法一键生成,需要自己对纹理的每一个层生成。

幸运的是,WebGPU 社区的 Toji 大佬编写了一个工具来生成纹理的 Mipmap:web-texture-tool/src/webgpu-mipmap-generator.js,原理就是开辟一个新的指令编码器,使用一条特定的渲染管线离屏计算每一级 mipmap,最终写入一个纹理对象并返回。若源纹理具备渲染附件的用途(GPUTextureUsage.RENDER_ATTACHMENT),那么就在源纹理上生成,否则会使用 commandEncoder.copyTextureToTexture() 方法把工具类内部创建的临时 mipmap 纹理对象拷贝到源纹理对象。

目前只能对 "2d" 类型的纹理起作用,这个类的简单用法如下:

import { WebGPUMipmapGenerator } from 'web-texture-tool/webgpu-mipmap-generator.js'

/* -- 常规创建纹理 -- */
const textureDescriptor = { /**/ }
const srcTexture = device.createTexture(textureDescriptor)

/* -- 为纹理创建 mipmap -- */
const mipmapGenerator = new WebGPUMipmapGenerator(device)
mipmapGenerator.generateMipmap(srcTexture, textureDescriptor)

// ...

generateMipmap() 方法执行后,将在 2d 纹理的每个 layer 创建完成每一层 Mipmap,顺带一提,这个工具并未完全稳定,请考虑各种风险。

注意一点,这个 textureDescriptormipLevelCount 是有一个 算法 的,它必须小于等于根据纹理维度、纹理尺寸计算的 最大限制值。这里纹理维度是 2d 类型,最大尺寸是 64,那么容易算得最大 mipLevel 是 Math.floor(Math.log2(64)) + 1 = 7.

const textureDescriptor = {
  // ...
  mipLevelCount: 7, // 创建纹理时,允许人为指定 mipmap 有多少级,但是不超最大限制
  size: {
    width: 64,
    height: 64,
    depthOrArrayLayers: 1
  },
  dimension: "2d"
}
扩展阅读:ThreeJS 关于 WebGPU 这项议程,参考了 Toji 的工具,集成到 WebGPUTextureUtils 类,有关讨论见 ThreeJS pull 20284 WebGPUTextures: Add support for mipmap computation.

3. 纹理压缩编码算法

涉及到压缩纹理格式我更是只能“纸上谈兵”,这一段仅作为个人知识浅表性的记录,道阻且长...

这一小节其实与 WebGL、WebGPU 的接口并无太大关系,纹理压缩算法,或者说压缩纹理格式,是另外的一门技术,WebGL 和 WebGPU 在底层实现上做了支持。

简单的说,压缩纹理格式是一种“时间+空间换空间”的产物,需要提前生成,常见的封装文件格式有 ktx2 等(就好比 h264/5.mp4)。它有效地节约了 GPU 显存,并且解压速度比传统的 Web 图像格式 jpgpng 更快,它本身也比 jpg/png 的文件体积要小一些。

不过很遗憾的是,诸多压缩编码算法在各个软硬件厂商的实现都不太一样,没法像 jpg/png 一样广泛、普遍使用。

为了兼容性,通常会针对不同平台生成不同的压缩纹理备用,也就是所谓的“时间+空间换解压时间+显存空间”。

WebGL 1.0 只能使用 2D 纹理,WebGL 2.0 支持使用 3D 纹理,而且对压缩纹理的使用,是需要借助扩展项来完成的。例如:

const ext = (
  gl.getExtension('WEBGL_compressed_texture_s3tc') ||
  gl.getExtension('MOZ_WEBGL_compressed_texture_s3tc') ||
  gl.getExtension('WEBKIT_WEBGL_compressed_texture_s3tc')
)

const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.compressedTexImage2D(gl.TEXTURE_2D, 0, ext.COMPRESSED_RGBA_S3TC_DXT5_EXT, 512, 512, 0, textureData)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)

这个示例代码展示了在 WebGL 1.0 通过 compressedTexImage2D() 方法使用了一个 S3TC_DXT5 压缩编码的纹理数据 textureData.

具体的 WebGL 1/2 压缩扩展和用法参考 [MDN - compressedTexImage[23]D()](https://developer.mozilla.org...)

对于 WebGPU,它支持三类压缩格式:

  • texture-compression-bc
  • texture-compression-etc2
  • texture-compression-astc

请求设备对象时传入 requiredFeatures 即可请求所需压缩纹理格式:

// 以 astc 格式为例 -- 需要在适配器上判断是否支持此格式

const requiredFeatures = []
if (gpuAdapter.features.has('texture-compression-astc')) {
  requiredFeatures.push('texture-compression-astc')
}
const device = await adapter.requestDevice({
  requiredFeatures
})

当适配器支持时即可请求。这样,astc 族压缩纹理格式就全部可用了:

const compressedTextureASTC = device.createTexture({
  // ...
  format: "astc-10x6-unorm-srgb"
})

三大类型的压缩纹理格式支持列表参考 WebGPU Spec - Feature Index: 24.4, 24.5, 24.6

幸运的是,Toji 的库 toji/web-texture-tool 也为纹理的加载写了两种 Loader,用于 WebGL 和 WebGPU 中纹理数据的生成,支持压缩格式。

纹理压缩算法(格式)简单记忆规则:

  • ETC1/2 - Android
  • DXT/S3TC - Windows
  • PVRTC - Apple
  • ASTC - Will Be The Future

详细的资料在文末的参考资料里了。

4. 总结

关于 Mipmap、级联纹理、压缩格式等进阶知识,我觉得已经超出了这两个 API 比对的范围,况且个人理解尚不深,就不关公面前舞大刀了。

这篇与上篇相隔时间较长,我在学习的过程中补充了很多欠缺的知识,为了严谨和准确性也查阅了不少的例子、啃了不少的源码。

简而言之,WebGPU 把 WebGL 1/2 两代的纹理接口进行了科学统一,并且出厂自带压缩纹理格式的支持(当然,还是看具体平台的,需要按需选取)。

其中最让我感兴趣的就是 WebGPU 对纹理的二级细化,提供 GPUTextureGPUTextureView 两级 API,发文时还未见到官方规范解释这两个 API,猜测前者专注于数据的 IO,后者则提供纹理数据的一层视图(根据参数具象化纹理数据的某一方面)。

很遗憾,发文时我还没深入了解过存储型纹理,以后介绍 GPGPU 时再说吧。

参考资料


岭南灯火
83 声望60 粉丝

一介草民