Simple Rendering
The full source code for this part of the tutorial is available in the file
root/tutorials/s3d/simple.bs. You can run it using storm -f tutorials.s3d.simple. The code
itself is fairly well-documented, so it is possible to read it and understand what is happening. The
rest of this page gives an overview of why all parts are needed, and the role they have in the
complete rendering pipeline.
If you want to follow along, you can create a .bs file in the current working directory and run it
on the command line (storm <file>.bs) as long as you use the name main for the function that is
named simple below. You also need to add use tutorials:s3d to the use statements at the start of
your file.
Geometry
The goal of this example is to render a multi-colored cube on the screen. The first step in this
process is to define a vertex type to store the cube itself. To do this, we define a new vertex
type called SimpleVertex. For this example, we only need a position and a color for each vertex.
use ui; use ui:s3d; use core:geometry; use graphics; // Needed if you create your own file, for SamplePainter3D later on. // use tutorials:s3d; vertex SimpleVertex { Vector pos; Color color; }
As can be seen above, the 3D library allows using normal types from the core:geometry and
graphics libraries to do this. As we will se later, we can even use these types in shaders.
Once we have a vertex type, we can define the geometry and store it inside a VertexBuffer and an
IndexBuffer. These will eventually end up in our Painter3D class, so we will create them there
right away.
class SimplePainter extends SimplePainter3D { // Vertex buffer, containing the vertices of the cube. VertexBuffer<SimpleVertex> vertices; // Index buffer. Lets us define faces without duplicating vertices. IndexBuffer indices; // The constructor. init() { init {} // Create a cube. First, create one vertex for each corner. vertices << SimpleVertex(Vector(-1, 1, 1), red); vertices << SimpleVertex(Vector(1, 1, 1), green); vertices << SimpleVertex(Vector(-1, -1, 1), blue); vertices << SimpleVertex(Vector(1, -1, 1), yellow); vertices << SimpleVertex(Vector(-1, 1, -1), red); vertices << SimpleVertex(Vector(1, 1, -1), green); vertices << SimpleVertex(Vector(-1, -1, -1), blue); vertices << SimpleVertex(Vector(1, -1, -1), yellow); // Then connect them using the index buffer. // Back face: indices << 0 << 2 << 1; indices << 1 << 2 << 3; // Top face: indices << 0 << 1 << 4; indices << 1 << 5 << 4; // Bottom face: indices << 2 << 6 << 3; indices << 3 << 6 << 7; // Left face: indices << 0 << 4 << 2; indices << 4 << 6 << 2; // Right face: indices << 1 << 3 << 5; indices << 5 << 3 << 7; // Front face: indices << 4 << 5 << 6; indices << 5 << 7 << 6; } }
Above, we first add one vertex for each corner of the cube (using the automatically generated constructor). We also give each corner an arbitrary color. After that we populate the index buffer with indices into the vertex buffer. This way we can use the same vertex as a part of more than one triangle, without duplicating the vertices.
Basic Rendering
To render things, we can overload the render function in our painter. Much like rendering 2D
graphics, the render function is called by the UI library to render new frames. The return value
from the render function determines whether the UI library will render frames continuously (e.g.
for an animation) or not. We can try this out by overriding the render function and create a
window that uses our painter using the runSample function in the common.bs file.
class SimplePainter extends SimplePainter3D { // ... // Render a frame. Bool render(Size size, Graphics3D g) : override { print("Render a frame!"); false; // no need for more frames } } // Note: call this function 'main' if you are writing code in a separate file. void simple() { runSample("Simple 3D", SimplePainter()); }
If we run the program at this point, we will se a blank window and a the text Render a frame!
printed to standard output whenever the window needs to be repainted.
As with 2D rendering, we need to use the ui.Graphics3D instance to actually render
things. The Graphics3D class has a few member functions that are useful to call at the start of
rendering, such as clearDepth to clear the depth buffer, and fill to fill the entire screen with
a background color. If we call them in our render function we can see control the background color
of the window.
Bool render(Size size, Graphics3D g) : override { // Clear the depth buffer. g.clearDepth(); // Set the background color to something interesting! g.fill(green); false; // No need for animations. }
However, if you inspect the members of the ui.Graphics3D object (e.g.
here), you will see that it does not provide any
way to draw vertices. To achieve that, we need to define a pipeline, a few additional vertex types,
and two shaders.
Pipelines and Intermediate Vertex Types
We start with the pipeline. A pipeline describes how data flows through the rendering pipeline: what data is unique for each vertex, what data is common between all vertices, how should this data be interpolated across the rendered triangles, and so on.
In this case, we want to render a set of SimpleVertex vertices in a vertex buffer (along with an
index buffer, but that is not too important at this time). However, we also need to transform them
from model space into screen space. In essence, we want to apply a transform to them. As such, we
can define our pipeline like below:
pipeline SimplePipeline { // The flow of data through the pipeline: // (input parameters) -> vertex shader output -> pixel shader output data (SimpleVertex point) -> SimpleIntermediate -> SimpleOutput; // Additional members become uniforms automatically, but are set globally rather than passed to // the function. Transform camera; }
The pipeline we defined is similar in structure to a class. Any variables (e.g. camera) will
become members of the class. What is special for a pipeline is that these will also become available
to shaders in the pipeline. They are often called uniforms since they have the same value for all
vertices.
Another difference is that a pipeline needs a data statement. This specifies how the pipeline
operates. One can think of a pipeline as a function that transforms data on the GPU. What makes it a
bit special is that it parallelizes its work on the GPU by operating on an array of its inputs in
parallel (this is one part of why GPUs are fast - the programming model makes it almost trivial to
parallellize the execution!). In this case, the pipeline accepts an array of SimpleVertex as an
input. The first stage is the vertex shader, and in this case the vertex shader will transform each
SimpleVertex into a SimpleIntermediate vertex. After that, the rendering hardware will
interpolate the SimpleIntermediate vertex type across all triangles, and execute the pixel shader
for each pixel. The pixel shader therefore accepts SimpleIntermediate as an input and outputs a
SimpleOutput, which the rendering hardware then uses to determine the final color of that pixel on
the screen.
The next step is therefore to define SimpleIntermediate and SimpleOutput. Since the rendering
hardware will use the information in SimpleIntermediate to construct and render a triangle on the
screen, it needs to contain at least a screen-space coordinate. Apart from that, we want it to
contain the color of the rendered triangle. As such, we can define it as follows:
vertex SimpleIntermediate { // Screen position. Vector4 pos : position, perspective; // Vertex color. Color color : perspective; }
Note that we need to annotate at least some of the members of SimpleIntermediate. To let the
rendering hardware know which element is the screen-space position, we need to mark one member as
position. We may also specify how each vertex compontent should be interpolated across the 3D
geometry. By default, perspective correct interpolation is used (i.e. the perspective keyword). As
such, we don't need to add perspective on the members. We chose to do it for clarity here. Other
interpolation options are linear for a linear interpolation, and none for no interpolation (it
simply uses the value from the first vertex).
It is worth noting that the position needs to be of the type Vector4, since the fourth component
(w) is used to perform perspective correct interpolation, for example.
The situation is similar for the SimpleOutput vertex type. It needs to contain at least one
element marked color or output (they are synonyms) to let the rendering hardware know which
member should be used for the screen color. As such, we can define it as follows:
vertex SimpleOutput { // Output color. Color color : color; }
Shaders
Now that we have defined a pipeline, we need to describe how to transform data between the different vertex types we just defined. We do this using shaders, which are essentially small programs that execute (often in parallel) on the GPU.
We need two shaders: a vertex shader and a pixel shader. The vertex shader is executed once for each
vertex in the vertex buffer. As we have defined our pipeline, its job is to convert a SimpleVertex
into a SimpleIntermediate vertex. As a part of this transform, it should transform the position of
the vertex from model space to world space. The pixel shader then receives a SimpleIntermediate
and determines what color the corresponding pixel should have.
In the 3D library, we can implement the shaders in a stripped-down version of Basic Storm. That way Storm is able to type-check the shader before it executes. It also cross-compiles the shader into a suitable shader language based on your current rendering backend (e.g. GLSL or HLSL).
We start by implementing the vertex shader as follows:
vertex shader for SimplePipeline: SimpleIntermediate simpleVertexShader(SimpleVertex point) { // Note: we can use the 'camera' uniform here. Vector4 pos = Vector4(point.pos) * camera; // Note: 'return' is optional, just as in Basic Storm. return SimpleIntermediate(pos, point.color); }
As can be seen above, pixel and vertex shaders are tied to a specific pipeline. This makes it
possible to use uniforms defined in the pipeline. In this case, the camera transform. The vertex
shader is quite simple in this case. The first line inside the shader casts the
core.geometry.Vector into a core.geometry.Vector4 and applies the camera
transformation to it. The intent is that we set up the camera rotation to transform model space into
camera space (i.e. screen coordinates). The second line of code then uses the variable pos to
create a SimpleIntermediate instance with the position and the original vertex color. As in Basic
Storm, if return is omitted, the value of the last statement is returned automatically.
Since we do not do any lightning, the pixel shader can simply use the color value of the
SimpleIntermediate vertex it receives (remember: the rendering hardware interpolates it for us).
As such, we can implement the pixel shader as follows:
pixel shader for SimplePipeline: SimpleOutput simplePixelShader(SimpleIntermediate point) { SimpleOutput(point.color); }
Note that we utilize the fact that return is optional in the implementation above.
Rendering Geometry
Now we have everything needed to render our cube, we just need to connect the pieces together.
The first thing we need to do is to create an instance of the pipeline. We add it as a member
variable to the SimplePainter class we created earlier. We also need to initialize it in the
init block of the class.
class SimplePainter extends SamplePainter3D { // The pipeline. SimplePipeline pipeline; // ... init() { init { pipeline = SimplePipeline:create(simpleVertexShader, simplePixelShader); } // ... } }
As can be seen above, creating a pipeline requires us to provide a vertex shader and a pixel shader. The type system will check that the shaders are actually associated with the pipeline we try to create.
Now we can use the pipeline in the render function to render geometry to the screen. The
pipeline has a render function that accepts the inputs we specified previously. In this case, we
use the overload that accepts a Graphics3D, a IndexBuffer, and a VertexBuffer<SimpleVertex>.
Note that the last parameter corresponds to what we specified after data in the pipeline! As such,
we can modify the render function in our class as follows:
Bool render(Size size, Graphics3D g) : override { // Clear the depth buffer and the background color. g.clearDepth(); g.fill(black); // Render using the pipeline. pipeline.render(g, indices, vertices); false; // No need for animations. }
If we run the program at this point, we will still not see anything useful. The reason for this is
that we have not yet set camera to a useful transform. Since the uniforms are initialized to 0 by
default, all vertices will simply be transformed to the coordinate (0, 0, 0), which is not very
interesting to look at.
Luckily, the fix is fairly easy. We just need to create a suitable transform and assign it to
camera. In this case, we can do the following:
Bool render(Size size, Graphics3D g) : override { // Clear the depth buffer and the background color. g.clearDepth(); g.fill(black); // Update the transform. Transform cameraPos = rotateX(-30 deg) * translate(Vector(0, 0, 3)); pipeline.camera = cameraPos * perspectiveProject(100 deg, size.aspect, 0.1, 100); // Render using the pipeline. pipeline.render(g, indices, vertices); false; // No need for animations. }
We first set cameraPos to a transform that rotates everything -30 degrees around the X axis, and
then translates everything 3 units away in the Z direction. This corresponds to a camera that looks
down on our cube (due to the rotation), and sits 3 units away in the Z direction. In the final code,
this part of the transform is stores as a member of the class so that we don't have to re-compute it
every frame.
The second line appends a perspective projection to everything. This is what causes the final result
to appear to be 3D. The parameters to perspectiveProject are: (1) the vertical FOV, (2) the aspect
ratio of the screen, (3) the near plane's Z coordinate, and (4) the far plane's Z coordinate.
This concatenated transform is then assigned to camera in the pipeline. This makes it so that the
data is uploaded to the GPU as appropriate. It is worth noting that it is not necessary to re-set
the transform every time render is called. Rather, the uniform retains its value across multiple
calls to render.
At this point, we can run the program and now we will see a stationary cube on the screen!
Simple Animations
To make the end result a bit more interesting (and to make it easier to see the back of the cube),
we want to make the cube rotate. To do this, we use the animationTime in the SamplePainter3D
class. This function simply returns a Float value that goes from 0 to 1 in the specified interval.
We can observe its behavior by updating our render function as follows:
Bool render(Size size, Graphics3D g) : override { // Get the animation time. Float time = animationTime(5 s); print("Time: ${time}"); // ... true; // We wish to do animations now! }
If you run the program now, you will see the output Time: ... and that the value increases from 0
to 1 every 5 seconds. Let's use this to make the cube move!
To make the cube rotate, we can simply add another transform before we apply the camera position and the perspective projection like below:
Bool render(Size size, Graphics3D g) : override { // Get the animation time. Float time = animationTime(5 s); print("Time: ${time}"); // Clear the depth buffer and the background color. g.clearDepth(); g.fill(black); // Update the transform. Transform cameraPos = rotateX(-30 deg) * translate(Vector(0, 0, 3)); pipeline.camera = rotateY(360 deg * time) * cameraPos * perspectiveProject(100 deg, size.aspect, 0.1, 100); // Render using the pipeline. pipeline.render(g, indices, vertices); true; // We wish to do animations now! }
Note that we use the expression 360 deg * time to "convert" time from a number in the range 0 to
1 into an angle expressed in degrees. This is a useful way to convert between units, without needing
to use pi.
If we run the program now, we see a cube that makes one full rotation every 5 seconds.
We can also use the time variable in shaders. Before we do, we first need to pass it to the
shader. Of course, we could add it as a member of the pipeline, just as we did with the camera
transform. However, we will illustrate a different way to pass uniforms to shaders. This way is
useful for values that will vary from one call to render to another (time is perhaps not the
best use-case for this, as it is the same for every frame).
First, we add another parameter after data in the pipeline. Note that we mark it as uniform
since we want the shader to use the same value for all vertices.
pipeline SimplePipeline { // The flow of data through the pipeline: // (input parameters) -> vertex shader output -> pixel shader output data (SimpleVertex point, uniform Float time) -> SimpleIntermediate -> SimpleOutput; // Additional members become uniforms automatically, but are set globally rather than passed to // the function. Transform camera; }
Much in the same way that camera is now available in our shaders, time is also available to our
shaders without any other modifications. For example, we can move the cube around in the vertex
shader, after applying the camera transform. We can do this by modifying the vertex shader as
follows:
vertex shader for SimplePipeline: SimpleIntermediate simpleVertexShader(SimpleVertex point) { // Note: we can use the 'camera' uniform here. Vector4 pos = Vector4(point.pos) * camera; // Modify the x coordinate a bit, for a wavy effect: pos.x += sin(360.0 deg * time); // Note: 'return' is optional, just as in Basic Storm. return SimpleIntermediate(pos, point.color); }
Finally, we need to pass the time to the rendering pipeline. We do this by updating the call to
pipeline.render as follows:
// Render using the pipeline. pipeline.render(g, indices, vertices, time);
Note how the list of types after data in the pipeline correspond to the parameters passed to
pipeline.render. However, also note that uniform parameters do not appear as a formal parameter
to the vertex (or pixel) shader. This is to illustrate the fact that only the formal parameters of
the vertex and pixel shaders are unique for this invocation. Remaining parameters are constant.
