1.2 Drawing a Triangle with Defined Vertices
In our previous tutorial, we drew a triangle without providing explicit vertex data, instead calculating vertex positions in the shader. While this approach works for simple geometries, it's impractical for most real-world scenarios. In this tutorial, we'll draw a triangle using explicitly defined vertex data, a method more suitable for complex geometries.
Launch Playground - 1_02_triangle_with_verticesIn this tutorial, we will again draw a single triangle. However, this time, we will create vertex data explicitly.
@vertex
fn vs_main(
@location(0) inPos: vec3<f32>,
) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(inPos, 1.0);
return out;
}
First, let's examine the shader changes. We've omitted what remains the same as before. The input to vs_main
has changed from @builtin(vertex_index) in_vertex_index: u32
to @location(0) inPos: vec3<f32>
. Recall that @builtin(vertex_index)
is a predefined input field containing the current vertex's index, whereas @location(0)
is akin to a pointer to a storage location with arbitrary data we feed into the pipeline. In this particular tutorial, we will put the vertex positions in this location. The data format for this storage location is a vector of 3 floats.
In the function body, we no longer need to derive the vertex positions as we expect them to be sent to the shader. Here, we simply create a vector of 4 floats and assign its xyz components to the input position and the w component to 1.0.
The rest of the shader remains the same. The new shader is actually simpler.
const positionAttribDesc = {
shaderLocation: 0, // @location(0)
offset: 0,
format: 'float32x3'
};
Now, let's look at the pipeline changes to adopt the new shader code. First, we need to create a position attribute description. An attribute refers to the input to the shader function @location(0) inPos: vec3<f32>
. Unlike the @builtins
, an attribute doesn't have predefined meanings. Its meaning is determined by the developer; it could represent vertex positions, vertex colors, or texture coordinates.
First, we specify the attribute's location shaderLocation
, which corresponds to @location(0)
. Second, we tell the pipeline the offset with respect to the beginning of the data buffer that contains the vertex data to find the first element of this attribute. This is because we can mingle multiple attributes in a single piece of buffer. Finally, the format field defines the format and corresponds to vec3<f32>
in the shader.
const positionBufferLayoutDesc = {
attributes: [positionAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
};
Our next task involves creating a buffer layout descriptor. This step is crucial in aiding our GPU pipeline to comprehend the buffer's format when we submit it. For those new to graphics programming, these steps may seem verbose, and it's often challenging to grasp the difference between an attribute descriptor and a layout descriptor, as well as why they are necessary to describe a GPU buffer.
When submitting vertex data to the GPU, we typically send a large buffer containing data for numerous vertices. As introduced in the first chapter, transferring small amounts of data from CPU memory to GPU memory is inefficient, hence the best practice is to submit data in large batches. As previously mentioned, vertex data can contain multiple attributes intermingled, such as vertex positions, colors, and texture coordinates. Alternatively, you may choose to use dedicated buffers for each attribute separately. However, when we reach the vertex shader's entry point, we process each vertex individually. At this stage, we no longer have visibility of the entire attribute buffers. Each shader invocation works on one vertex independently, which allows shader programs to benefit from the GPU's parallel architecture.
To transition from submitting a single chunk of buffer on the CPU side to per-vertex processing on the GPU side, we need to dissect the input buffer to extract information for each individual vertex. The GPU pipeline can do this automatically with the help of the layout description. To differentiate between the attribute descriptor and the layout descriptor: the attribute descriptor describes the attribute itself, such as its location and format, whereas the layout descriptor focuses on how to break apart a list of multiple attributes for many vertices into data for each individual vertex.
Within this layout descriptor structure, we find an attribute list. In our current example, which only deals with positions, the list solely contains the position attribute descriptor. In more complex scenarios, we would include more attributes in this list. Following that, we define the arrayStride. This parameter denotes the size of the step by which we advance the buffer pointer for each vertex. For instance, for the first vertex (vertex 0), its data resides at offset zero within the buffer. For the subsequent vertex (vertex 1), we locate its data at offset zero plus arrayStride, which starts at the 12th byte (4 bytes for one float multiplied by 3).
Lastly, we specify the step mode. Two options exist: vertex and instance. By choosing either, we instruct the GPU pipeline to advance the pointer of this buffer for each vertex or for each instance. We'll explore the concept of instancing in future chapters. However, for most scenarios, the vertex option suffices.
const positions = new Float32Array([
1.0, -1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 1.0, 0.0
]);
Now, let's proceed to prepare the actual buffer, which is a relatively straightforward step. Here, we create a 32-bit floating-point array and populate it with the coordinates of the three vertices. This array contains nine values in total.
To better understand these coordinate values, recall the clip space or screen space coordinates we introduced previously. Each set of three values represents a vertex position in 3D space. The first vertex (1.0, -1.0, 0.0) is positioned at the bottom-right corner of the clip space. The second vertex (-1.0, -1.0, 0.0) is at the bottom-left corner, and the third vertex (0.0, 1.0, 0.0) is at the top-center of the clip space. They are organized in a clockwise order.
These coordinates are chosen deliberately to form a triangle that spans across the visible area of our rendering surface. The z-coordinate is set to 0.0 for all vertices, placing them on the same plane perpendicular to the viewing direction. This arrangement will result in a triangle that covers half of the screen, with its base along the bottom edge and its apex at the top-center.
At this stage, the data we've created resides in CPU memory. To utilize it within the GPU pipeline, we must transfer this data to GPU memory, which involves creating a GPU buffer.
const positionBufferDesc = {
size: positions.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
};
let positionBuffer = device.createBuffer(positionBufferDesc);
const writeArray =
new Float32Array(positionBuffer.getMappedRange());
writeArray.set(positions);
positionBuffer.unmap();
We begin this process by crafting a buffer descriptor. The descriptor's first field specifies the buffer's size, followed by the usage flag. In our case, as we intend to use this buffer for supplying vertex data, we set the VERTEX
flag. Lastly, we determine whether we want to map this buffer at creation.
Mapping is a crucial operation that must precede any data transfer between the CPU and GPU. It essentially creates a mirrored buffer on the CPU side for the GPU buffer. This mirrored buffer serves as our staging area where we write our CPU data. Once we've finished writing the data, we call unmap to flush the data to the GPU.
The mappedAtCreation
flag offers a convenient shortcut. By setting this flag, the buffer is automatically mapped upon creation, making it immediately available for data copying.
After defining the descriptor structure, we create the buffer based on this descriptor in the subsequent line. Since the buffer is already mapped at this point, we can proceed to write the data.
Our approach involves creating a temporary 32-bit floating-point array writeArray
, directly linked to the mapped GPU buffer. We then simply copy the CPU buffer to this temporary array. After unmapping the buffer, we can be confident that the data has been successfully transferred to the GPU and is ready for use by the shader.
const pipelineDesc = {
layout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: [positionBufferLayoutDesc]
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: 'triangle-list',
frontFace: 'cw',
cullMode: 'back'
}
};
commandEncoder = device.createCommandEncoder();
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, positionBuffer);
passEncoder.draw(3, 1);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
The remaining portion of the code bears a strong resemblance to the previous tutorial, with only a few key differences. One notable change appears in the pipeline descriptor definition. Within the vertex stage, we now provide a buffer layout descriptor in the buffers field. It's important to note that this field can accommodate multiple buffer descriptors if needed.
Another significant change is in the primitive section of the pipeline descriptor. We specify frontFace: cw
for clockwise, which corresponds to the order of vertices in our vertex buffer. This setting informs the GPU about the winding order of our triangles, which is crucial for correct face culling.
After creating the new pipeline using this updated descriptor, we need to set a vertex buffer when crafting a command with this pipeline. We accomplish this using the setVertexBuffer
function. The first parameter represents an index, corresponding to the buffer layout indices of the buffers
field when defining the pipeline. In this case, we specify that the positionBuffer
, which resides on the GPU, should be used as the source of vertex data.
The draw command remains similar to our previous example, instructing the GPU to render three vertices as a single triangle. However, the key difference now is that these vertices are sourced from our explicitly defined buffer, rather than being generated in the shader.
Upon submitting this command, you should see a solid triangle rendered on the screen. This approach of explicitly defining vertex data offers greater flexibility and control over the geometry we render, paving the way for more complex shapes and models in future tutorials.