跳转至

9.5 纹理

Textures

纹理本质上是图元上从一点到另一点变化的某些属性。最常见的 —— 或者至少是最可见的 —— 一种纹理是颜色从点到点的变化,最常见的颜色纹理是图像纹理。其他类型的纹理,如反射率或法向量的变化,也是可能的。

图像纹理在 OpenGL 中的 第4.3节 以及 WebGL 中的 第6.4节第7.3节 有介绍。大多数基本思想都适用于 WebGPU,尽管编码细节不同。

WebGPU 拥有一维、二维和三维图像纹理以及立方体贴图纹理(见 5.3.4小节)。在本节的大部分内容中,我将集中讨论二维图像纹理。

A texture is simply some property that varies from point to point on a primitive. The most common—or at least the most visible—kind of texture is a variation in color from point to point, and the most common type of color texture is an image texture. Other kinds of texture, such as variations in reflectivity or normal vector, are also possible.

Image textures were covered in Section 4.3 for OpenGL and in Section 6.4 and Section 7.3 for WebGL. Most of the basic ideas carry over to WebGPU, even though the coding details are different.

WebGPU has one-, two-, and three-dimensional image textures plus cubemap textures (Subsection 5.3.4). I will concentrate on two-dimensional image textures for most of this section.

9.5.1 纹理坐标

Texture Coordinates

当图像纹理应用于表面时,通过基于该点的纹理坐标对纹理进行采样,以获取该点的纹理颜色。采样是在 WebGPU 程序的 GPU 端使用类型为 sampler 的 WGSL 变量完成的。

2D 图像纹理带有标准的 (u,v) 坐标系。坐标在图像上的范围是 0 到 1。纹理坐标在 0 到 1 范围之外的行为取决于用于采样纹理的采样器。对于 1D 纹理,只使用 u 坐标,对于 3D 纹理,坐标系被称为 (u,v,w)。

在将 2D 纹理图像应用于表面时,表面上某点的两个纹理坐标将该表面点映射到 (u,v) 坐标系中的点。采样过程使用 (u,v) 坐标从图像中查找颜色。查找过程可能很复杂,被称为“过滤”,可能涉及查看图像及其 mipmaps 中的多个 texels 的颜色(记住,纹理中的像素通常被称为 texels)。

按照惯例,我们可以将纹理坐标 (0,0) 指向图像的左上角,u 从右向左增加,v 从上到下增加。这只是一种惯例,但它对应于网络上图像数据的存储方式:图像左上角像素的数据首先被存储,数据按行存储,从图像顶部到底部。

注意,OpenGL 中的纹理坐标系统使用 r, s 和 t 作为坐标名称,而不是 u, v 和 w。OpenGL 中的约定是 t 轴指向上方,纹理坐标 (0,0) 指向图像的左下角。考虑到这一点,请参见 4.3.1小节 以更深入地讨论纹理坐标及其使用方法。

示例程序 webgpu/first_texture.html 是我们在 WebGPU 中使用纹理的第一个示例。这个简单程序只是在一个正方形上绘制了三种不同的纹理:

123

正方形的纹理坐标从左上角的 (0,0) 到右下角的 (1,1)。在图片中左边的正方形上,某点的纹理坐标被用作该点颜色的红色和绿色分量。(没有纹理图像。这是一个过程纹理的微不足道的例子(见 7.3.3小节)。右边的正方形使用图像纹理,其中“蒙娜丽莎”图像来自文件。中间的正方形也使用图像纹理,但在这个案例中,图像的颜色来自程序的一部分的像素颜色数组。该图像是一个非常小的四像素图像,有两行像素和两列像素。原始的纹理坐标在正方形上在采样纹理之前被乘以 5,以便我们在正方形上看到 5 份纹理的副本。(这是一个纹理变换的非常简单的例子(见 4.3.4小节)。

尽管我们将在本节的大部分时间讨论这个基本示例,您也可以查看 webgpu/textured_objects.html,它将纹理应用于三维形状,以及 webgpu/texture_from_canvas.html,它从同一页面上的画布获取纹理的图像。

采样是在片段着色器中完成的。用于采样的纹理坐标可能来自任何地方。但大多数情况下,纹理坐标作为顶点属性输入到着色器程序。然后,插值的纹理坐标被传递到片段着色器,在那里它们被用来采样纹理。

在示例程序中,正方形被绘制为具有四个顶点的三角形条带。有两个顶点属性,分别给出每个顶点的坐标和纹理坐标。这两个属性交错存储在一个单独的顶点缓冲区中(见 9.1.6小节)。数据来自这个数组:

const vertexData = new Float32Array([
    /* 坐标 */     /* 纹理坐标 */
    -0.8, -0.8,       0, 1,      // 左下角的数据
    0.8, -0.8,       1, 1,      // 右下角的数据
    -0.8,  0.8,       0, 0,      // 左上角的数据
    0.8,  0.8,       1, 0,      // 右上角的数据
]);

请注意,左上角的纹理坐标是 (0,0),右下角是 (1,1)。您应该检查这与插图中第一个正方形上的颜色如何对应。当用于将图像纹理映射到正方形上(没有纹理变换)时,正方形将显示图像的一个完整副本,以通常的方向显示。如果 OpenGL 纹理坐标的约定用于正方形上,纹理坐标 (0,0) 将被分配给正方形的左下角,图像将出现倒置。为了解决这个问题,在将图像数据加载到纹理之前,OpenGL 中的图像通常会垂直翻转。见 6.4.2小节 的末尾。如果您使用的是带有纹理坐标的几何模型,它们很可能是为 OpenGL 设计的纹理坐标,您可能会发现您需要翻转您的图像以正确地应用到模型上。例如,在 textured objects 示例中就是这种情况。

When an image texture is applied to a surface, the texture color for a point is obtained by sampling the texture, based on texture coordinates for that point. Sampling is done on the GPU side of a WebGPU program, using a WGSL variable of type sampler.

A 2D image texture comes with a standard (u,v) coordinate system. The coordinates range from 0 to 1 on the image. What happens for texture coordinates outside the range 0 to 1 depends on the sampler that is used to sample the texture. For a 1D texture, only the u coordinate is used, and for a 3D texture, the coordinate system is referred to as (u,v,w).

When applying a 2D texture image to a surface, the two texture coordinates for a point on the surface map that surface point to a point in the (u,v) coordinate system. The sampling process uses the (u,v) coordinates to look up a color from the image. The look-up process can be nontrivial. It is referred to as "filtering" and can involve looking at the colors of multiple texels in the image and its mipmaps. (Remember that pixels in a texture are often referred to as texels.)

By convention, we can take texture coordinates (0,0) to refer to the top-left corner of the image, with u increasing from right to left and v increasing from top to bottom. This is really just a convention, but it corresponds to the way that data for images on the web is usually stored: The data for the top-left pixel is stored first, and the data is stored row-by-row, from the top of the image to the bottom.

Note that the texture coordinate system in OpenGL uses r, s, and t as the coordinate names instead of u, v, and w. The convention in OpenGL is that the t-axis points upward, with texture coordinates (0,0) referring to the bottom-left corner of the image. With that in mind, see Subsection 4.3.1 for a more in-depth discussion of texture coordinates and how they are used.

The sample program webgpu/first_texture.html is our first example of using textures in WebGPU. This simple program just draws a square with three different textures:

123

Texture coordinates for the square range from (0,0) at the top left corner of the square to (1,1) at the bottom right corner. For the square on the left in the picture, the texture coordinates for a point on the square are used as the red and green components of the color for that point. (There is no texture image. This is a trivial example of a procedural texture (Subsection 7.3.3).) The square on the right uses an image texture, where the "Mona Lisa" image comes from a file. The middle square also uses an image texture, but in this case the colors for the image come from an array of pixel colors that is part of the program. The image is a tiny four-pixel image, with two rows of pixels and two columns. The original texture coordinates on the square are multiplied by 5 before sampling the texture, so that we see 5 copies of the texture across and down the square. (This is a very simple example of a texture transformation (Subsection 4.3.4).)

Although we will spend much of this section on this basic example, you can also look at webgpu/textured_objects.html, which applies textures to three-dimensional shapes, and webgpu/texture_from_canvas.html, which takes the image for a texture from a canvas on the same page.


Sampling is done in the fragment shader. The texture coordinates that are used for sampling could come from anywhere. But most often, texture coordinates are input to the shader program as a vertex attribute. Then, interpolated texture coordinates are passed to the fragment shader, where they are used to sample the texture.

In the sample program, the square is drawn as a triangle-strip with four vertices. There are two vertex attributes, giving the coordinates and the texture coordinates for each vertex. The two attributes are stored interleaved in a single vertex buffer (see Subsection 9.1.6). The data comes from this array:

const vertexData = new Float32Array([
/* coords */     /* texcoords */
    -0.8, -0.8,       0, 1,      // data for bottom left corner
    0.8, -0.8,       1, 1,      // data for bottom right corner
    -0.8,  0.8,       0, 0,      // data for top left corner
    0.8,  0.8,       1, 0,      // data for top right corner
]);

Note that the texture coordinates for the top left corner are (0,0) and for the bottom right corner are (1,1). You should check out how this corresponds to the colors on the first square in the illustration. When used to map an image texture onto the square (with no texture transformation), the square will show one full copy of the image, in its usual orientation. If the OpenGL convention for texture coordinates were used on the square, texture coordinates (0,0) would be assigned to the bottom left corner of the square, and the image would appear upside-down. To account for this, images in OpenGL are often flipped vertically before loading the image data into a texture. See the end of Subsection 6.4.2. If you use geometric models that come with texture coordinates, they might well be texture coordinates designed for OpenGL, and you might find that you need to flip your images to get them to apply correctly to the model. This is true, for example, in the textured objects example.

9.5.2 纹理和采样器

Textures and Samplers

WebGPU 程序中,纹理和采样器在 JavaScript 端创建,并在 GPU 端使用,它们在片段着色器中作为着色器资源。这意味着它们被声明为着色器程序中的全局变量。它们的值通过绑定组传递给着色器,因此采样器或纹理变量必须使用 @group 和 @binding 注解进行声明。例如,声明一个表示 2D 图像纹理资源的变量 tex 可能如下所示:

@group(0) @binding(0) var tex : texture_2d<f32>;

类型名 texture_2d<f32> 指的是一个 2D 纹理,其样本类型为 f32;也就是说,通过采样纹理返回的颜色将是 vec4f 类型。一个带有浮点样本的 1D 纹理将使用类型名 texture_1d<f32>,对于 3D 和立方体贴图也有类似的名称。(还有像 texture_2d<u32>texture_1d<i32> 这样的整型纹理,但它们不与采样器一起使用。本节后面会讨论它们。)

注意,纹理变量是使用不带地址空间的 var 声明的。(与 uniform 地址空间中的变量使用 var<uniform> 不同。)采样器变量也是如此。纹理和采样器被认为处于特殊的“句柄”地址空间,但这个名称在着色器程序中不使用。

采样器变量使用类型名 sampler 声明。(不幸的是,这意味着您不能将“sampler”作为变量名。)例如:

@group(0) @binding(1) var samp : sampler;

采样器是一个简单的数据结构,它指定了采样过程的某些方面,例如缩小滤波器以及是否使用各向异性过滤。

纹理和采样器的值在 JavaScript 端构建。着色器程序无法直接访问纹理或采样器的内部结构。实际上,在 WGSL 中,您可以使用它们的唯一操作就是将它们作为参数传递给函数。有几个内置函数用于处理纹理(它们中的大多数太晦涩,这里不涉及)。主要的采样纹理函数是 textureSample()。它的参数是一个浮点纹理、一个采样器和纹理坐标。例如,

let textureColor = textureSample ( tex, samp, texcoords );

这个函数可以用于采样 1D、2D、3D 和立方体贴图。对于 1D 纹理,texcoords 参数是一个 f32;对于 2D 纹理,它是一个 vec2f;对于 3D 或立方体贴图,它是一个 vec3f。返回值是一个表示 RGBA 颜色的 vec4f。即使纹理实际上没有存储四个颜色分量,返回值也总是 vec4f。例如,一个纹理可能只存储一个颜色分量;当它使用 textureSample() 进行采样时,纹理中的颜色值将用作颜色的红色分量,绿色和蓝色分量将被设置为 0.0,alpha 分量将为 1.0。

现在您应该能够理解示例程序中的片段着色器源代码。大部分工作在 JavaScript 端,所以着色器代码相当简单:

@group(0) @binding(0) var samp : sampler;  // 来自 JavaScript 的采样器资源。
@group(0) @binding(1) var tex : texture_2d<f32>;  // 图像纹理资源。

@group(0) @binding(2) var<uniform> textureSelect: u32;
// 值为 1、2 或 3,告诉片段着色器使用哪个纹理。

@fragment
fn fragmentMain(@location(0) texcoords : vec2f) -> @location(0) vec4f {
if (textureSelect == 1) { // 简单的程序纹理。
        // 将 texcoords 作为红/绿颜色分量。
    return vec4f( texcoords, 0, 1 );
}
else if (textureSelect == 2) { // 对于棋盘格纹理。
        // 应用纹理变换:将 texcoords 乘以 5。
    return textureSample( tex, samp, 5 * texcoords );
}
else { // 对于蒙娜丽莎纹理;没有纹理变换。
    return textureSample( tex, samp, texcoords );
}
}

由于选项有限,纹理和采样器在着色器程序中的使用相当简单。大部分工作在 JavaScript 端。


WebGPU 中采样器的目的是为采样过程设置选项。采样器是使用 JavaScript 函数 device.createSampler() 创建的。以下代码创建了一个典型的高质量 2D 纹理采样的采样器:

let sampler = device.createSampler({
    addressModeU: "repeat",  // 默认是 "clamp-to-edge"。
    addressModeV: "repeat",  //    (另一个可能的值是 "mirror-repeat"。)
    minFilter: "linear", 
    magFilter: "linear",     // 过滤器的默认值是 "nearest"。
    mipmapFilter: "linear",
    maxAnisotropy: 16        // 默认值是 1;16 是最大值。
});

addressModeU 属性指定如何处理超出 0 到 1 范围的 u 纹理坐标的值,addressModeV 对 v 坐标做同样的事情,对于 3D 纹理还有 addressModeW。(在 OpenGLWebGL 中,这被称为“包裹”;见 4.3.3小节。这里的含义是相同的。)

过滤考虑到图像在应用到表面时通常需要被拉伸或缩小。magFilter 或放大滤波器用于拉伸图像时。minFilter 或缩小滤波器用于缩小它时。Mipmaps 是图像的缩小尺寸副本,可以使过滤更有效。纹理不会自动带有 mipmaps;如果没有 mipmaps,mipmapFilter 将被忽略。这与 OpenGL 相似;见 4.3.2小节

maxAnisotropy 属性控制各向异性过滤,这在 7.5.1小节 中解释。默认值 1 表示不使用各向异性过滤。更高的值可以为边缘观看的纹理提供更好的质量。最大值取决于设备,但指定一个大于最大值的值是可以的;在这种情况下,将使用最大值。


纹理是在 JavaScript 端使用 device.createTexture() 创建的。但重要的是要理解,这个函数只分配了 GPU 上将保存纹理数据的内存。实际数据将需要稍后存储。这类似于创建 GPU 缓冲区。以下是示例程序中棋盘纹理的创建方式:

let checkerboardTexture = device.createTexture({
    size: [2,2],  // 宽两像素,高两像素。
    format: "rgba8unorm",  // 每个颜色分量一个 8 位无符号整数。
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
});

这是一个 2D 纹理,默认类型。size 属性指定了纹理的宽度和高度,可以是数组或对象,例如 {width: 2, height: 2}。这里指定的纹理格式 "rgba8unorm" 是图像的常见格式:每个像素有四个 RGBA 颜色分量,每个颜色分量有 8 位。名称中的 "unorm" 意味着 8 位表示范围在 0 到 255 的无符号整数,这些整数被缩放到 0.0 到 1.0 的范围以给出浮点颜色值。(这种缩放被称为 "归一化" 值——这是过度使用的术语 "normal" 的另一种含义。)在 usage 属性中,TEXTURE_BINDING 表示纹理可以在着色器程序中采样,COPY_DST 表示数据可以从其他地方复制到纹理中。也可以通过将纹理附加到管线作为渲染目标来填充纹理的数据;这需要使用 GPUTextureUsage.RENDER_ATTACHMENT。另一种可能的用途是 COPY_SRC,它允许将纹理用作复制数据的源。

size、format 和 usage 属性是必需的。还有一些可选属性。mipLevelCount 属性指定你将为纹理提供的 mipmap 数量。默认值 1 表示只提供主图像。dimension 属性可以是 "1d"、"2d" 或 "3d",默认值为 "2d"。sampleCount 属性的默认值为 1,可以设置为 4 来创建多重采样纹理。

我们已经使用 device.createTexture() 创建了用于多重采样和深度测试的特殊用途纹理。参见,例如,webgpu/depth_test.html。这些纹理被用作渲染附件,纹理的数据是通过绘制图像创建的。

图像纹理的数据通常来自程序的 JavaScript 端。当数据来自 ArrayBuffer 或类型化数组时,可以使用 device.queue.writeTexture() 函数将数据复制到纹理中。在示例程序中,微小棋盘纹理的数据来自一个 Uint8Array,并使用以下方式复制到纹理中:

device.queue.writeTexture(
    { texture: checkerboardTexture }, // 要写入数据的纹理。
    textureData,         // 包含要写入数据的 Uint8Array。
    { bytesPerRow: 8 },  // 每个 texels 行的字节数。
    [2,2]   // 纹理的大小(宽度和高度)。
);

writeTexture() 的第一个参数是一个对象。除了 texture 属性外,该对象还可以有一个 mipLevel 属性以将数据复制到纹理的某个 mipmap 中,以及一个 origin 属性以将数据复制到纹理内的矩形子区域中。(origin 可以作为整数数组给出;与函数的大小参数一起,它决定了矩形区域。)第三个参数也是一个对象。bytesPerRow 属性是一行 texels 从一行的开始到下一行的开始之间的字节距离。行之间可能有填充,这有时是满足对齐要求所必需的。还可以有一个 offset 属性,给出数据源中数据的起始点,以字节为单位。

所有这些可能看起来过于复杂,但纹理和图像是复杂的,与它们一起工作的函数可以有很多选项。


通常,纹理的数据源是图像文件。WebGPU 不能直接从图像文件中获取数据;您必须获取文件并将数据提取到一个 ImageBitmap 对象中。使用承诺的 fetch API第 A.4 节 中讨论。这里,例如,是 textured_objects.html 中用于从图像文件加载纹理的函数:

async function loadTexture(URL) {
    // 使用 fetch API 从 URL 获取纹理的标准方法。
    let response = await fetch(URL);
    let blob = await response.blob();  // 将图像数据作为 "blob" 获取。
    let imageBitmap = await createImageBitmap(blob);
    let texture = device.createTexture({
        size: [imageBitmap.width, imageBitmap.height],
        format: 'rgba8unorm',
        usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST |
                GPUTextureUsage.RENDER_ATTACHMENT
    });
    device.queue.copyExternalImageToTexture(
    { source: imageBitmap, flipY: true },
    { texture: texture },
    [imageBitmap.width, imageBitmap.height]
    );
    return texture;
}

纹理的 usage 属性是 copyExternalmageToTexture() 所需的。flipY 属性的使用是因为程序在其显示的对象上使用 OpenGL 风格的纹理坐标。source 属性也可以是画布,就像 texture_from_canvas.html 中所做的那样。这个 loadTexture() 函数必须使用 await 从 async 函数中调用,并且捕获可能发生的错误是一个好主意:

let texture;
try {
    texture = await loadTexture(URL);
}
catch (e) {
    ...
}

我将不再详细讨论。请参阅示例程序以获取更多示例。


JavaScript 端创建的采样器和纹理必须作为绑定组资源传递给着色器程序。在绑定组中,采样器的资源是采样器本身,而纹理的资源是纹理的视图。以下是 first_texture.html 中棋盘纹理的绑定组示例:

checkerboardBindGroup = device.createBindGroup({
    layout: bindGroupLayout,
    entries: [
        {    // 采样器。注意,资源是采样器本身。
            binding: 0,
            resource: checkerboardSampler
        },
        {    // 纹理。注意,资源是纹理的视图。
            binding: 1,
            resource: checkerboardTexture.createView()
        },
        {    // 资源是包含 uniform 变量的缓冲区。
            binding: 2,
            resource: {buffer: uniformBuffer, offset: 0, size: 4}
        }
    ]
});

Textures and samplers are created on the JavaScript side of a WebGPU program and are used on the GPU side, where they are used in the fragment shader. This means that they are shader resources. Like other resources, they are declared as global variables in the shader program. Their values are passed to the shader in bind groups, so a sampler or texture variable must be declared with @group and @binding annotations. As an example, the declaration of a variable, tex, that represents a 2D image texture resource could look like this:

@group(0) @binding(0) var tex : texture_2d<f32>;

The type name texture_2d<f32> refers to a 2D texture with samples of type f32; that is, the color returned by sampling the texture will be of type vec4f. A 1D texture with floating point samples would use type name texture_1d<f32>, and there are similar names for 3D and cube textures. (There are also integer textures with type names like texture_2d<u32> and texture_1d<i32>, but they are not used with samplers. They are discussed later in this section.)

Note that a texture variable is declared using var with no address space. (Not like var<uniform> for variables in the uniform address space.) The same is true for sampler variables. Textures and samplers are considered to be in a special "handle" address space, but that name is not used in shader programs.

Sampler variables are declared using type name sampler. (Unfortunately, this means that you can't use "sampler" as the name of a variable.) For example:

@group(0) @binding(1) var samp : sampler;

A sampler is a simple data structure that specifies certain aspects of the sampling process, such as the minification filter and whether to use anisotropic filtering.

Values for texture and sampler variables are constructed on the JavaScript side. A shader program has no direct access to the internal structure of a texture or sampler. In fact, the only thing you can do with them in WGSL is pass them as parameters to functions. There are several built-in functions for working with textures (most of them too obscure to be covered here). The main function for sampling textures is textureSample(). Its parameters are a floating-point texture, a sampler, and texture coordinates. For example,

let textureColor = textureSample ( tex, samp, texcoords );

This function can be used for sampling 1D, 2D, 3D, and cube textures. For a 1D texture, the texcoords parameter is an f32; for a 2D texture, it is a vec2f; and for a 3D or cube texture, it's a vec3f. The return value is a vec4f representing an RGBA color. The return value is always a vec4f, even when the texture does not actually store four color components. For example, a texture might store just one color component; when it is sampled using textureSample(), the color value from the texture will be used as the red component of the color, the green and blue color components will be set to 0.0, and the alpha component will be 1.0.

You should now be able to understand the fragment shader source code from the sample program. Most of the work is on the JavaScript side, so the shader code is quite simple:

@group(0) @binding(0) var samp : sampler;  // Sampler resource from JavaScript.
@group(0) @binding(1) var tex : texture_2d<f32>;  // Image texture resource.

@group(0) @binding(2) var<uniform> textureSelect: u32;
    // Value is 1, 2, or 3 to tell the fragment shader which texture to use.

@fragment
fn fragmentMain(@location(0) texcoords : vec2f) -> @location(0) vec4f {
if (textureSelect == 1) { // Trivial procedural texture.
        // Use texcoords as red/green color components.
    return vec4f( texcoords, 0, 1);
}
else if (textureSelect == 2) { // For the checkerboard texture.
        // Apply texture transform: multiply texcoords by 5.
    return textureSample( tex, samp, 5 * texcoords );
}
else { // For the Mona Lisa texture; no texture transform.
    return textureSample( tex, samp, texcoords );
}
}

Because of the limited options, textures and samplers are fairly simple to use in the shader program. Most of the work is on the JavaScript side.


The purpose of a sampler in WebGPU is to set options for the sampling process. Samplers are created using the JavaScript function device.createSampler(). The following code creates a typical sampler for high-quality sampling of a 2D texture:

let sampler = device.createSampler({
addressModeU: "repeat",  // Default is "clamp-to-edge".
addressModeV: "repeat",  //    (The other possible value is "mirror-repeat".)
minFilter: "linear", 
magFilter: "linear",     // Default for filters is "nearest".
mipmapFilter: "linear",
maxAnisotropy: 16        // 1 is the default; 16 is the maximum.
});

The addressModeU property specifies how to treat values of the u texture coordinate that are outside the range 0 to 1, addressModeV does the same for the v coordinates, and for 3D textures there is also addressModeW. (In OpenGL and WebGL, this was called "wrapping"; see Subsection 4.3.3. The meanings are the same here.)

Filtering accounts for the fact that an image usually has to be stretched or shrunk when it is applied to a surface. The magFilter, or magnification filter, is used when stretching an image. The minFilter, or minification filter, is used when shrinking it. Mipmaps are reduced-size copies of the image that can make filtering more efficient. Textures don't automatically come with mipmaps; the mipmapFilter is ignored if no mipmaps are available. This is all similar to OpenGL; see Subsection 4.3.2.

The maxAnisotropy property controls anisotropic filtering, which is explained in Subsection 7.5.1. The default value, 1, says that anisotropic filtering is not used. Higher values give better quality for textures that are viewed edge-on. The maximum value depends on the device, but it's OK to specify a value larger than the maximum; in that case, the maximum value will be used.


Textures are created on the JavaScript side using device.createTexture(). But it is important to understand that this function only allocates the memory on the GPU that will hold the texture data. The actual data will have to be stored later. This is similar to creating a GPU buffer. Here is how the checkerboard texture is created in the sample program:

let checkerboardTexture = device.createTexture({
size: [2,2],  // Two pixels wide by two pixels high.
format: "rgba8unorm",  // One 8-bit unsigned int for each color component.
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
});

This is a 2D texture, which is the default. The size property specifies the width and height of the texture, either as an array or as an object, {width: 2, height: 2}. The texture format specified here, "rgba8unorm", is a common one for images: four RGBA color components for each pixel, with 8 bits for each color component. The "unorm" in the name means that the 8 bits represent unsigned integers in the range 0 to 255 which are scaled to the range 0.0 to 1.0 to give a floating-point color value. (The scaling is referred to as "normalizing" the values—yet another meaning of the overworked term "normal.") In the usage property, TEXTURE_BINDING, means that the texture can be sampled in a shader program, and COPY_DST means that data can be copied into the texture from elsewhere. It is also possible to fill a texture with data by attaching the texture to a pipeline as a render target; that requires the usage GPUTextureUsage.RENDER_ATTACHMENT. The other possible usage is COPY_SRC, which allows the texture to be used as a source of copied data.

The size, format, and usage properties are required. There are a few optional properties. The mipLevelCount property specifies the number of mipmaps that you will provide for the texture. The default value, 1, means that only the main image will be provided. The dimension property can be "1d", "2d", or "3d", with a default of "2d". The sampleCount property has a default value of 1 and can be set to 4 to create a multisampled texture.

We have already used device.createTexture() to create the special purpose textures that are used for multisampling and for the depth test. See, for example, webgpu/depth_test.html. Those textures were used as render attachments, and the data for the textures were created by drawing an image.

Data for image textures often comes from the JavaScript side of the program. When the data comes from an ArrayBuffer or typed array, the data can be copied to the texture using the function device.queue.writeTexture(). In the sample program, the data for the tiny checkerboard texture comes from a Uint8Array and is copied to the texture with

device.queue.writeTexture(
    { texture: checkerboardTexture }, // Texture to which data will be written.
    textureData,         // A Uint8Array containing the data to be written.
    { bytesPerRow: 8 },  // How many bytes for each row of texels.
    [2,2]   // Size of the texture (width and height).
);

The first parameter to writeTexture() is an object. In addition to the texture property, the object can have a mipLevel property to copy the data into one of the texture's mipmaps, and an origin property to copy the data into a rectangular subregion within the texture. (The origin can be given as an array of integers; together with the size parameter to the function, it determines the rectangular region.) The third parameter is also an object. The bytesPerRow property is the distance, in bytes, from the start of one row of texels to the start of the next row of texels. There can be padding between rows, which is sometimes necessary to satisfy alignment requirements. There can also be an offset property, giving the starting point, in bytes, of the data within the data source.

All of this might seem overly complicated, but textures and images are complex, and the functions that work with them can have many options.


Often, the data source for a texture is an image file. WebGPU cannot take the data directly from an image file; you have to fetch the file and extract the data into an ImageBitmap object. The fetch API, which uses promises, is discussed in Section A.4. Here, for example, is the function from textured_objects.html that is used to load textures from image files:

async function loadTexture(URL) {
    // Standard method using the fetch API to get a texture from a ULR.
    let response = await fetch(URL);
    let blob = await response.blob();  // Get image data as a "blob".
    let imageBitmap = await createImageBitmap(blob);
    let texture = device.createTexture({
        size: [imageBitmap.width, imageBitmap.height],
        format: 'rgba8unorm',
        usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST |
                    GPUTextureUsage.RENDER_ATTACHMENT
    });
    device.queue.copyExternalImageToTexture(
    { source: imageBitmap, flipY: true },
    { texture: texture },
    [imageBitmap.width, imageBitmap.height]
    );
    return texture;
}

The texture's usage property is required by copyExternalmageToTexture(). The flipY property is used because the program uses OpenGL-style texture coordinates on the objects that it displays. The source property could also be a canvas, as is done in texture_from_canvas.html. This loadTexture() function must be called from an async function using await, and it is a good idea to catch the errors that might occur:

let texture;
try {
texture = await loadTexture(URL);
}
catch (e) {
...

I will not discuss this in any more detail. See the sample programs for more examples.


Samplers and textures that are created on the JavaScript side must be passed to a shader program as bind group resources. In the bind group, the resource for a sampler is the sampler itself, while the resource for a texture is a view of the texture. Here for example is the bind group for the checkerboard texture in first_texture.html:

checkerboardBindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
    {    // The sampler. Note that the resource is the sampler itself.
        binding: 0,
        resource: checkerboardSampler
    },
    {    // The texture.  Note that the resource is a view of the texture.
        binding: 1,
        resource: checkerboardTexture.createView()
    },
    {    // The resource is the buffer containing the uniform variable.
        binding: 2,
        resource: {buffer: uniformBuffer, offset: 0, size: 4}
    }
]
});

9.5.3 Mipmap

Mipmaps

Mipmaps 在纹理需要“缩小”以适应表面时对质量和效率至关重要。使用 mipmaps 时,mip 级别 0 是原始图像,mip 级别 1 是半尺寸副本,mip 级别 2 是四分之一尺寸副本,依此类推。确切地说,如果 width 是原始图像的宽度,那么 mip 级别 i 的宽度是 max(1, width >> i),高度也是如此。对于完整的 mipmap 集合,该过程会一直持续到所有尺寸都减小到 1。

WebGPU 没有自动生成 mipmaps 的方法,但在 GPU 上编写一个 WebGPU 程序来创建它们并不难。示例程序 webgpu/making_mipmaps.html 展示了如何做到这一点。它定义了一个函数,可以用来从 ImageBitmap 创建具有完整 mipmap 集合的纹理。该程序还作为渲染到纹理和使用纹理视图的示例。

创建纹理时,必须指定 mipmaps 的数量。给定用于级别 0 的图像位图,很容易计算出完整集合所需的 mipmaps 数量:

let mipmapCount = 1;
let size = Math.max(imageBitmap.width,imageBitmap.height);
while (size > 1) {
    mipmapCount++;
    size = size >> 1;
}
let texture = device.createTexture({
    size: [imageBitmap.width, imageBitmap.height],
    mipLevelCount: mipmapCount, // mipmaps 的数量。
    format: 'rgba8unorm',
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST |
            GPUTextureUsage.RENDER_ATTACHMENT
});

可以使用 copyExternalImageToTexture() 函数将位图复制到纹理的级别 0,方法与往常一样。然后,每个剩余的 mipmap 图像可以依次生成,方法是对前一个级别图像进行半尺寸复制。方法是将 mipmap 作为管线的渲染目标附加,并使用前一个 mipmap 级别作为管线的纹理资源。然后绘制一个正方形,它刚好覆盖输出,其纹理坐标将整个资源图像映射到输出上。

回想一下,纹理资源和渲染目标实际上是纹理的视图。我们一直在使用 texture.createView(),不带参数,来创建纹理视图。结果是包括纹理所拥有的所有 mipmaps 的视图。但是,通过向 createView() 传递一个参数来创建一个只包含可用 mipmaps 子集的视图是可能的,该参数指定了要包含在视图中的第一个 mipmap 以及要包含的 mipmaps 数量。创建只包含 mip 级别 i 的视图:

textureView = texture.createView({
    baseMipLevel: i,  // 包含在此视图中的第一个 mipmap 级别。
    mipLevelCount: 1  // 只包括一个 mipmap 级别。
});

这将允许我们使用单个纹理的 mipmap 作为纹理资源或渲染目标。这里,例如,是示例程序中创建 mipmap 图像的循环:

for (let mipmap = 1; mipmap < mipmapCount; mipmap++) {
    let inputView = texture.createView(  // 用作绑定组资源。
                            { baseMipLevel: mipmap - 1, mipLevelCount: 1 });
    let outputView = texture.createView( // 用作渲染目标。
                            { baseMipLevel: mipmap, mipLevelCount: 1 });
    let renderPassDescriptor = {
        colorAttachments: [{
            loadOp: "load",
            storeOp: "store", 
            view: outputView  // 渲染到 mipmap。
        }]
    };
    let bindGroup = webgpuDevice.createBindGroup({
        layout: pipeline.getBindGroupLayout(0),
        entries: [ { binding: 0, resource: sampler },
                    { binding: 1, resource: inputView } ]
    });
    let passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
    passEncoder.setPipeline(pipeline);
    passEncoder.setVertexBuffer(0,vertexBuffer); // 坐标和纹理坐标。
    passEncoder.setBindGroup(0,bindGroup); // 包括前一个 mipmap 级别。
    passEncoder.draw(4); // 作为三角形条带绘制正方形。
    passEncoder.end();
}

Mipmaps are important for quality and efficiency when a texture has to be "minified" to fit a surface. When working with mipmaps, mip level 0 is the original image, mip level 1 is a half-size copy, mip level 2 is a quarter-size copy, and so on. To be exact, if width is the width of the original image, then the width of mip level i is max(1, width >> i), and similarly for the height. For a full set of mipmaps, the process continues until all dimensions have been reduced to 1.

WebGPU has no method for automatically generating mipmaps, but it is not hard to write a WebGPU program to create them on the GPU. The sample program webgpu/making_mipmaps.html shows how to do this. It defines a function that can be used to create a texture with a full set of mipmaps from an ImageBitmap. The program also serves as an example of rendering to a texture and using texture views.

When creating a texture, the number of mipmaps must be specified. It is easy to count the number of mipmaps needed for a full set, given the image bitmap that will be used for level 0:

let mipmapCount = 1;
let size = Math.max(imageBitmap.width,imageBitmap.height);
while (size > 1) {
    mipmapCount++;
    size = size >> 1;
}
let texture = device.createTexture({
    size: [imageBitmap.width, imageBitmap.height],
    mipLevelCount: mipmapCount, // Number of mipmaps.
    format: 'rgba8unorm',
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST |
                GPUTextureUsage.RENDER_ATTACHMENT
});

The function copyExternalImageToTexture() can be used to copy the bitmap to level 0 in the texture in the usual way. Then each of the remaining mipmap images can be generated in turn by making a half-size copy of the previous level image. The idea is to attach the mipmap as the render target of a pipeline and use the previous mipmap level as a texture resource for the pipeline. Then draw a square that just covers the output, with texture coordinates that map the entire resource image onto the output.

Recall that texture resources and render targets are actually views of textures. We have been using texture.createView(), with no parameter, to create texture views. The result is a view that includes all the mipmaps that the texture has. But it is possible to create a view that contains just a subset of available mipmaps by passing a parameter to createView() that specifies the first mipmap and the number of mipmaps to include in the view. To create a view the contains only mip level i:

textureView = texture.createView({
    baseMipLevel: i,  // First mip level included in this view.
    mipLevelCount: 1  // Only include one mip level.
});

This will let us use a single mipmap from a texture as a texture resource or render target. Here, for example, is the loop from the sample program that creates the mipmap images:

for (let mipmap = 1; mipmap < mipmapCount; mipmap++) {
    let inputView = texture.createView(  // Used as a bind group resource.
                            { baseMipLevel: mipmap - 1, mipLevelCount: 1 });
    let outputView = texture.createView( // Used as a render target.
                            { baseMipLevel: mipmap, mipLevelCount: 1 });
    let renderPassDescriptor = {
    colorAttachments: [{
        loadOp: "load",
        storeOp: "store", 
        view: outputView  // Render to mipmap.
    }]
    };
    let bindGroup = webgpuDevice.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [ { binding: 0, resource: sampler },
                { binding: 1, resource: inputView } ]
    });
    let passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
    passEncoder.setPipeline(pipeline);
    passEncoder.setVertexBuffer(0,vertexBuffer); // Coords and texcoords.
    passEncoder.setBindGroup(0,bindGroup); // Includes previous mipmap level.
    passEncoder.draw(4); // Draw square as a triangle-strip.
    passEncoder.end();
}

9.5.4 立方体贴图纹理

Cubemap Textures

立方体贴图由六张图像组成,每张图像对应立方体的每个面。这些图像必须是正方形,并且大小必须相同。立方体贴图可以用于创建天空盒(见5.3.4小节)和环境映射(也称为反射映射,见7.3.5小节)。示例程序 webgpu/cubemap_texture.html 展示了如何在 WebGPU 中创建立方体贴图以及如何将其用于天空盒和环境映射。它在功能上与 WebGL 示例 webgl/skybox-and-env-map.html 相同。

除了 "2d" 图像纹理WebGPU 还有 "2d-array" 纹理。2d-array 纹理就是一个 2d 图像的数组。数组中的元素称为 "层"。我并没有在这本教科书中涵盖数组纹理,但你需要知道一些关于它们的信息,因为出于某些目的,立方体贴图被视为具有六层的数组。索引 0 到 5 的图像分别是立方体的 +X、-X、+Y、-Y、+Z 和 -Z 面,按此顺序。特别是,在创建纹理和加载六个面的图像时,立方体贴图被视为一个数组。以下是示例程序中加载纹理的一些(编辑过的)代码:

let urls = [  // 立方体贴图的六张图像链接。
    "cubemap-textures/park/posx.jpg", "cubemap-textures/park/negx.jpg",
    "cubemap-textures/park/posy.jpg", "cubemap-textures/park/negy.jpg",
    "cubemap-textures/park/posz.jpg", "cubemap-textures/park/negz.jpg"
];
let texture; 
for (let i = 0; i < 6; i++) {
    let response = await fetch( urls[i] ); // 获取第 i 张图像。
    let blob = await response.blob(); 
    let imageBitmap = await createImageBitmap(blob);
    if (i == 0) { // (我们需要知道图像大小才能创建纹理。)
        texture = device.createTexture({ 
            size: [imageBitmap.width, imageBitmap.height, 6],
                // (最后的 6 表示有 6 张图像。)
            dimension: "2d",  // (这是默认的纹理维度。)
            format: 'rgba8unorm',
            usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST |
                    GPUTextureUsage.RENDER_ATTACHMENT
        });
    }
    device.queue.copyExternalImageToTexture(
    { source: imageBitmap },
    { texture: texture,  origin: [0, 0, i] },
            // 最后的 i 将图像放入立方体的第 i 个面。
    [imageBitmap.width, imageBitmap.height]
    );
}

对于维度为 "2d" 的纹理,size 属性的第三个元素使纹理成为数组纹理。(对于 "3d" 纹理,第三个元素将是 z 方向上的大小。)类似地,当将图像复制到纹理中时,origin 属性的第三个元素指定了要将图像复制到的数组层。

(当我第一次使用上述代码编写程序时,环境映射看起来与 WebGL 版本相比非常糟糕。这在像茶壶把手这样的急剧弯曲表面上最为明显。最终,我意识到不同之处在于 WebGL 版本使用了 mipmap。因此,我为 WebGPU 版本添加了代码,以为立方体贴图生成 mipmap。我还添加了一个选项来打开和关闭 mipmap 的使用,以便你可以看到差异。)


WGSL 着色器程序中,立方体贴图的使用与 2D 纹理类似。立方体贴图的数据类型是 texture_cube<f32>。采样纹理时,与 2D 纹理一样使用 textureSample() 函数,但第三个参数,提供纹理坐标的是 vec3f。通过在 vec3f 的方向上投射光线,并查看它与立方体的交点来获取样本。对于天空盒,基本上显示从盒子内部的视图,纹理坐标就是盒子上某点的对象坐标。因此,绘制天空盒背景的片段着色器非常简单:

@group(1) @binding(0) var samp: sampler;
@group(1) @binding(1) var cubeTex : texture_cube<f32>;
@fragment fn fmain(@location(0) objCoords : vec3f) -> @location(0) vec4f {
    return textureSample(cubeTex, samp, objCoords);
}

对于环境映射,思路是从观察者向反射物体上的某点投射光线,并使用该光线从表面的反射作为纹理坐标向量:反射光线击中天空盒的点将是用户在反射物体上看到的点。由于示例程序中的天空盒可以旋转,因此必须调整射线的方向以考虑这种旋转。见 7.3.5小节 了解完整的数学讨论。以下是绘制反射物体的片段着色器:

@group(1) @binding(0) var samp: sampler;
@group(1) @binding(1) var cubeTex : texture_cube<f32>;
@group(1) @binding(2) var<uniform> normalMatrix : mat3x3f;
@group(1) @binding(3) var<uniform> inverseViewTransform : mat3x3f;
@fragment fn fmain(
        @location(0) eyeCoords: vec3f, // 观察者到表面的方向。
        @location(1) normal: vec3f // 表面未变换的法向量。
) -> @location(0) vec4f {
    let N = normalize(normalMatrix * normal); // 表面法向量。
    let R = reflect( eyeCoords, N );  // 反射方向(朝向天空盒)。
    let T = inverseViewTransform * R; 
        // 乘以视图变换的逆矩阵以考虑天空盒的旋转。
    return textureSample(cubeTex, samp, T); // 使用反射光线进行采样。
}

JavaScript 端,立方体贴图的使用与 2D 纹理类似。用于立方体贴图的采样器与用于 2D 纹理的采样器相同。并将立方体贴图的视图作为绑定组资源传递给着色器程序。一个区别是,在创建视图时,需要指定要将纹理视为立方体贴图:

cubeTexture.createView({dimension: "cube"})

默认情况下,它将被视为 2d 数组纹理。在为纹理创建 mipmap 时,我需要视图来表示立方体单个面的单个 mipmap 级别。例如,

let outputView = cubeTexture.createView({
    dimension: "2d",
    baseMipLevel: mipmap, mipLevelCount: 1,
    baseArrayLayer: side, arrayLayerCount: 1
});

其中 mipmap 是所需的 mipmap 级别,side 是立方体所需面的数组索引。维度必须明确指定为 "2d"。(所有这些可能帮助你理解纹理和纹理视图之间的区别。)

A cubemap texture consists of six images, one for each side of a cube. The images must be square and must all be the same size. A cubemap texture can be used, for example, to make a skybox (Subsection 5.3.4) and to do environment mapping (also called reflection mapping, Subsection 7.3.5). The sample program webgpu/cubemap_texture.html shows how to create a cubemap texture in WebGPU and how to use it for a skybox and for environment mapping. It is functionally identical to the WebGL example webgl/skybox-and-env-map.html.

In addition to "2d" image textures, WebGPU has "2d-array" textures. A 2d-array texture is just that—an array of 2d images. The elements of the array are called "layers". I do not cover array textures in this textbook, but you need to know a little about them since, for some purposes, a cubemap texture is treated as an array with six layers. The images at indices 0 through 5 are the +X, -X, +Y, -Y, +Z, and -Z sides of the cube, in that order. In particular, a cubemap texture is treated as an array when creating the texture and loading the images for the six sides. Here is some (edited) code from the sample program for loading the texture:

let urls = [  // Links to the six images for the cube.
"cubemap-textures/park/posx.jpg", "cubemap-textures/park/negx.jpg", 
"cubemap-textures/park/posy.jpg", "cubemap-textures/park/negy.jpg", 
"cubemap-textures/park/posz.jpg", "cubemap-textures/park/negz.jpg"
];
let texture; 
for (let i = 0; i < 6; i++) {
    let response = await fetch( urls[i] ); // Get image number i.
    let blob = await response.blob(); 
    let imageBitmap = await createImageBitmap(blob);
    if (i == 0) { // (We need to know the image size to create the texture.)
        texture = device.createTexture({ 
            size: [imageBitmap.width, imageBitmap.height, 6],
                // (The 6 at the end means that there are 6 images.)
            dimension: "2d",  // (This is the default texture dimension.)
            format: 'rgba8unorm',
            usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST |
                        GPUTextureUsage.RENDER_ATTACHMENT
        });
    }
    device.queue.copyExternalImageToTexture(
    { source: imageBitmap },
    { texture: texture,  origin: [0, 0, i] },
            // The i at the end puts the image into side number i of the cube.
    [imageBitmap.width, imageBitmap.height]
    );
}

For a texture with dimension "2d", the third element in the size property makes the texture into an array texture. (For a "3d" texture, the third element would be the size in the z direction.) Similarly, when copying an image into the texture, the third element of the origin property specifies the array layer into which the image is to be copied.

(When I first wrote the program, using the above code, the environment mapping looked really bad, compared to the WebGL version. This was most apparent on sharply curved surfaces such as the handle of the teapot. Eventually, I realized that the difference was that the WebGL version uses mipmaps. So, I added code to the WebGPU version to produce mipmaps for the cubemap texture. I also added an option to turn the use of mipmaps on and off, so that you can see the difference.)


In a WGSL shader program, cubemap textures are used similarly to 2D textures. The data type for a cubemap texture is texture_cube<f32>. For sampling the texture, the same textureSample() function is used as for 2D textures, but the third parameter, which gives the texture coordinates, is a vec3f. The sample is obtained by casting a ray from the origin in the direction of the vec3f, and seeing where it intersects the cube. For a skybox, which basically shows the view of the box from the inside, the texture coordinates are just the object coordinates of a point on the box. So, the fragment shader for drawing the skybox background is simply

@group(1) @binding(0) var samp: sampler;
@group(1) @binding(1) var cubeTex : texture_cube<f32>;
@fragment fn fmain(@location(0) objCoords : vec3f) -> @location(0) vec4f {
    return textureSample(cubeTex, samp, objCoords);
}

For environment mapping, the idea is to cast a ray from the viewer to a point on the reflective object, and use the reflection of that ray from the surface as the texture coordinate vector: The point where the reflected ray hits the skybox is the point that will be seen by the user on the reflective object. Since the skybox in the sample program can be rotated, the direction of the ray has to be adjusted to take that rotation into account. See Subsection 7.3.5 for a full discussion of the math. Here is the fragment shader for drawing the reflected object:

@group(1) @binding(0) var samp: sampler;
@group(1) @binding(1) var cubeTex : texture_cube<f32>;
@group(1) @binding(2) var<uniform> normalMatrix : mat3x3f;
@group(1) @binding(3) var<uniform> inverseViewTransform : mat3x3f;
@fragment fn fmain(
            @location(0) eyeCoords: vec3f, // Direction from viewer to surface.
            @location(1) normal: vec3f // Untransformed normal to surface.
    ) -> @location(0) vec4f {
    let N = normalize(normalMatrix * normal); // Normal vector to the surface.
    let R = reflect( eyeCoords, N );  // Reflected direction (towards skybox).
    let T = inverseViewTransform * R; 
        // Multiplying by inverse of the view transform accounts
        //    for the rotation of the skybox.
    return textureSample(cubeTex, samp, T); // Use reflected ray to sample.
}

On the JavaScript side, again, cubemap textures are used similarly to 2D textures. The samplers that are used for cubemap textures are the same as those used for 2D textures. And a view of the cubemap texture is passed to the shader program as a bind group resource. One difference is that when creating a view, you need to specify that you want to view the texture as a cube texture:

cubeTexture.createView({dimension: "cube"})

By default, it would be viewed as a 2d array texture. When creating mipmaps for the texture, I needed views of the texture to represent a single mipmap level of a single side of the cube. For example,

let outputView = cubeTexture.createView({
                    dimension: "2d",
                    baseMipLevel: mipmap, mipLevelCount: 1,
                    baseArrayLayer: side, arrayLayerCount: 1
                });

where mipmap is the desired mipmap level and side is the array index for the desired side of the cube. The dimension must be explicitly specified as "2d". (All this might help you understand the difference between a texture and a view of a texture.)

9.5.5 纹理格式

Texture Formats

纹理的格式指定了每个 texels 存储的数据类型。格式指定了颜色通道的数量、数据类型,并且在某些情况下还指定了数据的解释方式。在常见的 2D 图像格式 "rgba8unorm" 中,有四个颜色通道("r"、"g"、"b" 和 "a")。一个 texels 的数据由每个颜色通道的 8 位组成。颜色通道的值是一个无符号整数("u"),范围在 0 到 255 之间,除以 255 得到范围在 0.0 到 1.0 之间的浮点值("norm")。格式 "bgra8unorm" 类似,但 "r"、"g" 和 "b" 值的顺序相反。(这两种格式中的一种,根据平台的不同,是 HTML 画布的格式;函数 navigator.gpu.getPreferredCanvasFormat() 返回适合您平台的正确格式。然而,使用错误的格式并不会使您的程序停止工作,因为 WebGPU 在读写纹理时会自动进行一些格式转换。)

WebGPU 支持大量的纹理格式。有具有一个颜色通道 ("r")、两个颜色通道 ("rg") 和四个颜色通道 ("rgba") 的格式。每个颜色通道的位数可以是 8、16 或 32。数据类型可以是浮点数、无符号整数或有符号整数。一些整数格式是归一化的,但大多数不是。(还有压缩纹理格式,本教科书未涵盖。)

例如,格式 "r8uint"、"r16uint" 和 "r32uint" 是具有一个颜色通道的无符号整数格式,每个 texels 存储一个 8 位、16 位或 32 位的无符号整数。对于每个 texels 的两个 16 位有符号整数,格式将是 "rg16sint"。格式 "rgba32float" 每个 texels 使用四个 32 位浮点数。

所有纹理都可以作为资源通过绑定组传递到着色器程序中,但只有浮点纹理才能使用 textureSample() 进行采样。(这包括归一化整数格式。)然而,标准 WGSL 函数 textureLoad() 可以用于从纹理中读取 texels 数据,它既适用于整数纹理,也适用于浮点纹理。这个函数将纹理视为一个数组:不是使用纹理坐标来采样纹理,而是使用整数 texels 坐标来访问指定 texels 的值。例如,要从 texture_2d<u32> 的第 7 行、第 15 列的 texels 读取,可以使用

let texelValue : vec4u = textureLoad( tex, vec2u(7,15), 0 );

第三个参数是 mipmap 级别,这是必需的,但通常为零。

textureLoad() 的返回值始终是一个 4 组件向量,即使纹理只有一个或两个颜色通道。缺失的颜色通道用 "g" 或 "b" 通道的 0 填充,"a" 通道用 1 填充。(请注意,即使纹理中的值可能不代表颜色,整数纹理仍使用 "color" 一词。浮点纹理也可以存储除颜色之外的数据。)

着色器程序也可以使用 textureStore() 函数将 texels 数据写入纹理。然而,纹理必须作为所谓的 "storage texture" 传递到着色器中,而这仅适用于某些纹理格式。(关于各种纹理格式可以执行的操作有很多规则。这些规则在 WebGPU 规范的第 26.1 节的纹理格式功能表中进行了总结。)

在着色器中,存储纹理的类型如 texture_storage_2d<r32uint,write>。第一个类型参数 r32uint 是纹理格式,第二个参数 write 指定了访问模式。(目前,write 是唯一的可能性。)纹理作为类型为 textureStorage 的绑定组资源传递到着色器中,而不是 texture。例如,以下是使用两个 r32uint 纹理的着色器程序的绑定组布局,一个用于使用 textureLoad() 读取,一个用于使用 textureStore() 写入:

let bindGroupLayout = device.createBindGroupLayout({
    entries: [
        {    // 用于片段着色器中的 texture_2d<u32> 变量
            binding: 0,
            visibility: GPUShaderStage.FRAGMENT,
            texture: {
                sampleType: "uint"  // Texels 值是无符号整数。
                // (是的,尽管你不能采样它,它仍被称为 sampleType!)
            }
        },
        {    // 用于片段着色器中的 texture_storage_2d<r32uint,write>
            binding: 1,
            visibility: GPUShaderStage.FRAGMENT,
            storageTexture: {
                format: "r32uint",
                access: "write-only",  // 这是唯一可能的值。
                viewDimension: "2d"    // 这是默认值。
            }
        }
    ]
});

请注意 "storage texture" 只意味着作为类型为 textureStorage 的绑定组资源传递到着色器的纹理。同一个纹理可以作为常规纹理或存储纹理使用,或者在不同的时间两者都使用。

textureStore() 函数接受三个参数:纹理、要设置值的 texels 坐标和值。值始终是一个 4 组件向量,即使纹理少于四个颜色通道。缺失的通道应指定为 "g" 或 "b" 通道的 0,"a" 通道的 1。例如,要在 2D r32uint 存储纹理的第 7 行、第 15 列设置单个整数值 17,可以使用

textureStore( tex, vec2u(7,15), vec4u(17,0,0,1) );

示例程序 webgpu/life_1.html 实现了 John Conway 的著名生命游戏(见 6.4.5小节)。游戏棋盘是一个 2D 单元格数组,每个单元格可以是活的或死的。在程序中,棋盘的状态存储为类型为 r32uint 的 2D 纹理,其中 0 表示死亡的细胞,1 表示活的细胞。游戏棋盘显示在画布上,画布上的每个像素都是一个细胞。因此,纹理的大小与画布的大小相同。

游戏的动作涉及从当前一代计算出新一代的细胞。程序实际上使用两个纹理:一个常规纹理包含当前一代的棋盘和一个存储纹理,用于存储计算出的下一代。程序的所有工作都在其 draw() 函数中完成。该函数绘制一个完全覆盖画布的正方形,以便为画布上的每个像素调用一次片段着色器。片段着色器使用 textureLoad() 读取它正在处理的细胞的当前状态。如果细胞是活的,它返回白色作为片段的颜色;如果细胞是死的,它返回黑色。同时,片段着色器计算细胞在下一代的状态,并使用 textureStore() 将该状态写入存储纹理。在绘制之间,两个纹理的角色被交换,因此下一代成为当前一代。

以下是片段着色器,省略了计算细胞新状态的部分。它使用另一个新函数 textureDimensions(),该函数获取纹理在每个维度上的大小。这个值是新状态计算所需的。

@group(0) @binding(0) var inputBoard: texture_2d<u32>;
@group(0) @binding(1) var outputBoard: texture_storage_2d<r32uint,write>;

@fragment
fn fragmentMain(@builtin(position) position : vec4f) -> @location(0) vec4f {
    let boardSize = textureDimensions(inputBoard);
    let cell = vec2u(position.xy); // 此片段的整数像素坐标。
    let alive = textureLoad( inputBoard, cell, 0 ).r;  // 获取当前状态。
                // (请注意,状态在 r 颜色组件中。)
        .
        . // (计算 newAlive,细胞在下一代的状态,)
        .
    textureStore( outputBoard, cell, vec4u(newAlive,0,0,1) ); // 存储新状态。
    let c = f32(alive);
    return vec4f(c,c,c,1); // 如果细胞现在是活的,则为白色,如果是死的,则为黑色。
}
程序创建了两个纹理,texture1 和 texture2,并将 texture1 加载为棋盘的初始状态。以下是将 texture1 分配给着色器中的 inputBoard,将 texture2 分配给 outputBoard 的绑定组。它使用了上面显示的样本绑定组布局。
bindGroupA = device.createBindGroup({
    // 使用 texture1 进行输入,texture2 进行输出的绑定组。
layout: bindGroupLayout,
entries: [
    { 
        binding: 0,
        resource: texture1.createView()
    },
    {
        binding: 1,
        resource: texture2.createView()
    }
]
});

第二个绑定组 bindGroupB 交换了纹理的角色。程序在第一次调用 draw() 时使用 bindGroupA,在第二次调用时使用 bindGroupB,在第三次调用时再次使用 bindGroupA,以此类推。


生命游戏的第二个版本,webgpu/life_2.html,采用了不同的方法。它使用两个格式为 "r8unorm" 的纹理来表示棋盘的当前状态和下一个状态。具有该格式的纹理可以用于着色器程序中的采样,因此可以使用 textureSample() 而不是 textureLoad() 从输入棋盘中读取值。并且 r8unorm 纹理可以作为渲染管线的输出目标。然后,片段着色器可以有两个输出,一个发送到画布,另一个发送到 r8unorm 纹理

要使片段着色器有第二个输出,管线描述符必须指定两个目标:

let pipelineDescriptor = {
    ...
    fragment: {
        module: shader,
        entryPoint: "fragmentMain",
        targets: [
            { format: navigator.gpu.getPreferredCanvasFormat() },
            { format: "r8unorm"}
        ]
    },
    ...
}

然后渲染通道描述符使用输出纹理的视图作为第二个颜色附件:

let renderPassDescriptor = {
    colorAttachments: [
        {
            clearValue: { r: 0, g: 0, b: 0, a: 1 }, 
            loadOp: "clear",
            storeOp: "store", 
            view: context.getCurrentTexture().createView()
        },
        {
            // 第二个颜色附件是 r8unorm 纹理。
            loadOp: "load", // (这里可以,因为内容完全被替换。)
            storeOp: "store",
            view: outputTexture.createView()
        }
    ]
};

片段着色器的输出类型是一个包含两个输出值的结构体。有关全部细节,你当然应该查看两个示例生命程序的源代码。


纹理是复杂的。我只涵盖了 API 的部分内容。但我试图给你一个概述,包括你可能会需要的大部分信息。

The format of a texture specifies what kind of data is stored for each texel. The format specifies the number of color channels, the type of data, and in some cases how the data is interpreted. In the common 2D image format "rgba8unorm", there are four color channels ("r", "g", "b", and "a"). The data for a texel consists of 8 bits per color channel. And the value for a color channel is an unsigned integer ("u") in the range 0 to 255, which is divided by 255 to give a float value in the range 0.0 to 1.0 ("norm"). The format "bgra8unorm" is similar, but the order of the "r", "g", and "b" values is reversed. (One of these two formats, depending on platform, is the format for an HTML canvas; the function navigator.gpu.getPreferredCanvasFormat() returns the correct one for your platform. However, using the wrong format will not stop your program from working, since WebGPU does some format conversions automatically when reading and writing textures.)

WebGPU supports a large number of texture formats. There are formats with one color channel ("r"), two color channels ("rg"), and four color channels ("rgba"). The number of bits per color channel can be 8, 16, or 32. The data type can be float, unsigned integer, or signed integer. Some of the integer formats are normalized, but most are not. (There are also compressed texture formats, which are not covered in this textbook.)

For example, the formats "r8uint", "r16uint", and "r32uint" are unsigned integer formats with one color channel and storing one 8-, 16-, or 32-bit unsigned integer per texel. For two 16-bit signed integers per texel, the format would be "rg16sint". The format "rgba32float" uses four 32-bit floating-point numbers per texel.

All textures can be passed into shader programs as resources in bind groups, but only floating-point textures can be sampled using textureSample(). (This includes normalized integer formats.) However, the standard WGSL function textureLoad() can be used to read texel data from a texture, and it works both for integer and for floating-point textures. This function treats the texture like an array: Instead of using texture coordinates to sample the texture, you use integer texel coordinates to access the value at a specified texel. For example, to read from the texel in row 7, column 15 of a texture_2d<u32>, tex, you can use

let texelValue : vec4u = textureLoad( tex, vec2u(7,15), 0 );

The third parameter is the mipmap level, which is required but will usually be zero.

The return value from textureLoad() is always a 4-component vector, even when the texture has only one or two color channels. The missing color channels are filled in with 0 for the "g" or "b" channel, and 1 for the "a" channel. (Note that the term "color" is used for integer textures, even though the values in the texture probably don't represent colors. Floating-point textures can also store data other than colors.)

It is also possible for a shader program to write texel data to a texture, using the function textureStore(). However, the texture has to be passed into the shader as what is called a "storage texture," and this only works for certain texture formats. (There are lots of rules about what can be done with various texture formats. The rules are summarized in a table of Texture Format Capabilities in Section 26.1 of the WebGPU specification.)

In a shader, a storage texture has a type such as texture_storage_2d<r32uint,write>. The first type parameter, r32uint, is the texture format, and the second, write, specifies the access mode. (Currently, write is the only possibility.) The texture is passed into the shader as a bind group resource, with resource type storageTexture, rather than texture. Here, for example, is a bind group layout for a shader program that uses two r32uint textures, one for reading with textureLoad() and one for writing with textureStore():

let bindGroupLayout = device.createBindGroupLayout({
entries: [
    {    // for a texture_2d<u32> variable in the fragment shader
        binding: 0,
        visibility: GPUShaderStage.FRAGMENT,
        texture: {
            sampleType: "uint"  // Texel values are unsigned integers.
            // (Yes, it's called sampleType even though you can't sample it!)
        }
    },
    {    // for a texture_storage_2d<r32uint,write> in the fragment shader
        binding: 1,
        visibility: GPUShaderStage.FRAGMENT,
        storageTexture: {
            format: "r32uint",
            access: "write-only",  // This is the only possible value.
            viewDimension: "2d"    // This is the default.
        }
    }
]
});

Note that "storage texture" just means a texture that has been passed to the shader as a bind group resource of type textureStorage. The same texture could be used as a regular texture or as a storage texture, or both at different times.

The textureStore() function takes three parameters: the texture, the texel coordinates of the texel whose value is to be set, and the value. The value is always a 4-component vector, even if the texture has fewer than four color channels. The missing channels should be specified as 0 for the "g" or "b" channel and as 1 for the "a" channel. For example to set the single integer value at row 7, column 15 in a 2D r32uint storage texture to 17, you could use

textureStore( tex, vec2u(7,15), vec4u(17,0,0,1) );

The sample program webgpu/life_1.html implements John Conway's well-known Game of Life (see Subsection 6.4.5). The game board is a 2D array of cells, where each cell can be alive or dead. In the program, the state of the board is stored as a 2D texture of type r32uint, with 0 representing a dead cell and 1 representing a living cell. The game board is displayed on a canvas, and each pixel in the canvas is a cell. So, the size of the texture is the same as the size of the canvas.

The action of the game involves computing a new "generation" of cells from the current generation. The program actually uses two textures: a regular texture containing the current generation of the board and a storage texture that is used to store the next generation as it is computed. The program does all its work in its draw() function. That function draws a square that completely covers the canvas, so that the fragment shader is called once for each pixel on the canvas. The fragment shader uses textureLoad() to read the current state of the cell that it is processing. If the cell is alive, it returns white as the color of the fragment; if the cell is dead, it returns black. At the same time, the fragment shader computes the state of the cell in the next generation, and it writes that state to the storage texture using textureStore(). Between draws, the roles of the two textures are swapped, so that what was the next generation becomes the current generation.

Here is the fragment shader, leaving out the part that computes the new state of the cell. It uses another new function, textureDimensions(), which gets the size of a texture in each of its dimensions. That value is required for the new state computation.

@group(0) @binding(0) var inputBoard: texture_2d<u32>;
@group(0) @binding(1) var outputBoard: texture_storage_2d<r32uint,write>;

@fragment
fn fragmentMain(@builtin(position) position : vec4f) -> @location(0) vec4f {
let boardSize = textureDimensions(inputBoard);
let cell = vec2u(position.xy); // Integer pixel coords of this fragment.
let alive = textureLoad( inputBoard, cell, 0 ).r;  // Get current state.
                // (Note that the state is in the r color component.)
    .
    . // (Compute newAlive, the state of the cell in the next generation,)
    .
textureStore( outputBoard, cell, vec4u(newAlive,0,0,1) ); // Store new state.
let c = f32(alive);
return vec4f(c,c,c,1); // White if cell is now alive, black if it is dead.
}

The program creates two textures, texture1 and texture2, and loads texture1 with the initial state of the board. Here is the bind group that assigns texture1 to inputBoard in the shader and texture2 to outputBoard. It uses the sample bind group layout shown above.

bindGroupA = device.createBindGroup({
    // A bind group using texture1 for input and texture2 for output.
layout: bindGroupLayout,
entries: [
    { 
        binding: 0,
        resource: texture1.createView()
    },
    {
        binding: 1,
        resource: texture2.createView()
    }
]
});

A second bind group, bindGroupB, reverses the roles of the textures. The program uses bindGroupA the first time draw() is called, bindGroupB the second time, bindGroupA the third time, and so on.


A second version of the Life program, webgpu/life_2.html, uses a different approach. It uses two textures with format "r8unorm" to represent the current state and the next state of the board. A texture with that format can be used for sampling in a shader program, so values can be read from the input board using textureSample() instead of textureLoad(). And a r8unorm texture can be an output target for a render pipeline. The fragment shader can then have two outputs, one going to the canvas and one going to the r8unorm texture.

To have a second output from the fragment shader, the pipeline descriptor must specify two targets:

let pipelineDescriptor = {
        ...
    fragment: {
    module: shader,
    entryPoint: "fragmentMain",
    targets: [
            { format: navigator.gpu.getPreferredCanvasFormat() },
            { format: "r8unorm"}
    ]
    },
    ...

Then the render pass descriptor uses a view of the output texture as the second color attachment:

let renderPassDescriptor = {
colorAttachments: [
    {
        clearValue: { r: 0, g: 0, b: 0, a: 1 }, 
        loadOp: "clear",
        storeOp: "store", 
        view: context.getCurrentTexture().createView()
    },
    {  // The second color attachment is a r8unorm texture.
        loadOp: "load", // (OK here since contents are entirely replaced.)
        storeOp: "store",
        view: outputTexture.createView()
    }
]
};

The output type for the fragment shader is a struct that contains the two output values. For full details, you should, of course, look at the source code for the two sample Life programs.


Textures are complex. I have only covered parts of the API. But I have tried to give you an overview that includes most of the information that you are likely to need.