Textures

The full source code for this part of the tutorial is available in the file root/tutorials/s3d/texture.bs. You can run it using storm -f tutorials.s3d.texture. As in the previous part, the code is fairly well-documented, so it is possible to read it and understand what is happening. Note, however, that both the source code and this tutorial assumes that you have read and understood the basics of rendering as outlined in the previous tutorial.

If you wish to write the code yourself, you can modify the code you wrote in the previous tutorial to add support for textures. As you will see, the majority of the code is very similar. This part of the tutorial therefore only describes the differences from the previous step. Note, however, that the code (texture.bs) uses different names for all types to not clash with the previous sample. As such, anythin named Simple... is now renamed to Tex... to differentiate the two. All other differences are described below.

Texture Coordinates

The first step in rendering textures is to describe how textures should be mapped to the 3D geometry. To do this, we need texture coordinates. In the vertex types used in the previous part of the tutorial, there is nowhere to store texture coordinates, so the first thing we need to do is to replace the color member in our vertex formats with texture coordinates. A texture coordinate is, as the name implies, a 2D coordinate that corresponds to a point on the texture. By convention, the texture coordinates range from 0 to 1 (inclusive), regardless of the size of the texture. Coordinates outside of this range either clip to the edge of the texture, mirror it, or repeat it, depending on the settings of the texture sampler.

As such, the first step is to update the vertex types as follows. Note that we do not need to revise the SimpleOutput type. Instead we just use the Output vertex type from common.bs, which is identical to SimpleOutput.

vertex TexVertex {
    // Position.
    Vector pos;

    // Texture coordinate (Point is a 2D coordinate).
    Point tex;
}

vertex TexIntermediate {
    // Screen position.
    Vector4 pos : position, perspective;

    // Texture coordinate.
    Point tex : perspective;
}

As we have now changed the vertex used to store the cube, we also need to update the code that creates the cube. Luckily it is enough to update the code that creates the vertices. The indices can stay the same.

class TexPainter extends SamplePainter3D {
    // ...

    init() {
        init {
            // ...
        }

        // Create a cube:
        vertices << TexVertex(Vector(-1, 1, 1), Point(1, 0));
        vertices << TexVertex(Vector(1, 1, 1), Point(0, 0));
        vertices << TexVertex(Vector(-1, -1, 1), Point(1, 1));
        vertices << TexVertex(Vector(1, -1, 1), Point(0, 1));
        vertices << TexVertex(Vector(-1, 1, -1), Point(0, 0));
        vertices << TexVertex(Vector(1, 1, -1), Point(1, 0));
        vertices << TexVertex(Vector(-1, -1, -1), Point(0, 1));
        vertices << TexVertex(Vector(1, -1, -1), Point(1, 1));

        // ...
    }

    // ...
}

Texture Objects

The rendering pipeline also needs to know which texture to use. To do this, we need to add a ui.Texture member to the pipeline. Even though a texture is not a vertex type, the pipeline knows how to handle textures properly. The texture object represents a 2D bitmap (potentially with different mip-map levels) that is uploaded to the GPU as required.

As such, we can modify our pipeline as follows:

pipeline TexPipeline {
    data (TexVertex point) -> TexIntermediate -> Output;

    // Transform.
    Transform camera;

    // Texture.
    Texture texture;
}

We also need to supply an image for the texture. We can do that by adding the following code to the init function of the painter class:

init() {
    init {
        // ...
    }

    // Set the texture.
    pipeline.texture.image = loadImage(resUrl("flower-raw.ppm"));

    // ...
}

Note that the added line of code utilizes the fact that the pipeline will create an empty ui.Texture object for us by default. As such, we can just set image to a graphics.Image instance that represents the image. Here, we load an image from disk using the graphics.Image loadImage(core.io.Url file) function. The core.io.Url is supplied by the resUrl function, which looks for the file in root/tutorials/s3d/res and root/res.

This approach works well when we want to set the texture once and use the same texture all the time. However, if we were to render different sets of geometry with different textures (e.g. cubes with different textures), this would not be efficient. Since each ui.Texture instance represents one texture uploaded to the GPU, constantly replacing the image in the texture would cause the 3D library to upload the same images to the GPU repeatedly. In such cases, it is better to create a set of ui.Texture instances up front, set their images, and then assign entire ui.Texture objects to the pipeline. That way the 3D library does not need to re-upload textures to the GPU all the time. For example:

class TexPainter extends SamplePainter3D {
    // Textures.
    Texture tex1;
    Texture tex2;

    // Create.
    init() {
        init {
            // ...
        }

        // Load textures.
        tex1.image = loadImage(resUrl("flower-raw.ppm"));
        tex2.image = loadImage(resUrl("tree-24.bmp"));

        // ...
    }

    // Render.
    Bool render(Size size, Graphics3D g) : override {
        // ...

        pipeline.texture = tex1;
        pipeline.render(g, indices, vertices);

        // ...

        pipeline.texture = tex2;
        pipeline.render(g, indices, vertices);

        // ...
    }
}

Using Textures in Shaders

Finally, we need to adjust our shaders. The vertex shader need to be updated to forward the texture coordinates instead of the vertex color. This can be done by simply replacing point.color with point.tex as below:

vertex shader for TexPipeline: TexIntermediate texVertexShader(TexVertex point) {
    Vector4 pos = Vector4(point.pos) * camera;
    return TexIntermediate(pos, point.tex);
}

The pixel shader requires a slightly larger modification. Instead of simply forwarding the color, we need to sample the texture. We can do this by calling the graphics.Color sample(core.geometry.Point coords) member inside the shader to read the relevant pixel (possibly with interpolation) from the texture. This looks as follows:

pixel shader for TexPipeline: Output texPixelShader(TexIntermediate point) {
    Color texColor = texture.sample(point.tex);
    return Output(texColor);
}

With those changes, we can now run the program to see a textured rotating cube!