Skip to content

Utility Classes

Nick McDonald edited this page Jan 26, 2022 · 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)

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_Image and 3_Automata for more information and usage examples.

For an example on using cubemap textures, see the example 9_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;

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_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_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"});

  //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", tex.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_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_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_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_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 als GL_TRIANGLE_STRIP.

For examples on how to use models see the examples 2_Heightmap, 6_Tree, 9_Scene. For examples on how to use the pre-made models, see the examples 1_Image (Square2D), 5_Particles (Square3D).

Target

The target utility class wraps FBOs so that you can render directly for textures. It has two sub-classes "Billboard" and "Cubemap", that give easier construction of the render targets.

  //Construction
  Target target(1200, 800);
  Texture tex(1200, 800);
  target.bind(tex);

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

Optionally, you can specify in the constructor whether you want to include a depth-buffer or ignore the color buffer (read target.cpp for more info).

  //Targeting
  billboard.target();
  billboard.target(glm::vec3 clearcolor);

When using the target base class, the bound texture is rendered to. When using the derived classes, they have a member "texture" and "depth" for the respective textures (which can be bound to a shader normally and used for sampling).

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);
Clone this wiki locally