A simple example π’ΒΆ
Resulting code: step050-next
Resulting code: step050-vanilla-next
Letβs dive into what you are quite likely here for: rendering 3D shapes!
Note
I rolled back the part of the code about dynamic uniforms for now. I also set the offset
to vec2f(0.0)
;
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let ratio = 640.0 / 480.0;
var offset = vec2f(0.0, 0.0);
out.position = vec4f(in.position.x + offset.x, (in.position.y + offset.y) * ratio, 0.0, 1.0);
out.color = in.color;
return out;
}
Switching to 3D dataΒΆ
Data fileΒΆ
The first thing we need is a 3rd column in our point position! We stick with our ad-hoc file format for now and simply add a βzβ attribute.
Here is a simple shape that you can save in resources/pyramid.txt
and which corresponds to a simple pyramid shape whose tip has a different color:
# In file resources/pyramid.txt
[points]
# We add a Z coordinate
# x y z r g b
# The base
-0.5 -0.5 -0.3 1.0 1.0 1.0
+0.5 -0.5 -0.3 1.0 1.0 1.0
+0.5 +0.5 -0.3 1.0 1.0 1.0
-0.5 +0.5 -0.3 1.0 1.0 1.0
# And the tip of the pyramid
+0.0 +0.0 +0.5 0.5 0.5 0.5
[indices]
# Base
0 1 2
0 2 3
# Sides
0 1 4
1 2 4
2 3 4
3 0 4
Of course we need to adapt our loadGeometry
function to handle this extra dimension. I added a int dimensions
argument that should be either 2 or 3 depending on whether we are in 2D or 3D:
bool ResourceManager::loadGeometry(
const std::filesystem::path& path,
std::vector<float>& pointData,
std::vector<uint16_t>& indexData,
int dimensions // <-- new argument
) {
// [...]
// Get x, y, z, r, g, b
for (int i = 0; i < dimensions + 3; ++i) {
// ^^^^^^^^^^^^^^ This was a 5
// [...]
}
/**
* Load a file from `path` using our ad-hoc format and populate the `pointData`
* and `indexData` vectors.
*/
static bool loadGeometry(
const std::filesystem::path& path,
std::vector<float>& pointData,
std::vector<uint16_t>& indexData,
int dimensions // <-- new argument
);
bool ResourceManager::loadGeometry(
const std::filesystem::path& path,
std::vector<float>& pointData,
std::vector<uint16_t>& indexData,
int dimensions // <-- new argument
) {
std::ifstream file(path);
if (!file.is_open()) {
std::cerr << "Could not load geometry!" << std::endl;
return false;
}
pointData.clear();
indexData.clear();
enum class Section {
None,
Points,
Indices,
};
Section currentSection = Section::None;
float value;
uint16_t index;
std::string line;
while (!file.eof()) {
getline(file, line);
// overcome the `CRLF` problem
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (line == "[points]") {
currentSection = Section::Points;
}
else if (line == "[indices]") {
currentSection = Section::Indices;
}
else if (line[0] == '#' || line.empty()) {
// Do nothing, this is a comment
}
else if (currentSection == Section::Points) {
std::istringstream iss(line);
// Get x, y, z, r, g, b
for (int i = 0; i < dimensions + 3; ++i) {
// ^^^^^^^^^^^^^^ This was a 5
iss >> value;
pointData.push_back(value);
}
}
else if (currentSection == Section::Indices) {
std::istringstream iss(line);
// Get corners #0 #1 and #2
for (int i = 0; i < 3; ++i) {
iss >> index;
indexData.push_back(index);
}
}
}
return true;
}
We can now load the geometry in Application::InitializeBuffers()
with an extra dimensions
argument:
std::vector<float> pointData;
std::vector<uint16_t> indexData;
bool success = ResourceManager::loadGeometry(
RESOURCE_DIR "/pyramid.txt", // <-- switch to the pyramid
pointData,
indexData,
3 /* dimensions */ // <-- new argument
);
if (!success) return false;
m_indexCount = static_cast<uint32_t>(indexData.size());
Vertex bufferΒΆ
As a consequence of this new dimension, we need to update the vertex buffer description.
First we change the format of the position attribute from Float32x2
to Float32x3
:
// Position attribute
vertexAttribs[0].shaderLocation = 0; // @location(0)
vertexAttribs[0].format = VertexFormat::Float32x3;
// ^ This was a 2
vertexAttribs[0].offset = 0;
// Position attribute
vertexAttribs[0].shaderLocation = 0; // @location(0)
vertexAttribs[0].format = WGPUVertexFormat_Float32x3;
// ^ This was a 2
vertexAttribs[0].offset = 0;
This offsets all the attributes coming after! So we change the offset of the color attribute:
// Color attribute
vertexAttribs[1].shaderLocation = 1; // @location(1)
vertexAttribs[1].format = VertexFormat::Float32x3;
vertexAttribs[1].offset = 3 * sizeof(float);
// ^ This was a 2
// Color attribute
vertexAttribs[1].shaderLocation = 1; // @location(1)
vertexAttribs[1].format = WGPUVertexFormat_Float32x3;
vertexAttribs[1].offset = 3 * sizeof(float);
// ^ This was a 2
And since the overall size of our attribtues changed, we need to reflect this in the vertex buffer byte stride:
// The buffer stride
vertexBufferLayout.arrayStride = 6 * sizeof(float);
// ^ This was a 5
vertexBufferLayout.stepMode = VertexStepMode::Vertex;
// The buffer stride
vertexBufferLayout.arrayStride = 6 * sizeof(float);
// ^ This was a 5
vertexBufferLayout.stepMode = WGPUVertexStepMode_Vertex;
Vertex shaderΒΆ
And donβt forget to update the vertex input struct in the shader!
struct VertexInput {
@location(0) position: vec3f,
// ^ This was a 2
@location(1) color: vec3f,
};
Now it kinda works, we can guess a pyramid is here, but I wouldnβt call it 3D yet. And adding in.position.z
to out.position.z
does not change anything so far:

The pyramid⦠seen from above, with no perspective.¢
Note
I intentionally set a different color for the tip of the pyramid so that we can see better. This will be better addressed when introducing a basic shading.
Basic transformΒΆ
This is a gentle introduction to trigonometry. If you are familiar with the concept, you may jump ahead.
Seen from above, this pyramid boringly looks like an square. Could we rotate this? A very basic way to change the view angle is to swap axes:
var position = vec3f(
in.position.x,
in.position.z, // swap axis Y and Z
in.position.y,
);
out.position = vec4f(position.x, position.y * ratio, 0.0, 1.0);
Where to insert this?
We place this in the vertex shader:
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let ratio = 640.0 / 480.0;
{{Set vertex out position}}
out.color = in.color;
return out;
}

The pyramid seen from the side (still no perspective).ΒΆ
What about in-between rotations? The idea is to mix axes, adding a little bit of z in the y coordinates and a little bit of y in the z coordinates.
var position = vec3f(
in.position.x,
in.position.y + 0.5 * in.position.z, // add a bit of Z in Y...
in.position.z + 0.5 * in.position.y, // ...and a bit of Y in Z.
);
out.position = vec4f(position.x, position.y * ratio, 0.0, 1.0);

The pyramid from a tilted view angle.ΒΆ
Of course at some point we have to remove some of in.position.y
from Y so that after a quarter of turn we reach Y = 0.0 * in.position.y + 1.0 * in.position.z
, as in the example above. So more generally our transform writes like this, where alpha
and beta
depend on the rotation angle:
let angle = uMyUniforms.time; // you can multiply it go rotate faster
let alpha: f32 = /* ??? */;
let beta: f32 = /* ??? */;
var position = vec3f(
in.position.x,
alpha * in.position.y + beta * in.position.z,
alpha * in.position.z - beta * in.position.y,
);
out.position = vec4f(position.x, position.y * ratio, 0.0, 1.0);
Note
If you pay close attention to the snippet above, you can notice a minus sign -
before the second beta
. It is not visible on our pyramid because it is symmetrical but swapping axes also flips the object. To counter-balance this, we can change the sign of one of the dimensions. Hence the Z coordinate after a quarter of turn must be -in.position.y
instead of in.position.y
.
It turns out that these weights alpha
and beta
are not easy to express in terms of basic operations with respect to the angle. So mathematicians came up with a dedicated name for them: cosine and sine! And the good news is that these are built-in operations in WGSL:
let angle = uMyUniforms.time; // you can multiply it go rotate faster
let alpha = cos(angle);
let beta = sin(angle);
var position = vec3f(
in.position.x,
alpha * in.position.y + beta * in.position.z,
alpha * in.position.z - beta * in.position.y,
);
out.position = vec4f(position.x, position.y * ratio, 0.0, 1.0);
Rotation in the YZ plane
A side-view of the pyramid. The (signed) length of the green vertical and horizontal lines give the value of alpha
and beta
respectively.ΒΆ
Congratulations, you have learned most of what there is to know about trigonometry for computer graphics!
Hint
If you cannot remember which one is the \(cos\) and which one is the \(sin\) among alpha
and beta
(donβt worry! It happens to everyone), just take an example with very simple rotation: angle = 0
. In such a case, we need alpha = 1
and beta = 0
. If you look at a plot of the \(sin\) and \(cos\) functions youβll quickly see that \(cos(0) = 1\) and \(sin(0) = 0\)
Important
The argument of trigonometric functions is an angle, but be aware that it must be expressed in radians. There is a total of \(2\pi\) radians for a full turn, which leads to the following elementary cross-multiplication rule:
So to convert an angle \(d\) in degrees into its equivalent \(r\) in radians, we simply do:
ConclusionΒΆ
We have a beginning of something. With this rotation, it starts looking like 3D, but there remains some important points to be concerned about:
Depth fighting: As highlighted in the image below, the triangles do not overlap in the correct order.
Transform: We have the basics, but it is a bit manual, and there is still no perspective!
Shading: The trick of setting the tip of the pyramid to a darker color was good for a start, but we can do much better.
These points are, in this order, the topic of the next 4 chapters (transforms are split in 2 chapters).

There is something wrong with the depth.ΒΆ
Resulting code: step050-next
Resulting code: step050-vanilla-next