跳转至

9.4 WebGPU 中的 3D 图形

3D Graphics With WebGPU

到目前为止,我们的WebGPU示例都是二维的,但当然计算机图形学的主要兴趣在于渲染三维场景。这意味着使用三维坐标系、几何变换以及光照和材质。我们将在本节中看到所有这些内容。但请注意,我们将只使用基本的OpenGL光照模型,而不是已经变得更加常见的更现实的基于物理的渲染。本节的最后一个示例将是我简单的WebGL "diskworld" 层次建模示例的移植。这是WebGPU版本的演示:diskworld WebGPU Demo

So far, our WebGPU examples have been two-dimensional, but of course the main interest in computer graphics is in rendering three-dimensional scenes. That means using 3D coordinate systems, geometric transformations, and lighting and material. We will look at all that in this section. But note that we will use only the basic OpenGL lighting model, not the more realistic physically based rendering that has become more common. The last example in the section will be a port of my simple WebGL "diskworld" hierarchical modeling example. Here is a demo of the WebGPU version:

9.4.1 深度测试

The Depth Test

在我们进入3D之前,我们需要知道如何在WebGPU中实现深度测试。深度测试用于确保位于其他对象后面的对象实际上被这些前景对象隐藏起来。(见3.1.4小节。)与OpenGL不同,这不仅仅是启用测试的问题。您还必须提供用于保存图像中像素深度信息的深度缓冲区,并且您必须将该缓冲区附加到渲染管线。

示例程序 webgpu/depth_test.html 在一个2D场景中使用深度测试,绘制了五十个带有黑色轮廓的彩色圆盘。所有圆盘和轮廓都是在绘制之前完成的。着色器程序为每个圆盘和轮廓应用了不同的深度,以确保即使它们不是按顺序绘制的,圆盘和轮廓也遵循正确的前到后的顺序。详情请参阅源代码,并注意只有与深度测试相关的源代码部分有注释。

WebGPU中的深度缓冲区实际上是一个纹理,与图像大小相同。可以使用 device.createTexture() 函数创建它:

depthTexture = device.createTexture({
    size: [context.canvas.width, context.canvas.height],  // 画布大小
    format: "depth24plus", 
    usage: GPUTextureUsage.RENDER_ATTACHMENT
});

这里的 depthTexture 是一个全局变量,因为纹理是在初始化期间创建一次,但它将在每次绘制图像时使用。纹理的格式描述了每个像素存储的数据。这里使用的值 "depth24plus" 表示纹理每个像素至少持有24位深度信息。使用方式表示此纹理可以附加到渲染管线。

创建管线时,必须通过在 device.createRenderPipeline() 函数中使用的管线描述符中添加 depthStencil 属性来启用深度测试:

depthStencil: {  // 为此管线启用深度测试
    depthWriteEnabled: true,
    depthCompare: "less",
    format: "depth24plus",
},

这里的格式应与创建纹理时指定的格式匹配。depthWriteEnabled 和 depthCompare 的值可能如上所示。(深度测试通过比较新片段的深度值与当前存储在深度缓冲区的深度值来工作。如果比较结果为假,则丢弃新片段。depthCompare 属性指定应用的比较运算符。使用 "less" 意味着如果片段的深度小于当前深度,则使用该片段;也就是说,深度较低的项目被认为更接近用户。在某些情况下,"less-equal" 可能是该属性的更好值。将 depthWriteEnabled 属性设置为 true 意味着当新片段通过深度测试时,其深度值将写入深度缓冲区。在某些应用程序中,可能需要应用深度测试而不保存新的深度值。这有时是完成的,例如,在绘制半透明对象时(见 7.4.1小节)。)

最后,在绘制图像时,深度缓冲区必须作为渲染通道描述符的一部分附加到管线:

let renderPassDescriptor = {
    colorAttachments: [{
        clearValue: { r: 1, g: 1, b: 1, a: 1 },
        loadOp: "clear", 
        storeOp: "store",
        view: context.getCurrentTexture().createView()
    }],
    depthStencilAttachment: {  // 将深度缓冲区添加到 colorAttachment
        view: depthTexture.createView(),
        depthClearValue: 1.0,
        depthLoadOp: "clear",
        depthStoreOp: "store",
    }
};

请注意,depthStencilAttachment 中的 view 是之前创建的 depthTexture 的视图。depthClearValue 表示在清除深度缓冲区时,每个片段的深度将初始化为 1.0。1.0 是可能的最大深度值,表示深度位于图像中其他任何东西的后面。(顺便说一下,这里的 "Stencil" 指的是模板测试,本教科书中没有涵盖;模板测试的内存通常与深度测试的内存结合在一起,在WebGPU中它们将是同一纹理的一部分。)

renderPassDescriptor 中的 "clear" 属性意味着在渲染任何内容之前,颜色和深度缓冲区将用清除值填充。这适用于第一次渲染通道。但是,对于任何额外的渲染通道,为了避免擦除已经绘制的内容,"clear" 必须更改为 "load"。例如,示例程序在第二次渲染通道之前进行此更改:

renderPassDescriptor.depthStencilAttachment.depthLoadOp = "load";
renderPassDescriptor.colorAttachments[0].loadOp = "load";

实际上,示例程序使用了多重采样(9.2.5小节),这在创建深度纹理时需要一个小的更改:

depthTexture = device.createTexture({
    size: [context.canvas.width, context.canvas.height],
    format: "depth24plus",
    sampleCount: 4, // 使用多重采样时必需!
    usage: GPUTextureUsage.RENDER_ATTACHMENT,
});

Before we enter 3D, we need to know how to implement the depth test in WebGPU. The depth test is used to make sure that objects that lie behind other objects are actually hidden by those foreground objects. (See Subsection 3.1.4.) Unlike in OpenGL, it is not simply a matter of enabling the test. You also have to provide the depth buffer that is used to hold depth information about pixels in the image, and you have to attach that buffer to the rendering pipeline.

The sample program webgpu/depth_test.html uses the depth test in a 2D scene that draws fifty colored disks with black outlines. All of the disks are drawn before all of the outlines. The shader programs apply a different depth to each disk and to each outline to ensure that the disks and outlines are seen to follow the correct back-to-front order, even though they are not drawn in that order. See the source code for details, and note that only the parts of the source code that have to do with the depth test are commented.

The depth buffer in WebGPU is actually a kind of texture, with the same size as the image. It can be created using the device.createTexture() function:

depthTexture = device.createTexture({
    size: [context.canvas.width, context.canvas.height],  // size of canvas
    format: "depth24plus", 
    usage: GPUTextureUsage.RENDER_ATTACHMENT
});

depthTexture here is a global variable, since the texture is created once, during initialization, but it will be used every time the image is drawn. The format of the texture describes the data stored for each pixel. The value used here, "depth24plus", means that the texture holds at least 24 bits of depth information per pixel. The usage means that this texture can be attached to a render pipeline.

When the pipeline is created, the depth test must be enabled in the pipeline by adding a depthStencil property to the pipeline descriptor that is used in the device.createRenderPipeline() function:

depthStencil: {  // enable the depth test for this pipeline
depthWriteEnabled: true,
depthCompare: "less",
format: "depth24plus",
},

The format here should match the format that was specified when creating the texture. The values for depthWriteEnabled and depthCompare will probably be as shown. (The depth test works by comparing the depth value for a new fragment to the depth value currently stored in the depth buffer for that fragment. If the comparison is false, the new fragment is discarded. The depthCompare property specifies the comparison operator that is applied. Using "less" for that property means that the fragment is used if it has depth less than the current depth; that is, items with lower depth are considered closer to the user. In some cases, "less-equal" might be a better value for this property. Setting the depthWriteEnabled property to true means that when a new fragment passes the depth test, its depth value is written to the depth buffer. In some applications, it's necessary to apply the depth test without saving the new depth value. This is sometimes done, for example, when drawing translucent objects (see Subsection 7.4.1).)

Finally, when drawing the image, the depth buffer must be attached to the pipeline as part of the render pass descriptor:

let renderPassDescriptor = {
colorAttachments: [{
    clearValue: { r: 1, g: 1, b: 1, a: 1 },
    loadOp: "clear", 
    storeOp: "store",
    view: context.getCurrentTexture().createView()
}],
depthStencilAttachment: {  // Add depth buffer to the colorAttachment
    view: depthTexture.createView(),
    depthClearValue: 1.0,
    depthLoadOp: "clear",
    depthStoreOp: "store",
}
};

Note that the view in the depthStencilAttachment is a view of the depthTexture that was created previously. The depthClearValue says that the depth for every fragment will be initialized to 1.0 when the depth buffer is cleared. 1.0 is the maximum possible depth value, representing a depth that is behind anything else in the image. ("Stencil" here, by the way, refers to the stencil test, which is not covered in this textbook; memory for the stencil test is generally combined with memory for the depth test, and in WebGPU they would be part of the same texture.)

The "clear" properties in the renderPassDescriptor mean that the color and depth buffers will be filled with the clear value before anything is rendered. This is appropriate for the first render pass. But for any additional render passes, "clear" has to be changed to "load" in order to avoid erasing whatever was already drawn. For example, the sample program makes this change before the second render pass:

renderPassDescriptor.depthStencilAttachment.depthLoadOp = "load";
renderPassDescriptor.colorAttachments[0].loadOp = "load";

The sample program actually uses multisampling (Subsection 9.2.5), which requires a small change when creating the depth texture:

depthTexture = device.createTexture({
    size: [context.canvas.width, context.canvas.height],
    format: "depth24plus",
    sampleCount: 4, // Required when multisampling is used!
    usage: GPUTextureUsage.RENDER_ATTACHMENT,
});

9.4.2 坐标系

Coordinate Systems

我们一直在使用默认的WebGPU坐标系统,其中x轴从左到右范围是-1.0到1.0,y轴从下到上范围是-1.0到1.0,深度或z值从前到后范围是0.0到1.0。坐标超出这些范围的点不构成图像的一部分。这个坐标系统被称为归一化设备坐标NDC)。(OpenGL使用“裁剪坐标”来称呼其默认坐标系统;WebGPU使用该术语来指代其默认系统的齐次坐标,(x,y,z,w);也就是说,从裁剪坐标到NDC的变换是通过将(x,y,z,w)映射到(x/w,y/w,z/w)来实现的。)

归一化设备坐标被映射到光栅化过程中的视口坐标。视口坐标是正在渲染的矩形区域上的像素或设备坐标,其中(0,0)位于左上角,每个像素的高度和宽度等于1。视口坐标还包括未变换的深度值,范围在0到1之间。当片段着色器使用@builtin(position)输入时,其值以视口坐标给出。通常,片段着色器中像素的xy坐标将是该像素的中心,对于视口左上角的像素,坐标如(0.5,0.5)是半整数坐标。对于多重采样,像素内的其他点被使用。

但是,我们希望在绘制时能够使用我们选择的坐标系统。这就引入了几个新的坐标系统:对象坐标,顶点最初指定时的坐标系统;世界坐标,整个场景任意的坐标系统;以及眼坐标,代表用户视角下的世界,观察者位于(0,0,0),x轴从左到右延伸,y轴指向上方,z轴指向屏幕内部。所有这些坐标系统以及它们之间的变换在第3.3节中有详细讨论。这张图从该节中重复使用:

123

对于WebGPU,您应该将“裁剪坐标”与归一化设备坐标等同起来,并将“设备坐标”与视口坐标等同起来。

重要的是要理解,只有归一化设备坐标、视口坐标和视口变换是内置于WebGPU中的。其他坐标系统和变换是在代码中实现的,无论是在JavaScript端还是在着色器程序中。

建模变换和观察变换通常结合成一个模型视图变换,如上所示,原因在3.3.4小节中解释。所以,程序通常只需要处理模型视图和投影变换。

图中没有展示一个重要的变换。表面法向量在光照中扮演重要角色(见4.1.3小节)。当对象通过模型视图变换时,它的法向量也必须被变换。法向量的变换与模型视图变换不同,但可以从中派生。

所有这些变换都以矩阵形式实现。模型视图和投影变换是4x4矩阵。法向量的变换矩阵是3x3矩阵。

We have been using the default WebGPU coordinate system, in which x ranges from -1.0 to 1.0 from left to right, y ranges from -1.0 to 1.0 from bottom to top, and the depth, or z-value, ranges from 0.0 to 1.0 from front to back. Points with coordinates outside these ranges are not part of the image. This coordinate system is referred to as normalized device coordinates (NDC). (OpenGL uses the term "clip coordinates" for its default coordinate system; WebGPU uses that term to refer to homogeneous coordinates, (x,y,z,w), for its default system; that is, the transformation from clip coordinates to NDC is given by mapping (x,y,z,w) to (x/w,y/w,z/w).)

Normalized device coordinates are mapped to viewport coordinates for rasterization. Viewport coordinates are pixel or device coordinates on the rectangular region that is being rendered, with (0,0) at the top left corner and each pixel having height and width equal to 1. Viewport coordinates also include the untransformed depth value between 0 and 1. When a fragment shader uses the @builtin(position) input, its values are given in viewport coordinates. Ordinarily the xy coordinates for a pixel in the fragment shader will be the center of that pixel, with half-integer coordinates such as (0.5,0.5) for the pixel in the upper left corner of the viewport. For multisampling, other points within the pixel are used.

But we want to be able to use the coordinate system of our choice when drawing. That brings in several new coordinate systems: object coordinates, the coordinate system in which vertices are originally specified; world coordinates, the arbitrary coordinate system on the scene as a whole; and eye coordinates, which represent the world from the point of view of the user, with the viewer at (0,0,0), the x-axis stretching from left to right, the y-axis pointing up, and the z-axis pointing into the screen. All of these coordinate systems and the transformations between them are discussed extensively in Section 3.3. This illustration is repeated from that section:

123

For WebGPU, you should identify "clip coordinates" with normalized device coordinates and "device coordinates" with viewport coordinates.

It is important to understand that only normalized device coordinates, viewport coordinates, and the viewport transformation are built into WebGPU. The other coordinate systems and transformations are implemented in code either on the JavaScript side or in the shader program.

The modeling transform and viewing transform are usually combined into a modelview transform, as shown, for reasons explained in Subsection 3.3.4. So a program generally only needs to work with the modelview and projection transforms.

There is one important transformation not shown in the diagram. Normal vectors for surfaces play an important role in lighting (Subsection 4.1.3). When an object is transformed by the modelview transformation, its normal vectors must also be transformed. The transformation for normal vectors is not the same as the modelview transformation but can be derived from it.

All of these transformations are implemented as matrices. The modelview and projection transformations are 4-by-4 matrices. The transformation matrix for normal vectors is a 3-by-3 matrix.

9.4.3 进入 3D

Into 3D

示例程序 webgpu/Phong_lighting.html 是我们在 WebGPU 中的第一个 3D 图形示例。这个程序的功能与 WebGL 版本相同,webgl/basic-specular-lighting-Phong.html。它一次显示一个对象,由单一的白色光源照亮。用户可以控制显示的对象以及对象的材质属性,并且用户可以通过拖动图像来旋转对象。对象被定义为索引面集,并使用索引绘制进行渲染。

各种属性由程序的 JavaScript 端提供,并在着色器程序中使用。我已经将它们全部收集到着色器程序中的一个结构体中:

struct UniformData {
    modelview : mat4x4f,   // 大小 16,偏移量 0  
    projection : mat4x4f,  // 大小 16,偏移量 16 (以 4 字节浮点数为单位)
    normalMatrix : mat3x3f,// 大小 12,偏移量 32
    lightPosition : vec4f, // 大小 4,偏移量 44
    diffuseColor : vec3f,  // 大小 3,偏移量 48
    specularColor : vec3f, // 大小 3,偏移量 52
    specularExponent : f32 // 大小 1,偏移量 55
}

@group(0) @binding(0) var<uniform> uniformData : UniformData;

这在 JavaScript 端由一个长度为 56 的 Float32Array,userData 支持,值从该数组写入到 GPU 端持有该结构体的 uniform 缓冲区。上述结构体成员的偏移量对应于数组中的索引。例如,要将漫反射颜色设置为红色,我们可能会说:

userData.set( [1,0,0], 48 );
device.queue.writeBuffer( uniformBuffer, 4*48, uniformData, 48, 3 );

类型化数组方法 userData.set(array,index) 将数组的元素复制到 userData,从指定的索引开始。在 writeBuffer() 调用中,注意第二个参数给出了缓冲区中数据的字节偏移量,这是以浮点数为单位的偏移量的四倍。第四个参数是在类型化数组中要复制的数据的起始索引,第五个参数给出了要复制的数组元素的数量 —— 而不是字节。(程序实际上比这个示例更有组织地从 JavaScript 端到 GPU 端复制各种数据项。)

在着色器程序中,模型视图和投影矩阵在顶点着色器中使用,结构体的其他成员在片段着色器中使用。(将顶点着色器和片段着色器的数据组合在同一个结构体中,就像我在这里做的,可能不是最佳实践。)顶点着色器的输入是顶点的 3D 坐标和法向量。向量坐标以对象坐标系给出。顶点着色器的输出是顶点在裁剪坐标系中的位置(这是必需的输出),法向量和顶点在眼坐标系中的位置:

struct VertexOut {
    @builtin(position) position : vec4f,
    @location(0) normal : vec3f,
    @location(1) eyeCoords : vec3f
}

@vertex
fn vmain( @location(0) coords: vec3f,
        @location(1) normal: vec3f ) -> VertexOut {
    let eyeCoords = uniformData.modelview * vec4f(coords,1);
    var output : VertexOut;
    output.position = uniformData.projection * eyeCoords;
    output.normal = normalize(normal);  // 确保它是一个单位向量
    output.eyeCoords = eyeCoords.xyz/eyeCoords.w;  // 转换为 (x,y,z) 坐标
    return output;
}

要理解这段代码,你需要理解各种坐标系以及 WGSL 对矩阵和向量数学的支持。顶点的眼坐标通过将齐次对象坐标向量与模型视图矩阵相乘来获得。这给出了齐次 (x,y,z,w) 眼坐标,通过将 vec3f eyeCoords.xyz 除以 w 坐标 eyeCoords.w 转换为普通的 (x,y,z) 坐标。必须以裁剪坐标系给出的位置输出,通过将眼坐标向量与投影矩阵相乘来获得。

顶点着色器输出的单位法向量和眼坐标成为片段着色器的输入,在那里它们用于光照计算。(当然,片段的它们的值是从包含片段的三角形的顶点插值得到的。)Phong 光照指的是在片段着色器中使用插值法向量和基本的 OpenGL 光照模型进行光照计算(见 4.1.4小节7.2.2小节)。本节最后一个示例中将更多地讨论光照。

The sample program webgpu/Phong_lighting.html is our first example of 3D graphics in WebGPU. This program has functionality identical to the WebGL version, webgl/basic-specular-lighting-Phong.html. It displays one object at a time, illuminated by a single white light source. The user has some control over what object is shown and the material properties of the object, and the user can rotate the object by dragging on the image. The objects are defined as indexed face sets and are rendered using indexed drawing.

Various properties are provided by the JavaScript side of the program and used in the shader program. I have collected them all into a single struct in the shader program:

struct UniformData {
    modelview : mat4x4f,   // size 16, offset 0  
    projection : mat4x4f,  // size 16, offset 16 (measured in 4-byte floats)
    normalMatrix : mat3x3f,// size 12, offset 32
    lightPosition : vec4f, // size  4, offset 44
    diffuseColor : vec3f,  // size  3, offset 48
    specularColor : vec3f, // size  3, offset 52
    specularExponent : f32 // size  1, offset 55
}

@group(0) @binding(0) var<uniform> uniformData : UniformData;

This is backed on the JavaScript side by a Float32Array, userData, of length 56, and values are written from that array into the uniform buffer that holds the struct on the GPU side. The offsets listed above for members of the struct correspond to indices in the array. For example, to set the diffuse color to red, we might say

userData.set( [1,0,0], 48 );
device.queue.writeBuffer( uniformBuffer, 4*48, uniformData, 48, 3 );

The typed array method userData.set(array,index) copies the elements of the array into userData, starting at the specified index. In the call to writeBuffer(), note that the second parameter gives the byte offset of the data in the buffer, which is four times the offset measured in floats. The fourth parameter is the starting index in the typed array of the data to be copied, and the fifth parameter gives the number of elements—not bytes—of the array to be copied. (The program is actually more organized than this example about copying the various data items from the JavaScript to the GPU side.)

In the shader program, the modelview and projection matrices are used in the vertex shader, and the other members of the struct are used in the fragment shader. (It is probably not best practice to combine data for the vertex shader and fragment shader in the same struct, as I have done here.) The inputs to the vertex shader are the 3D coordinates and the normal vector for the vertex. The vector coordinates are given in the object coordinate system. The vertex shader outputs are the position of the vertex in clip coordinates (which is a required output), the normal vector, and the position of the vertex in the eye coordinate system:

struct VertexOut {
    @builtin(position) position : vec4f,
    @location(0) normal : vec3f,
    @location(1) eyeCoords : vec3f
}

@vertex
fn vmain( @location(0) coords: vec3f,
        @location(1) normal: vec3f ) -> VertexOut {
    let eyeCoords = uniformData.modelview * vec4f(coords,1);
    var output : VertexOut;
    output.position = uniformData.projection * eyeCoords;
    output.normal = normalize(normal);  // make sure it's a unit vector
    output.eyeCoords = eyeCoords.xyz/eyeCoords.w;  // convert to (x,y,z) coords
    return output;
}

To understand this code, you need to understand the various coordinate systems and the support in WGSL for matrix and vector math. The eye coordinates of the vertex are obtained by multiplying the homogeneous object coordinate vector by the modelview matrix. This gives the homogeneous (x,y,z,w) eye coordinates, which are converted to ordinary (x,y,z) coordinates by dividing the vec3f eyeCoords.xyz by the w-coordinate, eyeCoords.w. The position output, which must be given in clip coordinates, is obtained by multiplying the eye coordinate vector by the projection matrix.

The unit normal and eye coordinate outputs from the vertex shader become inputs to the fragment shader, where they are used in the lighting calculation. (Their values for a fragment are, of course, interpolated from the vertices of the triangle that contains the fragment.) Phong lighting refers to doing lighting calculations in the fragment shader using interpolated normal vectors and the basic OpenGL lighting model (see Subsection 4.1.4 and Subsection 7.2.2). There is more about lighting in the last example in this section.

9.4.4 wgpu 矩阵

wgpu-matrix

在程序的 JavaScript 端处理矩阵和向量时,使用一个支持矩阵和向量数学的 JavaScript 库会很方便。对于 WebGL,我们使用了 glMatrix(见 7.1.2小节)。对于 WebGPU,我们需要一个不同的库,原因有几个。一个原因是 WGSL 中裁剪坐标的 z 范围是从 0 到 1,而在 GLSL 中,范围是从 -1 到 1。这意味着两种着色语言中的投影矩阵将会不同。第二个原因是 WGSL 中的 3x3 矩阵包含 12 个浮点数,因为对齐问题(见 9.3.1小节),而在 GLSL 中,3x3 矩阵包含 9 个浮点数。

在我的示例中,我使用了 wgpu-matrix 库(webgpu/wgpu-matrix.js),由 Gregg Tavares 开发,根据 MIT 开源许可证分发。可以在其网页 https://wgpu-matrix.org/ 上找到下载和文档链接。(我的一些示例使用了该库的更小的,“压缩的”版本,webgpu/wgpu-matrix.min.js,该版本不适合人类阅读。)我在 wgpu-matrix 下载的 "dist" 文件夹中找到了 JavaScript 文件。

模型视图变换矩阵可以在 JavaScript 端计算,从单位矩阵开始,然后乘以由缩放、旋转和平移给出的观察和建模变换。有几种熟悉的方法来构造正交和透视投影矩阵(见 3.3.3小节)。所有这些都是使用 wgpu-matrix 容易实现的。

在 wgpu-matrix.js 中,矩阵和数学函数是对象 wgpuMatrix.mat4、wgpuMatrix.mat3 和 wgpuMatrix.vec4 等的属性。矩阵和向量表示为具有适当长度的 Float32Arrays。它们可以直接作为 Float32Arrays 创建,或者通过调用库中的函数创建;例如:

matrix4 = wgpuMatrix.mat4.create();  // 一个 4x4 矩阵
vector3 = wgpuMatrix.vec3.create();  // 一个 3 维向量

这些函数创建填充有零的数组。大多数矩阵和向量操作都会产生一个矩阵或向量作为输出。在 wgpu-matrix 中,您通常可以将现有的矩阵或向量作为函数的最后一个参数传递,以接收输出。然而,那个参数是可选的,如果没有提供,库将为输出创建一个新的矩阵或向量。在任何情况下,输出都是函数的返回值。例如,如果 modelview 是当前的模型视图矩阵,并且如果您想应用 [3,6,4] 的平移,您可以这样说:

wgpuMatrix.mat4.translate( modelview, [3,6,4], modelview );

或者

modelview = wgpuMatrix.mat4.translate( modelview, [3,6,4] );

第一个版本当然更有效率。

让我们看看 wgpu-matrix.js 中一些最重要的函数。这将包括在我的示例中使用的所有函数。创建投影矩阵最常见的方法是:

projMatrix = wgpuMatrix.mat4.perspective( fovy, aspect, near, far );

其中 fovy 是垂直视场角度,以弧度给出,aspect 是图像宽度与其高度的比率,near 是近裁剪面距离观察者的距离,far 是远裁剪面的距离。这基本上与 OpenGL 中的 gluPerspective() 函数相同(见 3.3.3小节),除了用弧度而不是度数来测量角度。glOrtho() 和 glFrustum() 的等价函数也在 wgpu-matrix 中可用。

对于模型视图矩阵,通常从观察变换开始。对此,gluLookAt() 的等价函数很方便:

modelview = wgpuMatrix.mat4.lookAt( eye, viewRef, viewUp )

参数是 3 维向量,可以指定为常规的 JavaScript 数组。这为位于 eye 的观察者构建了一个视图矩阵,观察方向朝向 viewRef,向量 viewUp 在视图中指向上方。当然,也可以通过从单位矩阵开始并应用平移和一些旋转来创建视图矩阵。例如,

modelview = wgpuMatrix.mat4.identity();
wgpuMatrix.mat4.translate(modelview, [0,0,-10], modelview);
wgpuMatrix.mat4.rotateX(modelview, Math.PI/12, modelview);
wgpuMatrix.mat4.rotateY(modelview, Math.PI/15, modelview);

(我将指出,然而,在我的本节示例程序中,视图矩阵实际上来自于我与 WebGL 一起使用的相同的“trackball rotator”。见 7.1.5小节。)

对于将建模变换应用于模型视图矩阵,wgpu-matrix 有以下函数,我在这里包括了可选的最后一个参数,并显示了数组形式的向量参数:

  • gpuMatrix.mat4.scale(modelview, [sx,sy,sz], modelview) — 按 x 方向的 sx 因子,y 方向的 sy 因子,和 z 方向的 sz 因子进行缩放。
  • gpuMatrix.mat4.axisRotate(modelview, [ax,ay,az], angle, modelview) — 绕通过 [0,0,0] 和 [ax,ay,az] 的直线旋转 angle 弧度。(注意,所有旋转都使用右手规则。)
  • gpuMatrix.mat4.rotateX(modelview, angle, modelview) — 绕 x 轴旋转 angle 弧度。
  • gpuMatrix.mat4.rotateY(modelview, angle, modelview) — 绕 y 轴旋转 angle 弧度。
  • gpuMatrix.mat4.rotateZ(modelview, angle, modelview) — 绕 z 轴旋转 angle 弧度。
  • gpuMatrix.mat4.translate(modelview, [tx,ty,tz], modelview) — 按 x 方向的 tx 距离,y 方向的 ty 距离,和 z 方向的 tz 距离进行平移。

法向量矩阵,用于变换法向量,是一个 3x3 矩阵。它可以通过取 4x4 模型视图矩阵的左上角 3x3 子矩阵,然后取该矩阵的转置的逆来从模型视图矩阵导出。在 wgpu-matrix 中,可以这样做:

normalMatrix = mat3.fromMat4(modelview); 
mat3.transpose(normalMatrix,normalMatrix)
mat3.inverse(normalMatrix,normalMatrix);

(如果模型视图矩阵不包括任何缩放操作,那么取逆和转置是不必要的。)

还有函数用于将向量 V 乘以矩阵 M。对于 4 维向量和 4x4 矩阵

transformedV = wgpuMatrix.vec4.transformMat4( V, M );

以及对于 3 维向量和 3x3 矩阵的类似函数。

We need to work with matrices and vectors on the JavaScript side of a program. For that, it is convenient to use a JavaScript library that supports matrix and vector math. For WebGL, we used glMatrix (Subsection 7.1.2). For WebGPU, we need a different library, for several reasons. One reason is that the range for z in clip coordinates in WGSL is from 0 to 1 while in GLSL, the range is from -1 to 1. This means that projection matrices will be different in the two shading languages. A second reason is that a 3-by-3 matrix in WGSL contains 12 floats, because of alignment issues (Subsection 9.3.1), while in GLSL, a 3-by-3 matrix contains 9 floats.

In my examples, I use the wgpu-matrix library (webgpu/wgpu-matrix.js), by Gregg Tavares, which is distributed under the MIT open source license. Download and documentation links can be found on its web page, https://wgpu-matrix.org/. (Some of my examples use the smaller, "minified," version of the library, webgpu/wgpu-matrix.min.js, which is not human-readable.) I found the JavaScript files in the "dist" folder in the wgpu-matrix download.

The modelview transformation matrix can be computed on the JavaScript side by starting with the identity matrix and then multiplying by viewing and modeling transformations that are given by scaling, rotation, and translation. There are several familiar ways to construct orthographic and perspective projection matrices (see Subsection 3.3.3). All of this is easily implemented using wgpu-matrix.

In wgpu-matrix.js, the matrix and math functions are properties of objects such as wgpuMatrix.mat4, wgpuMatrix.mat3, and wgpuMatrix.vec4. Matrices and vectors are represented as Float32Arrays with the appropriate lengths. They can be created as Float32Arrays directly or by calling functions from the library; for example:

matrix4 = wgpuMatrix.mat4.create();  // a 4-by-4 matrix
vector3 = wgpuMatrix.vec3.create();  // a 3-vector

These functions create arrays filled with zeros. Most matrix and vector operations produce a matrix or vector as output. In wgpu-matrix, you can usually pass an existing matrix or vector as the final parameter to a function, to receive the output. However, that parameter is optional, and the library will create a new matrix or vector for the output, if none is provided. In any case, the output is the return value of the function. For example, if modelview is the current modelview matrix, and if you want to apply a translation by [3,6,4], you can say either

wgpuMatrix.mat4.translate( modelview, [3,6,4], modelview );

or

modelview = wgpuMatrix.mat4.translate( modelview, [3,6,4] );

The first version is, of course, more efficient.

Lets look at some of the most important functions from wgpu-matrix.js. This will include all of the functions that are used in my examples. For creating a projection matrix, the most common approach is

projMatrix = gpuMatrix.mat4.perspective( fovy, aspect, near, far );

where fovy is the vertical field of view angle, given in radians, aspect is the ratio of the width of the image to its height, near is the distance of the near clipping plane from the viewer, and far is the distance of the far clipping plane. This is essentially the same as the gluPerspective() function in OpenGL (Subsection 3.3.3) except for measuring the angle in radians instead of degrees. Equivalents of glOrtho() and glFrustum() are also available in wgpu-matrix.

For the modelview matrix, it is usual to start with a viewing transformation. For that, the equivalent of gluLookAt() is convenient:

modelview = gpuMatrix.mat4.lookAt( eye, viewRef, viewUp )

The parameters are 3-vectors, which can be specified as regular JavaScript arrays. This constructs a view matrix for a viewer positioned at eye, looking in the direction of viewRef, with the vector viewUp pointing upwards in the view. Of course, a view matrix might also be created by starting with the identity matrix and applying a translation and some rotations. For example,

modelview = gpuMatrix.mat4.identity();
gpuMatrix.mat4.translate(modelview, [0,0,-10], modelview);
gpuMatrix.mat4.rotateX(modelview, Math.PI/12, modelview);
gpuMatrix.mat4.rotateY(modelview, Math.PI/15, modelview);

(I will note, however, that in my sample programs for this section, the view matrix actually comes the same "trackball rotator" that I used with WebGL. See Subsection 7.1.5.)

For applying modeling transformations to the modelview matrix, wgpu-matrix has the following functions, where I am including the optional final parameter and showing vector parameters as arrays:

  • gpuMatrix.mat4.scale(modelview, [sx,sy,sz], modelview) — scales by a factor of sx in the x direction, sy in the y direction, and sz in the z direction.
  • gpuMatrix.mat4.axisRotate(modelview, [ax,ay,az], angle, modelview) — rotates by angle radians about the line through [0,0,0] and [ax,ay,az]. (Note that all rotations use the right-hand rule.)
  • gpuMatrix.mat4.rotateX(modelview, angle, modelview) — rotates by angle radians about the x-axis.
  • gpuMatrix.mat4.rotateY(modelview, angle, modelview) — rotates by angle radians about the y-axis.
  • gpuMatrix.mat4.rotateZ(modelview, angle, modelview) — rotates by angle radians about the z-axis.
  • gpuMatrix.mat4.translate(modelview, [tx,ty,tz], modelview) — translates by a distance of tx in the x direction, ty in the y direction, and tz in the z direction.

The normal matrix, which is used to transform normal vectors, is a 3-by-3 matrix. It can be derived from the modelview matrix by taking the upper-left 3-by-3 submatrix of the 4-by-4 modelview matrix, and then taking the inverse of the transpose of that matrix. In wgpu-matrix, that can be done as follows:

normalMatrix = mat3.fromMat4(modelview); 
mat3.transpose(normalMatrix,normalMatrix)
mat3.inverse(normalMatrix,normalMatrix);

(If the modelview matrix does not include any scaling operations, then taking the inverse and transpose is unnecessary.)

There are also functions for multiplying a vector, V, by a matrix, M. For a 4-vector and a 4-by-4 matrix:

transformedV = wgpuMatrix.vec4.transformMat4( V, M );

and similarly for a 3-vector and a 3-by-3 matrix.

9.4.5 再次磁盘世界

Diskworld Yet Again

第7.2节 涵盖了在 WebGL 中实现 OpenGL 风格光照和材质的方法,包括漫反射、镜面反射和自发光材质属性,定向光和点光源,聚光灯和光照衰减。该节最后的“Diskworld 2”示例展示了所有这些属性。

示例程序 webgpu/diskworld_webgpu.html 是将 Diskworld 2 示例移植到 WebGPU 的功能相同的版本。WebGPU 版本的顶点着色器本质上与上面讨论的 Phong 光照示例 中的相同。片段着色器本质上与 WebGL 版本相同,除了变量和函数声明的语法以及一些类型的重命名。程序的 JavaScript 端使用层次建模来创建场景(见 3.2.3小节),变换使用 wgpu-matrix 库实现。基本对象,如圆柱体和球体,被创建为索引面集。每个对象有三个关联的缓冲区:一个包含 3D 顶点坐标的顶点缓冲区,一个包含法向量的顶点缓冲区,以及一个索引缓冲区。当渲染对象时,其缓冲区被附加到渲染管线。程序使用深度测试(显然!)和多重采样。值得查看源代码,但我将不详细讨论。然而,我们将简要看看片段着色器如何实现光照方程。光和材质属性以及法向量矩阵是片段着色器中的 uniform 变量:

struct MaterialProperties {
    diffuseColor : vec4f, // alpha 分量成为片段的 alpha
    specularColor : vec3f,
    emissiveColor : vec3f,
    specularExponent : f32
}

struct LightProperties {
    position : vec4f,
    color : vec3f,
    spotDirection: vec3f,  // 注意:只有点光源可以是聚光灯。
    spotCosineCutoff: f32, // 如果 <= 0,则不是聚光灯。
    spotExponent: f32,
    attenuation: f32,   // 线性衰减因子,>= 0(仅限点光源)。
    enabled : f32  // 0.0 或 1.0 表示 false/true
}

@group(1) @binding(0) var<uniform> material : MaterialProperties;
@group(1) @binding(1) var<uniform> lights : array<LightProperties,4>;
@group(1) @binding(2) var<uniform> normalMatrix : mat3x3f;

所有这些值都在同一个 uniform 缓冲区中。请注意,由于 uniform 的对齐要求(见 9.3.1小节),光属性在缓冲区中的偏移是 256 字节,法向量矩阵是 512 字节。(但这是 JavaScript 端的信息)。

光照方程由以下函数实现,该函数由片段着色器入口点函数为每个启用的光源调用:

fn lightingEquation( light: LightProperties, material: MaterialProperties,
                    eyeCoords: vec3f, N: vec3f, V: vec3f ) -> vec3f {
    // N 是法向量,V 是指向观察者的方向;它们都是单位向量。
    var L : vec3f;  // 指向光源的单位向量
    var R : vec3f;  // 反射光方向;通过 N 反射 -L
    var spotFactor = 1.0;  // 考虑聚光灯的乘数
    var attenuationFactor = 1.0; // 考虑光衰减的乘数
    if ( light.position.w == 0.0 ) { // 定向光。
        L = normalize( light.position.xyz );
    }
    else { // 点光源。
        // 只有点光源可能有聚光灯和衰减。
        L = normalize( light.position.xyz/light.position.w - eyeCoords );
        if (light.spotCosineCutoff > 0.0) { // 光源是聚光灯。
            var D = -normalize(light.spotDirection);
            var spotCosine = dot(D,L);
            if (spotCosine >= light.spotCosineCutoff) { 
                spotFactor = pow(spotCosine, light.spotExponent);
            }
            else { // 该点在聚光灯的光锥之外。
                spotFactor = 0.0; // 光不会对该点添加颜色。
            }
        }
        if (light.attenuation > 0.0) {
            var dist = distance(eyeCoords, light.position.xyz/light.position.w);
            attenuationFactor = 1.0 / (1.0 + dist*light.attenuation);
        }
    }
    if (dot(L,N) <= 0.0) { // 光没有照亮这一面。
        return vec3f(0.0);
    }
    var reflection = dot(L,N) * light.color * material.diffuseColor.rgb;
    R = -reflect(L,N);
    if (dot(R,V) > 0.0) { // 添加镜面反射。
        let factor = pow(dot(R,V), material.specularExponent);
        reflection += factor * material.specularColor * light.color;
    }
    return spotFactor*attenuationFactor*reflection;
}

返回值表示光源对片段颜色的贡献。可能光源实际上照射在正在渲染的图元的另一侧(“dot(L,N) <= 0.0”),在这种情况下,它不会对颜色做出贡献。否则,贡献被计算为漫反射和镜面反射的总和,乘以考虑聚光灯和光衰减的因子。如果光不是聚光灯,相应的因子是 1.0,对返回值没有影响。对于聚光灯,因子取决于片段在聚光灯锥体内的哪个位置。这里使用的光衰减因子称为“线性衰减”。它在物理上不真实,但经常使用,因为它可以比物理真实的衰减提供更好的视觉效果。我鼓励你阅读代码,作为一个 WGSL 编程的例子,并在你对光照模型有疑问时参考 第7.2节

Section 7.2 covered the implementation of OpenGL-style lighting and materials in WebGL, including diffuse, specular, and emissive material properties, directional and point lights, spotlights, and light attenuation. The "Diskworld 2" example at the end of that section illustrated all of these properties.

The sample program webgpu/diskworld_webgpu.html is a functionally identical port of the Diskworld 2 example to WebGPU. The vertex shader in the WebGPU version is essentially the same as that in the Phong lighting example that was discussed above. The fragment shader is essentially the same as the WebGL version, except for the syntax of variable and function declarations and some renaming of types. The JavaScript side of the program uses hierarchical modeling to create the scene (Subsection 3.2.3), with transformations implemented using the wgpu-matrix library. The basic objects, such as cylinders and spheres, are created as indexed face sets. Each object has three associated buffers: a vertex buffer containing the 3D vertex coordinates, a vertex buffer containing the normal vectors, and an index buffer. When an object is rendered, its buffers are attached to the render pipeline. The program uses the depth test (obviously!) and multisampling. It is worth looking at the source code, but I will not discuss it in detail. However, we will look briefly at how the fragment shader implements the lighting equation. The light and material properties and the normal matrix are uniform variables in the fragment shader:

struct MaterialProperties {
    diffuseColor : vec4f, // alpha component becomes the alpha for the fragment
    specularColor : vec3f,
    emissiveColor : vec3f,
    specularExponent : f32
}

struct LightProperties {
    position : vec4f,
    color : vec3f,
    spotDirection: vec3f,  // Note: only a point light can be a spotlight.
    spotCosineCutoff: f32, // If <= 0, not a spotlight.
    spotExponent: f32,
    attenuation: f32,   // Linear attenuation factor, >= 0 (point lights only).
    enabled : f32  // 0.0 or 1.0 for false/true
}

@group(1) @binding(0) var<uniform> material : MaterialProperties;
@group(1) @binding(1) var<uniform> lights : array<LightProperties,4>;
@group(1) @binding(2) var<uniform> normalMatrix : mat3x3f;

All of these values are in the same uniform buffer. Note that because of alignment requirements for uniforms (Subsection 9.3.1), the light properties are at offset 256 bytes in the buffer, and the normal matrix is at offset 512. (But that's information for the JavaScript side.)

The lighting equation is implemented by the following function, which is called by the fragment shader entry point function for each enabled light:

fn lightingEquation( light: LightProperties, material: MaterialProperties,
                            eyeCoords: vec3f, N: vec3f, V: vec3f ) -> vec3f {
    // N is normal vector, V is direction to viewer; both are unit vectors.
    var L : vec3f;  // unit vector pointing towards the light
    var R : vec3f;  // reflected light direction; reflection of -L through N
    var spotFactor = 1.0;  // multiplier to account for spotlight
    var attenuationFactor = 1.0; // multiplier to account for light attenuation
    if ( light.position.w == 0.0 ) { // Directional light.
        L = normalize( light.position.xyz );
    }
    else { // Point light.
        // Spotlights and attenuation are possible only for point lights.
        L = normalize( light.position.xyz/light.position.w - eyeCoords );
        if (light.spotCosineCutoff > 0.0) { // The light is a spotlight.
            var D = -normalize(light.spotDirection);
            var spotCosine = dot(D,L);
            if (spotCosine >= light.spotCosineCutoff) { 
                spotFactor = pow(spotCosine, light.spotExponent);
            }
            else { // The point is outside the cone of light from the spotlight.
                spotFactor = 0.0; // The light will add no color to the point.
            }
        }
        if (light.attenuation > 0.0) {
            var dist = distance(eyeCoords, light.position.xyz/light.position.w);
            attenuationFactor = 1.0 / (1.0 + dist*light.attenuation);
        }
    }
    if (dot(L,N) <= 0.0) { // Light does not illuminate this side.
        return vec3f(0.0);
    }
    var reflection = dot(L,N) * light.color * material.diffuseColor.rgb;
    R = -reflect(L,N);
    if (dot(R,V) > 0.0) { // Add in specular reflection.
        let factor = pow(dot(R,V), material.specularExponent);
        reflection += factor * material.specularColor * light.color;
    }
    return spotFactor*attenuationFactor*reflection;
}

The return value represents the contribution of the light to the color of the fragment. It is possible that the light is actually shining on the other side of the primitive that is being rendered ("dot(L,N) <= 0.0"), in which case there is no contribution to the color. Otherwise, the contribution is computed as the sum of the diffuse and specular reflection, multiplied by factors that account for spotlights and light attenuation. If the light is not a spotlight the corresponding factor is 1.0 and has no effect on the return value. For a spotlight, the factor depends on where in the cone of the spotlight the fragment is located. The light attenuation factor used here is called "linear attenuation." It is not physically realistic but is often used because it can give better visual results than physically realistic attenuation. I encourage you to read the code, as an example of WGSL programming, and to consult Section 7.2 if you have questions about the lighting model.