Skip to content

Utility Classes

Nick McDonald edited this page Mar 20, 2023 · 14 revisions

TinyEngine - Utility Classes

Utility classes wrap boilerplate OpenGL behavior. The classes are extremely small, so it is worth reading through their code to understand what they do and how they can be modified for your specific needs. Otherwise, their default constructors are usually sufficient! These classes have a destructor included that deletes all the data inside, so you don't have to.

Texture

Wraps OpenGL textures. Lets you create blank textures of a size, load from images or generate from raw data.

//Construction
Texture tex;                                     //Empty OpenGL Texture
Texture tex(1200, 800);                          //Size-Based (empty)
Texture tex(image::load("filename.png"));        //Using Surface Data
Texture tex(image::make(size, data, algorithm)); //Using raw data + algorithm (function pointer / lambda)

You can also specify the properties of the texture in more detail passing a simple struct:

Texture tex(1600, 1600, {GL_DEPTH_COMPONENT, GL_DEPTH_COMPONENT, GL_UNSIGNED_BYTE});
Texture tex(1600, 1600, {GL_DEPTH_COMPONENT, GL_DEPTH_COMPONENT, GL_UNSIGNED_BYTE}, raw_data_pointer);

which is practical when you would like a texture with e.g. only a depth component.

Texture contains a subclass cubetexture, which does the same stuff except as a cube map:

A texture generally has a constructor that takes an SDL_Surface* pointer. After a texture is created, you can also fill it using the method

void Texture::raw(SDL_Surface*)

The helper namespace image provides methods to load surfaces from image files or fill surface pixels using a passed algorithm.

See the examples 1.0_Image and 3.0_Automata for more information and usage examples.

For an example on using cubemap textures, see the example 9.0_Scene.

Buffer

As a recent optimization to TinyEngine, the Buffer utility class was created to allow for easy uploading and retrieving of data from the GPU. How to use buffers with shaders and models is explained further below. Buffers have a number of simple, templated constructors:

Buffer buf;

std::vector<float> vec(SIZE, 0.0f);
Buffer buf(vec);

float* fvec = new float[SIZE];
Buffer buf(SIZE, fvec);

//Empty Buffer with allocated memory!
Buffer buf(SIZE, (float)*NULL);

Buffers can be filled after declaration using the fill method, identically to the constructors:

buf.fill(SIZE, fvec);
buf.fill(vec);
buf.fill(SIZE, (float*)NULL); //also allowed! empty memory allocation

Data is retrieved from a buffer using the retrieve method (retrieval is important for e.g. compute shaders or SSBO writing):

buf.retrieve(SIZE, fvec);
buf.retrieve(vec);

Note that for a raw pointer, the correct amount of memory must already be allocated. For an example of use-cases for data retrieval, see example 15.0_Compute.

Note that buffers "own" their buffer and have a destructor which will delete the data from the GPU. Allocate a buffer with "new" if you want the binding location to persist.

This generally allows for efficient reuse of buffers. For instance, you can have a compute shader operate on a buffer in one pass and then use the exact same buffer be interpreted as render data while all data remains on the GPU. For an example of buffer reuse like this, see example 16.0_Gravity (gravity n-body simulation with positions used as particle locations when rendered to main FBO).

Shader

Wraps OpenGL shaders. Lets you define all input properties, and load files for the shader pipeline.

//Construction
Shader shader({"vertex.vs", "fragment.fs"}, {"all", "shader", "input", "properties"});
Shader shader({"vertex.vs", "geometry.gs", "fragment.fs"}, {"all", "shader", "input", "properties"});

A shader can also be told to have a certain number of SSBOs by declaring their name in the constructor.

Shader shader({"vertex.vs", "fragment.fs"}, {"prop"}, {"ssbo1", "ssbo2"});

Uniforms can be set for the shader after "using" it. Textures can be registered with the shader by passing the texture (reference) directly.

//Activation
shader.use();    
//Uniform Setting (fully templated - automatic handling of type)
shader.uniform(string name, int value);
shader.uniform(string name, glm::vec3 vec);
shader.uniform(string name, glm::mat4 mat);
shader.texture("exampleTexture", texture);

Uploading a texture like this makes the texture available via the GLSL samplers. For an example on how to sample cubemaps with geometry shaders, see 9.0_Scene.

SSBO buffering is templated so it is easy to bind the buffer to the shader before rendering. Once a buffer is declared it can be bound to the shader at a named binding location using the bind method. This allows for dynamic arrays in the shader.

//SSBO Buffering
std::vector<glm::mat4> models;
std::vector<glm::vec2> screen_positions;

Buffer modelbuf(models);
Buffer screen_positionsbuf(screen_positions);

shader.bind<glm::mat4>("ssbo1", &modelbuf);           //Access in shader with ssbo name "ssbo1"
shader.bind<glm::vec2>("ssbo2", &screen_positionsbuf);      
      
//... etc

SSBOs are accessed in the shader in a shader as shown here.

//fragment.fs or fragment.vs

#version 430 core

//...

layout (std430, binding = 0) buffer ssbo1 {
  mat4 some_name[];
};

layout (std430, binding = 1) buffer ssbo2 {
  vec2 some_other_name[];
};

//...

void main(){
//value accessed by: some_name[index]
}

Once bound to the shader the buffers are always available at the same locations. If you wish to update the information in the buffers, the buffer.fill method is called and the data at the correct location on the GPU is updated via the Buffer class.

See TinyEngine/examples/11.0_Voronoi for a simple example using the full functionality.

Compute Shader

A compute shader is derived from the shader base class and can be constructed in a similar manner. The difference is that the shader does not have attributes, and only has SSBOs. These are templated like above:

//Construction:
Compute compute("shader.cs", {"ssbo1", "ssbo2"});

The buffers are bound in an identical fashion to regular shaders:

//Binding Buffers

const int size = 1024;
std::vector<glm::vec2> position(size, glm::vec2(0.0f));
std::vector<glm::vec2> velocity(size, glm::vec2(0.0f));

Buffer positionbuf(position);
Buffer velocitybuf(velocity);

compute.bind<glm::vec2>("ssbo2", velocitybuf); 
compute.bind<glm::vec2>("ssbo1", positionbuf); //order does not matter

To dispatch a compute shader, use it and then define how large the work groups are to dispatch:

compute.use();
compute.dispatch(glm::vec3(size, 1, 1));

If you write to an SSBO in the shader, the data can be retrieved from the GPU in a simple manner:

//Retrieve SSBO Data from GPU
positionbuf.retrieve("ssbo1", position);

This requires that the target buffer (in this case: position) is coherent in memory and already has the appropriate size allocated.

For a fully working example application, see TinyEngine/examples/16.0_Gravity here. This is an N-Body gravity simulation in compute shaders. Can also be easily rewritten to simulate boids.

For another example where I have implemented a number of typical parallel algorithms (matrix multiplication, accumulation, incrementation, N-particle gauss transform for a GMM), see the example 15.0_Compute.

Important notes on SSBOs

Note that various shader invocations perform incoherent writes to the SSBO, so you may find that your add / subtract operation on the SSBO from various shader invocations don't all register. For this you must use atomic operations, which are only available for integral types (sadly).

OpenGL / GLSL does a weird thing, where it will PAD any vec3 buffers. See the discussion here.

This means that you should store any vec4 SSBOs with appropriate padding or simply as vec4 type. Otherwise, it will load the data correctly into the SSBO but retrieving the data will include the padding, and there is no elegant way to remove it with a stride somehow.

Shader Include Directives

The TinyEngine shader loader extends GLSL to have basic #include directives. It does this by simply doing a recursive load whenever it finds the appropriate macro. The include directives, like in C++, assume a relative path to the position of the shader which is including it. Note that the #include directive only works if your file name does not have apostrophes, i.e.:

//Wrong:
#include "test.incl"

//Correct:
#include test.incl

Model

Wraps VAOs rendering. Models are basically small interfaces for wrapping a number of buffers together into a single drawable object. A model first defines a number of input attributes to a shader:

//Construction
Model model({"binding", "points"});

A model can then bind a number of buffers to these attributes:

model.bind<glm::vec2>("binding", &binding_buf);
model.bind<glm::mat4>("point", &point_buf);

Note that a model does not assume ownership of the buffer here. If the buffer has its destructor called, then the data will be removed from the GPU and will not be available. You can pass a flag to the model's bind method so that the model assumes ownership of the buffer. This still requires that the buffer is allocated with new so it isn't deconstructed when it leaves scope, but is instead deconstructed when the model is deconstructed:

Buffer* binding_buf = new Buffer();
model.bind<glm::vec2>("binding", binding_buf, true); //assumes ownership of binding_buf

This has the benefit that you can e.g. define a set of models in a loop. Buffers for which a model assumes ownership can be accesed by the model's buffers member:

model.buffers["binding"]; //returns a pointer to the original buffer allocated with new

A model is then rendered by calling its render method.

//Rendering
model.render(GL_TRIANGLES); //lets you choose how to render

See example programs for some examples on how models are constructed.

To index a model, simply call the buffers index method:

model.index(&indexbuf, true); //owned index buffer

The index buffer can also be owned by passing the true flag. The index buffer is made available at the binding point name "index", i.e. model.buffers["index"].

Models are derived from a base-class "Primitive", which has a few pre-made classes that contain the buffers for e.g. rendering billboards to screen, or sprites / particles in 3D space.

Square2D board;  //2D vertex data (for e.g. drawing billboards)
Square3D sprite; //3D vertex data (for e.g. drawing textures on a flat board in 3D space)
Cube cube;       //Cube vertex data
Gizmo gizmo;

These also have render methods, but by default render using GL_TRIANGLE_STRIP.

For examples on how to use models see the examples 2.0_Heightmap, 6.0_Tree, 9.0_Scene. For examples on how to use the pre-made models, see the examples 1.0_Image (Square2D), 5.0_Particles (Square3D).

Target

The target utility class wraps FBOs so that you can render directly for textures. Textures can be manually and explicitly bound to various components.

//Construction
Target target(1200, 800);

Texture colortex(1200, 800, {GL_RGBA, GL_RGBA, GL_UNSIGNED_BYTE});
target.bind(colortex, GL_COLOR_ATTACHMENT0);

Texture depthtex(1200, 800, {GL_DEPTH_COMPONENT, GL_DEPTH_COMPONENT, GL_UNSIGNED_BYTE});
target.bind(depthtex, GL_DEPTH_ATTACHMENT);

Target has two sub-classes "Billboard" and "Cubemap", that give easier construction of the render targets. Both of these subclasses have one color component and one depth component bound by default.

//Easier Binding
Billboard billboard(1200, 800);
Cubemap cubemap(1200, 800);

A gBuffer might be constructed as follows:

Texture gPosition(WIDTH, HEIGHT, {GL_RGBA16F, GL_RGBA, GL_FLOAT});
Texture gNormal(WIDTH, HEIGHT, {GL_RGBA16F, GL_RGBA, GL_FLOAT});
Texture gColor(WIDTH, HEIGHT, {GL_RGBA, GL_RGBA, GL_UNSIGNED_BYTE});
Texture gDepth(WIDTH, HEIGHT, {GL_DEPTH_COMPONENT, GL_DEPTH_COMPONENT, GL_UNSIGNED_BYTE});

Target gBuffer(WIDTH, HEIGHT);
gBuffer.bind(gPosition, GL_COLOR_ATTACHMENT0);
gBuffer.bind(gNormal, GL_COLOR_ATTACHMENT1);
gBuffer.bind(gColor, GL_COLOR_ATTACHMENT2);
gBuffer.bind(gDepth, GL_DEPTH_ATTACHMENT);

The textures bound to a target can be bound to shaders for rendering and used for sampling normally.

To "target" a target for rendering, simply call its target method:

Target target(100, 100);
target.target(true);  //clears the textures
target.target(false); //does not clear the textures

To target the main window again, call:

Tiny::view.target(glm::vec3 clearcolor);

Instance

This is a class that allows you to instance render any model or primitive with arbitrary data buffers. The instance is constructed with a pointer to the model which it will instance render.

//Construction
Instance instance(&model); //using a pointer to the model we want to instance-render

Buffers are added to the instance class using the bind<T> method. This prepares an instance buffer object and prepares it for passing to an instanced render.

//Adding buffer data
std::vector<glm::mat4> models; //for e.g. a particle system

//Bind to instance
instance.bind<glm::mat4>(models);

You can bind any number of instanced attributes to the instance class. Note that the total number of instances (i.e. the size) is taken automatically from the last buffer bound.

Finally instance.render can be called with an optional drawing mode. If no argument is passed it assumes GL_TRIANGLE_STRIP which is intended for Square2D and Square3D primitives. For the Model class, just pass whatever drawing mode your VBOs are constructed for.

//Instanced render!
instance.render(GLenum drawing_mode);