Models
The full source code for this part of the tutorial is available in the file
root/tutorials/s3d/model.bs. You can run it using storm -f tutorials.s3d.model. As in the
previous parts, the code is fairly well-documented, and is likely possible to read on its own.
As before, this tutorial focuses on the differences from the first tutorial.
If you started working on your own previously, you can resume from the code you wrote as a part of
that tutorial. Note, however, that the painter, the pipeline, and the shaders are renamed from
Simple... to Model... to avoid name clashes in the tutorials package.
The Model Class
The center piece of this tutorial is the ui.s3d.Model class, and how it interacts with
the rest of the rendering system. At its core, the ui.s3d.Model class is fairly simple.
It is conceptually a class that contains a set of vertices and a set of indices as follows:
class Model { // Vertex buffer. VertexBuffer<Vertex> vertices; // Index buffer. IndexBuffer indices; // Additional members... }
What makes this useful is that it defines a standard interface which different systems can use to
exchange geometry between each other. As a part of this, the Model class also defines its own
vertex type: ui.s3d.Vertex, which can be used in rendering. It has the following members.
vertex Vertex { // Vertex position. Vector pos; // Vertex normal. Assumed to be normalized to length 1. Vector normal; // Vertex color. Color color; // Texture coordinate. Each coordinate is normally between 0 and 1. Point tex; }
Apart from defining a standard data layout, the Model class also provides a number of utilities to
manipulate geometry. For example, loading .obj files, creating simple geometric shapes, and
manipulating them. We will see a few of them in the remainder of this tutorial.
Adjusting the Pipeline
Since we will represent the geometry using the ui.s3d.Vertex type, we will once again
have to adapt our pipeline accordingly. In this case, we will define the pipeline and the
intermediate vertex type like below. Note that ModelIntermediate is the same as SimpleIntermediate.
vertex ModelIntermediate { // Position. Vector4 pos : position, perspective; // Color. Color color : perspective; } pipeline ModelPipeline { // Note that the source type refers to ui:s3d:Vertex. data (Vertex vertex) -> ModelIntermediate -> Output; // Camera transform. Transform camera; }
We will also have to modify our shaders slightly from the shaders used in the first tutorial, mainly by adjusting the name of the input in the vertex shader.
vertex shader for ModelPipeline: ModelIntermediate modelVertexShader(Vertex point) { ModelIntermediate(Vector4(point.pos) * camera, point.color); } pixel shader for ModelPipeline: Output modelPixelShader(ModelIntermediate point) { Output(point.color); }
Rendering Models
Now that we have adapted the pipeline, we can turn our attention to our painter. We will modify it
to render a Model. As such, our first order of business is to replace the vertex buffer and index
buffer with a Model. The Model class contains a vertex buffer and an index buffer after all. As
a starting point, we will just create a simple cube using the Model:cube static member.
At this time, our painter will look like below:
class ModelPainter extends SamplePainter3D { // The pipeline. ModelPipeline pipeline; // Model to render. Model model; // Transform for the camera position. Transform cameraPos; // Create. init() { init { // Create the pipeline and attach shaders. pipeline = ModelPipeline:create(modelVertexShader, modelPixelShader); // Create the camera transform. cameraPos = rotateX(-35 deg) * translate(Vector(0, 0, 8)); // Create a default model. model = Model:cube(2); } } // Render. Bool render(Size size, Graphics3D g) : override { Float time = animationTime(5 s); g.clearDepth(); g.fill(black); pipeline.camera = rotateY(360 deg * time) * cameraPos * perspectiveProject(100 deg, size.aspect, 0.1, 100); pipeline.render(g, model.indices, model.vertices); true; } }
The code above is not too different from what we have seen so far, at least as long as we keep in
mind that the model is just a vertex buffer and an index buffer wrapped in a class to make them
easier to manipulate. However, since the Model class is a part of the 3D rendering package, it
does receive some special treatment from other parts of the system. For example, we can simplify the
call to render by just passing the model as a whole unit:
pipeline.render(g, model);
At this time, you can run the example to see a rotating white cube.
Manipulating Models
Now it is time to explore the capabilities provided by the Model class. As a starting point, we
can easily set the color of all vertices in a model using the color assign function. For example,
we can make the cube red by adding the following line to the end of the constructor:
model.color = red;
Perhaps more interesting is the fact that the 3D library provides a simple loader for .obj files,
to make it easier to implement more complex 3D geometry. For example, we can import a version of the
Utah teapot by replacing the initialization of model with the following line:
model = loadOBJ(resUrl("teapot.obj"));
Due to the name resolution rules in Basic Storm, this can also be written as follows:
model = resUrl("teapot.obj").loadOBJ();
As in the texture tutorial, the resUrl function is a helper function that finds the
file teapot.obj in any of the res/ directories for us. We can then pass the resulting
core.io.Url to the loadOBJ function to load the file data (there is also an overload
that accepts an core.io.IStream if that is more convenient).
If we run the program at this point, we will see that the cube is replaced with a rotating teapot!
The Model class also allows concatenating and transforming the geometry contained within. For
example, we can add a translated green cube to the same model as the teapot as follows. Another
option would, of course, be to have two Model instances and render them separately. However, GPUs
tend to like working in batches, so few calls to render with large amounts of geometry tend to
give better performance compared to many calls with less geometry.
model.append(Model:cube(2).withColor(green).transform(translate(Vector(5, 0, 0))));
As you can see, the API makes it quite easy to nest things nicely. Let's walk through what happens step by step.
-
First, we create a cube with sides of 2 units using
Model:cube(2). As we saw before, this creates aModelinstance. -
After that, we call
withColor(green)on the newly created model. As withmodel.color = green, this changes the color of all vertices in the model. The difference is thatwithColorreturns the original model, so that it works a bit nicer in pipelines. -
After that, we call
.transform(translate(Vector(5, 0, 0))). The expressiontranslate(Vector(5, 0, 0))creates acore.geometry.Transformthat represents a translation in the X direction. Thetransformmember ofModelthen applies the transform to all vertices (in particular, toposandnormal). -
Finally, the translated model is appended to the teapot model using
.append.
Similarly, we can also add a sphere to balance the geometry a bit:
model.append(Model:sphere(2, 30, 30).withColor(blue).transform(translate(Vector(-5, 0, 0))));
At this point, we see a red teapot, a green cube, and a blue sphere in a line, all rotating on the screen.
