[ Previous Section | Chapter Index | Main Index ]

Section 9.7

Some Details


To finish this introduction to WebGPU, we'll look briefly at a few useful things to know that didn't make it into earlier sections. You will find several new sample programs in the last two subsections.


9.7.1  Lost Device

If you start writing more serious applications, you should be aware that it's possible for a WebGPU device to become "lost." When that happens, the device stops working, and anything that you try to do with the device will be ignored. Resources such as buffers and pipelines that were created with the device will no longer be valid. Ordinarily, this will be rare. It can happen, for example, if you call device.destroy() because you no longer need the device. It could happen if the user unplugs an external display. More disturbing, the WebGPU specification says, "The device may become lost if shader execution does not end in a reasonable amount of time, as determined by the user agent." The "user agent" is the web browser that is running your program. That does not give much definite guidance about what to expect.

The function device.lost() returns a promise that resolves if and when the device becomes lost. It can be used to set up a function to be called if the device is lost. It might be used something like this, just after creating the device:

device.lost().then(
   (info) => {
      if ( info.reason !== "destroyed" ) {
         ... // (possibly try to recover)
      }
   }
);

The only possible values of info.reason are "destroyed" (meaning that device.destroy() was called) and "unknown." If the reason is not "destroyed," you might try to create a new device and reinitialize your application—at the risk that the same thing will go wrong again.

Hopefully, the behavior of device.lost() will be better defined in the future.


9.7.2  Error Handling

The first thing to remember about WebGPU errors is that they will almost always be reported in the Web browser's console. WebGPU error messages are informative and will often give you hints about how to fix the problem. The second thing to know is that WebGPU validates programs according to tightly specified criteria. If a program passes validity checks on one platform, it is likely to do so on every platform. The third thing is that when WebGPU finds a validity error, it does not automatically stop processing. It will mark the object that caused the problem as invalid and will try to continue. Attempts to use the invalid object will produce more error messages. So, if your program produces a series of error messages, concentrate on the first one.

You can improve the error messages generated by WebGPU by labeling your objects. You can label just about any WebGPU object with a text string of your choosing by adding a label property to the object. If WebGPU finds a validation error in the object, it will include the label in the error message. For example, if your program uses several bind groups and one of them causes a problem, adding labels to your bind groups can help you track down the error:

bindGroupA = device.createBindGroup({
    label: "bind group for outlines",
    layout: 
       .
       .
       .

Instead of relying on the Web browser console, it is possible to have a program check for errors. Things are complicated by the fact that errors are detected by the GPU side of the program. To get the error report back to the JavaScript side, you can use device.pushErrorScope() to add an error check to the GPU. Later, you can retrieve the result by calling device.popErrorScope(). pushErrorScope() takes a parameter indicating the type of error that you want to detect. The parameter can be "validation", "out-of-memory", or "internal"; "validation" is the most common. popErrorScope() returns a promise that resolves when all operations submitted to the GPU after the corresponding push have been completed. The value returned by the promise will be null if no error was detected; otherwise, it will be an object with a message property that describes the error.

For example, when I am developing a program, I like to check for compilation errors in my shader code. I can do that by pushing a "validation" error scope before attempting the compilation:

device.pushErrorScope("validation");
shader = device.createShaderModule({
   code: shaderSource
});
let error = await device.popErrorScope();
if (error) {
   throw Error("Compilation error in shader: " + error.message);
}

The error check could be removed once the program is working.

When WebGPU encounters an error that is not captured by an error scope, it generates an "uncapturederror" event. You can add an event handler to the device to respond to uncaptured errors: device.onuncapturederror = function(event) { ... }. But, as always, remember that watching the Web browser console is usually good enough!


9.7.3  Limits and Features

A WebGPU device is subject to certain "limits," such as the maximum number of vertex buffers that can be attached to a render pipeline or the maximum size of a compute workgroup. When you create a device by calling adapter.requestDevice() with no parameter, the device that is returned has a default set of limits which are guaranteed to be supported by every WebGPU implementation. For example, the default maximum size for a workgroup is 256. For most applications, the default limits are fine. However, if you absolutely need a workgroup of size 1024, you can try requesting a device with that limit:

device = await adapter.requestDevice({
    requiredLimits: {
       maxComputeInvocationsPerWorkgroup: 1024
    }
});

If the WebGPU adapter doesn't support the requested limit, this will throw an exception. If it succeeds in your Web browser, it means that you are writing a program that might fail elsewhere, when run on a platform that doesn't support the increased limit.

The object adapter.limits contains the actual limits supported by the adapter. (To see a list, write the object to the console.) Before requesting an increased limit, you should check this object to see whether the adapter supports it.

WebGPU also defines a set of "features," which represent optional device capabilities. For example, the feature "texture-compression-bc" makes it possible to use a certain type of compressed texture. (Compressed textures are not covered in this book.) Features cannot be used unless they are requested when the device is created:

device = await adapter.requestDevice({
   requiredFeatures: ["texture-compression-bc"] // array of feature names
});

Again, this will throw an exception if the feature is not available, and a feature request will limit the devices on which your program can run. The boolean-valued function adapter.hasFeature(name) can be used to test whether the adapter supports the feature wih the given name. For a list of possible features, see the WebGPU documentation.


9.7.4  Render Pass Options

A render pass encoder is used to add drawing commands to a command encoder. It specifies a pipeline and resources such as bind groups that are required by the pipeline. It also has several other options. We'll look at two of them here.

The viewport is the rectangular region in a canvas or other render target in which the rendered image is displayed. The default viewport is the entire render target, but the setViewport() function in a render pass encoder can be used to select a smaller viewport. The standard WebGPU NDC coordinate system, with x and y ranging from minus one to one and depth ranging from zero to one, is then mapped onto the smaller viewport, and no drawing takes place outside that viewport. If passEncoder is a render pass encoder, a call to the function takes the form

passEncoder.setViewport( left, top, width, height, depthMin, depthMax );

where left, top, width, and height are given in pixel coordinates, and depthMin and depthMax are in the range 0 to 1, with depthMin less than depthMax. Usually, depthMin will be zero and depthMax will be one. For example, when drawing to an 800-by-600 pixel canvas, you can map the scene to the right half of the canvas using

passEncoder.setViewport( 400, 0, 400, 600, 0, 1 );

In addition, you can restrict drawing to a smaller rectangle within the viewport using setScissorRect(), which has the form

passEncoder.setScissorRect( left, top, width, height );

where again left, top, width, and height are given in pixel coordinates. The difference between viewport and scissor rect is that a scissor rect does not affect the coordinate mapping: The viewport shows the entire rendered scene, but a scissor rect prevents part of the scene from being drawn.

The sample program webgpu/viewport_and_scissor.html uses both viewport and scissor rect. It is yet another moving disk animation, showing colored disks with black outlines. Different viewports are used to draw four copies of the scene to the four quadrants of a canvas. In two of the viewports, a scissor rect is also applied, but just to the disk interiors, not to their outlines.


9.7.5  Render Pipeline Options

A pipeline descriptor is used with device.createRenderPipeline() to create a render pipeline. The descriptor has a number of options that affect how the pipeline will render primitives. We have seen, for example, how the multisample property is used for multisampling antialiasing (Subsection 9.2.5) and how detpthStencil is used to configure the depth test (Subsection 9.4.1). Here, we look at a few more render pipeline options.

Color Blending. By default, the color that is output by a fragment shader replaces the current color of the fragment. But it is possible for the two colors to be blended. That is, the new color of the fragment will be some combination of the "source" color (from the shader) and the "destination" color (the current color of the fragment in the render target). This is often used to implement translucent colors, where the alpha component of the source color determines the degree of transparency. For an example, see the sample program webgpu/alpha_blend.html.

The configuration for color blending is nested inside the fragment property of the pipeline descriptor. The functionality is similar to the WebGL function gl.blendFuncSeparate(), which is discussed in Subsection 7.4.1. Here is the typical configuration for translucency:

fragment: {
   module: shader,
   entryPoint: "fragmentMainForDisk",
   targets: [{
     format: navigator.gpu.getPreferredCanvasFormat(),
     blend: { // Configure the formulas to be used for color blending.
        color: { // For RGB color components.
           operation: "add",                  // "add" is the default.
           srcFactor: "src-alpha",            // The default is "one".
           dstFactor: "one-minus-src-alpha"   // The default is "zero".
        },
        alpha: { // For the alpha component.
           operation: "add",
           srcFactor: "zero",
           dstFactor: "one"
        }
     }
   }]
}

Blending for the red, green, and blue color components is configured separately from the alpha component. The values used here for the color property say that the new RGB color value is a weighted average of the fragment shader output and the current fragment color. The values used for alpha say that the alpha component of the destination will remain unchanged. The general formula, using the "add" operation, is

new_color = shader_output*srcFactor + current_color*dstFactor

Another common configuration is to set the operation to "add" and both srcFactor and dstFactor to "one", meaning that the shader output is simply added to the current color. This might be used to build up the colors in the target by using multiple passes that each add a little to the color value.

Color Masking. The writeMask property of the fragment target lets you control which color components of the fragment shader output will be written to the render target. (The same functionality is called "color masking" in OpenGL; Subsection 7.4.1 discusses how it can be used for anaglyph stereo.) For example, if you restrict writing to the red component, then only the red component of the current fragment color can be changed; the green, blue, and alpha components will be left unchanged. Here is how you would do that in a render pipeline descriptor:

fragment: {
   module: shader,
   entryPoint: "fragmentMain",
   targets: [{
     format: navigator.gpu.getPreferredCanvasFormat(),
     writeMask: GPUColorWrite.RED  // Only write the red component to target.
   }]
}

Other values for the writeMask property include GPUColorWrite.GREEN, GPUColorWrite.BLUE, and GPUColorWrite.ALPHA. You can also combine several of these constants with the or ("|") operator to write several components. For example,

writeMask: GPUColorWrite.GREEN | GPUColorWrite.BLUE

The default value is GPUColorWrite.ALL, which means that all four color components are written. The sample program webgpu/color_mask.html lets you experiment with writing to any combination of the red, green, and blue color components. Note that if you write just the red component to a black background, you will get shades of red, since the green and blue components will still be zero after writing. But if you write to a white background, you will get shades of blue-green, since the green and blue components will still equal one after the write, while the red component can be less than one.

Depth Bias. When the depth test is enabled, drawing two things at almost exactly the same depth can be a problem, because one object might be visible at some pixels while the other object is visible at other pixels. See the end of Subsection 3.1.4. The solution is to add a small amount, or "bias," to the depth of one of the objects. (This is called "polygon offset" in OpenGL; see the end of Subsection 3.4.1.) The sample program webgpu/polyhedra.html lets the users view polyhedra that are drawn with white faces and black edges. It uses depth bias to ensure that the edges are fully visible. The configuration is part of the depthStencil property of the pipeline descriptor that is used for drawing the faces:

depthStencil: {  
   depthWriteEnabled: true,
   depthCompare: "less",
   format: "depth24plus",
   depthBias: 1,
   depthBiasSlopeScale: 1.0
}

The depthBias and depthBiasSlopeScale properties are used to modify the depth of each fragment that is rendered by the pipeline. The default values are zero, which leaves the depth unchanged. Positive values will increase the fragment's depth, moving it a bit away from the user. The values 1 and 1.0 for depthBias and depthBiasSlopeScale shown here should work in most cases. (The value of depthBias is multiplied by the smallest positive difference between two depths that can be represented in the depth buffer. That by itself might work in many cases, but for triangles that the user is viewing close to edge-on, it might not be enough. The depthBiasSlopeScale adds an additional bias that depends on the angle that the triangle makes with the view direction.) Note that depth bias seems to work only for triangle primitives, not for lines or points, so the depth bias in the sample program is applied to the faces of the polyhedron, not to the edges.

Face Culling and Front Face. The polyhedra example uses two more pipeline options: cullMode and frontFace. The are options in the primitive property of the render pipeline descriptor.

The polyhedra in the program are all closed objects: The interior is completely hidden by the exterior. There is no need to render back-facing polygons, since they lie behind front-facing polygons. The cullMode property can be used to turn off rendering of either front-facing or back-facing triangles. With the default value, "none", no triangles are culled. In the polyhedra program, I set cullMode to "back", to avoid the expense of rendering back-facing triangles that would not be visible in the final image.

However, I had to make another change. The usual convention is that the front face of a triangle is determined by the rule that when looking at the front face, the vertices are given in counterclockwise order. However, the polyhedron models in the program use the opposite convention: clockwise ordering. So, I set the frontFace option of the primitive to "cw" to specify clockwise vertex ordering.

 primitive: {
    topology: "triangle-list",
    cullMode: "back",  // Other values are "front" and "none".
    frontFace: "cw"    // The other value is "ccw" (counterclockwise).
}

Now, that change has no effect on the appearance of the scene; it was done for efficiency only. And if you wondering, yes, I could have just set cullMode to "front", but that would be misleading—and it would have left me with no example for frontFace.


[ Previous Section | Chapter Index | Main Index ]