The Shader Storage Buffer Object (SSBO) and the Uniform Buffer Object (UBOs) are buffer objects in OpenGL used to transfer data to shaders. Although both are very similar there are important differences between them.

The UBO provides uniform data to the shader, in the form of uniform blocks, which is accessed through internal shader-accessible memory reads. The advantage of using the UBO instead of separate uniforms is that you can quickly switch between different sets of uniform data for different instances of the same program in you application. Additionally, UBO accesses are faster than SSBOs.

// Example of uniform block declared in GLSL code
layout (std140) uniform PBR {
    vec3 albedo;
    float metallic;
    float roughness;
    float ao;
};

SSBOs can be used in the same way, but have some advantages over UBOs:

  • SSBOs can store much more memory than UBOs (128MB against 16KB).
  • shaders can write into SSBOs (we need to atomic operations and barriers though).
  • SSBOs can have variable size (which can be queried by the shader at runtime).
// Example of SSBO layout declared in GLSL code
layout(std430, binding = 0) buffer layoutName {
    struct {
        vec3 position;
        vec3 color;
    } lights[];
};
// data can then be accessed as
lights[i].position
lights[i].color

Although SSBOs seem to be much better then UBOs, their subtle differences are important. It really depends on the situation to decide which one to use.

Let’s now discuss an example that uses both types of buffers (and gives the results above). Here we are going to setup a scene with 4 point light sources, 1 object with a PBR shader attached to it. Light data will be stored in a SSBO and PBR parameters will be stored in an UBO (the shader will access both buffers just like the two code snippets listed earlier). The steps to construct our application are as follows:

  • load mesh data
  • compile shader
  • create and update UBO data
  • create and set SSBO light data

You’ll notice that we use vec3_16 in client code to store elements in the UBO and SSBO. That is because depending on the layout you pick – std140, std430,… – the memory must be aligned. For example, the alignment is set to be the same as the alignment of the biggest member of the struct. For some layouts, a 3-component vector can have its alignment rounded up to 4-component alignment, which is the case of out vec3, so we need to use a vec3 with alignment of 16.

struct alignas(16) vec3_16 {
  float x{0};
  float y{0};
  float z{0};
};

Scene Object

Our shader will require only vertex positions and normals. We could load the mesh from a file or use some procedural mesh:

auto include_normals = circe::shape_options::normal;
circe::gl::SceneModel mesh = 
            circe::Shapes::icosphere(5, include_normals);
// or
            circe::gl::SceneModel::fromFile("path/to/file", include_normals);

PBR Shader

Here we will use a simple version of a PBR shader (source). The required uniforms are:

// uniforms used in vertex shader
layout(location = 3) uniform mat4 projection;
layout(location = 4) uniform mat4 model;
layout(location = 5) uniform mat4 view;
// uniforms used in the fragment shader
uniform vec3 camPos;
layout (std140) uniform PBR {
    vec3 albedo;
    float metallic;
    float roughness;
    float ao;
};

Note that the fragment shader will use the an UBO to access the PBR parameters. Also, you don’t need to set the location for each uniform (as above) or cache the locations in you code. Circe does it automatically.

In order to compile the shader and use it in our scene object, we can do:

if (!mesh.program.link("directory", "shader name"))
    std::cerr << "Failed to load model shader: " + mesh.program.err;

To update the uniform values just do:

mesh.program.use();
mesh.program.setUniform("view",
                ponos::transpose(camera->getViewTransform().matrix()));
mesh.program.setUniform("model", ponos::transpose(mesh.transform.matrix()));
mesh.program.setUniform("projection",
                ponos::transpose(camera->getProjectionTransform().matrix()));
mesh.program.setUniform("camPos", camera->getPosition());

UBO

Circe provides a class to handle UBOs:

circe::gl::UniformBuffer scene_ubo;

Here we need to connect the UBO and the shader program in order to register the uniform blocks the shader will access and the binding point of the buffer:

scene_ubo.push(mesh.program);
mesh.program.setUniformBlockBinding("PBR", 0);

Now we can store data into our UBO and it will be available to our shader. For this, we can declare a similar struct to represent our uniform block:

struct alignas(16) PBR_UB {
    vec3_16 albedo;
    float metallic{};
    float roughness{};
    float ao{};
} pbr_ubo_data;

and simply store it into our buffer

// setup parameter values
pbr_ubo_data.albedo = vec3_16(.5f, 0.f, 0.f);
pbr_ubo_data.ao = 1;
pbr_ubo_data.metallic = 0.45;
pbr_ubo_data.roughness = 0.35;
// store in UBO
scene_ubo["PBR"] = &pbr_ubo_data;

SSBO

Now we want to use a SSBO to store all data for the 4 lights in the scene. Remember from the beginning that the fragment shader have the definition:

layout(std430, binding = 0) buffer layoutName {
    struct {
        vec3 position;
        vec3 color;
    } lights[];
};

We can use an array of structures to easily setup the data for our ssbo class:

ponos::AoS aos;
aos.pushField<vec3_16>("position");
aos.pushField<vec3_16>("color");
aos.resize(4);
// ... setup data and store
circe::gl::ShaderStorageBuffer ssbo = aos;

and remember to bind it before draw

mesh.program.use();
// ... update uniforms
ssbo.bind();
mesh.draw();

If we want to update our light parameters, we can map the buffer memory and modify it directly:

auto m = ssbo.memory()->mapped(GL_MAP_WRITE_BIT);
// update position (field 0) of the first light (index 0)
ssbo.descriptor.valueAt<vec3_16>(m, 0, 0) = {1.f, 1.f, 1.f};
// ... modify other fields and indices
ssbo.memory()->unmap();

Putting all together

Circe provides a quick way to setup a window application through a base class called BaseApp. To inherit it, we must override the render method:

class MyApp : public circe::gl::BaseApp {
public:
  MyApp() : BaseApp(WINDOW_WIDTH, WINDOW_HEIGHT) {
    // setup mesh
    // compile shader
    // setup UBO
    // setup SSBO
  }
  void render(circe::CameraInterface *camera) override {
    // update uniforms
    // bind ssbo
    // draw mesh
  }
  // scene
  circe::gl::SceneModel mesh;
  circe::gl::UniformBuffer scene_ubo;
  circe::gl::ShaderStorageBuffer scene_ssbo;
};

int main() { return MyApp().run(); }

Where

// setup mesh
auto include_normals = circe::shape_options::normal;
circe::gl::SceneModel mesh = 
            circe::Shapes::icosphere(5, include_normals);
// compile shader
if (!mesh.program.link("directory", "shader name"))
    std::cerr << "Failed to load model shader: " + mesh.program.err;
// setup UBO
struct alignas(16) PBR_UB {
    vec3_16 albedo;
    float metallic{};
    float roughness{};
    float ao{};
} pbr_ubo_data;
pbr_ubo_data.albedo = vec3_16(.5f, 0.f, 0.f);
pbr_ubo_data.ao = 1;
pbr_ubo_data.metallic = 0.45;
pbr_ubo_data.roughness = 0.35;
scene_ubo["PBR"] = &pbr_ubo_data;    
// setup SSBO
ponos::AoS aos;
aos.pushField<vec3_16>("position");
aos.pushField<vec3_16>("color");
aos.resize(4);
// light positions
aos.valueAt<vec3_16>(0, 0) = vec3_16(10, 10, 10);
aos.valueAt<vec3_16>(0, 1) = vec3_16(-10, -10, 10);
aos.valueAt<vec3_16>(0, 2) = vec3_16(-10, 10, 10);
aos.valueAt<vec3_16>(0, 3) = vec3_16(10, -10, 10);
// light colors
aos.valueAt<vec3_16>(1, 0) = vec3_16(300, 300, 300);
aos.valueAt<vec3_16>(1, 1) = vec3_16(300, 300, 300);
aos.valueAt<vec3_16>(1, 2) = vec3_16(300, 300, 300);
aos.valueAt<vec3_16>(1, 3) = vec3_16(300, 300, 300);
scene_ssbo = aos;
// update uniforms
mesh.program.use();
mesh.program.setUniform("view",
        ponos::transpose(camera->getViewTransform().matrix()));
mesh.program.setUniform("model", ponos::transpose(mesh.transform.matrix()));
mesh.program.setUniform("projection",
        ponos::transpose(camera->getProjectionTransform().matrix()));
mesh.program.setUniform("camPos", camera->getPosition());
// bind ssbo    
scene_ssbo.bind();
// draw mesh
mesh.draw();

You can find the source code here.

And that is the is the result: