WebGL 与 WebGPU比对[6] - 纹理
-
图形编程中的纹理,是一个很大的话题,涉及到的知识面非常多,有硬件的,也有软件的,有实时渲染技术,也有标准的实现等非常多可以讨论的。
受制于个人学识浅薄,本文只能浅表性地列举 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_S
、gl.TEXTURE_WRAP_T
、gl.TEXTURE_MIN_FILTER
、gl.TEXTURE_MAG_FILTER
,这四个采样参数的值分别是gl.CLAMP_TO_EDGE
、gl.CLAMP_TO_EDGE
、gl.NEAREST
、gl.NEAREST
,具体含义就不细说了,我认为这方面的资料还是蛮多的。1.2. 纹理数据写入与拷贝
首先,是纹理数据的写入。
使用
gl.texImage2D()
方法将内存中的数据写入至纹理中,流向是CPU → GPU
:gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
这个函数有非常多种重载,可以自行查阅 MDN 或 WebGL 有关规范。
上述函数调用传递的
image
是Image
类型的,也即HTMLImageElement
;其它的重载可以使用的数据来源还可以是:ArrayBufferView
:Uint8Array
、Uint16Array
、Uint32Array
、Float32Array
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 主题的可以翻翻我不久之前的文章。
纹理与渲染缓冲,即
WebGLTexture
和WebGLRenderbuffer
,其实最大的区别就是纹理允许再次通过 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 将纹理分成
GPUTexture
与GPUTextureView
两种对象。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
,需要注意mipmapTexture
和destTexture
的usage
,复制源需要有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 对应sampler
和sampler_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,顺带一提,这个工具并未完全稳定,请考虑各种风险。注意一点,这个
textureDescriptor
的mipLevelCount
是有一个 算法 的,它必须小于等于根据纹理维度、纹理尺寸计算的 最大限制值。这里纹理维度是 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 图像格式jpg
、png
更快,它本身也比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()
对于 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 对纹理的二级细化,提供
GPUTexture
和GPUTextureView
两级 API,发文时还未见到官方规范解释这两个 API,猜测前者专注于数据的 IO,后者则提供纹理数据的一层视图(根据参数具象化纹理数据的某一方面)。很遗憾,发文时我还没深入了解过存储型纹理,以后介绍 GPGPU 时再说吧。
参考资料
- WebGLFundamentals - CubeMaps
- WebGLFundamentals - WebGL 图像处理
- 郭隆邦 - ThreeJS 环境贴图
- 掘金 - WebGL学习之纹理盒
- 掘金 - WebGL2系列之多采样渲染缓冲对象
- CSDN - Three.js 使用设置envMap环境贴图创建反光效果
- CSDN - WebGL着色器GLSL ES内置函数
- 知乎 - WebGL 纹理详解
- WebGL2Fundamentals - WebGL2 纹理
- WebGL2Fundamentals - WebGL2 有什么新内容
- WebGL2Fundamentals - 迁移WebGL1到WebGL2
- WebGL 纹理
- WebGL纹理详解之一:纹理的基本使用
- WebGL纹理详解之三:纹理尺寸与Mipmapping
- MDN - WEBGL_draw_buffers
- GithubIO - WebGL 纹理详解
- 知乎 - 你所需要了解的几种纹理压缩格式原理