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.

  1. First, we create a cube with sides of 2 units using Model:cube(2). As we saw before, this creates a Model instance.
  2. After that, we call withColor(green) on the newly created model. As with model.color = green, this changes the color of all vertices in the model. The difference is that withColor returns the original model, so that it works a bit nicer in pipelines.
  3. After that, we call .transform(translate(Vector(5, 0, 0))). The expression translate(Vector(5, 0, 0)) creates a core.geometry.Transform that represents a translation in the X direction. The transform member of Model then applies the transform to all vertices (in particular, to pos and normal).
  4. 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.