sincerely
Singaporean
If you have not done so, read this full tutorial on how to use SGEXTN to build an application.
See here for the previous part of the tutorial.
In the previous part, we built the functionality of using previously saved presets.
In this part of the tutorial, we will be writing shaders and using the SG - RI system to actually make the display page display stuff.
SG - RI lets you use shaders really easily and also integrates into SGWidget ⁽㈳㈴㈳㈮㈱㈨㈠㈫ ㈧㈤㈱㈤⁾. Before we continue, first read through this tutorial here.
Now that you have read through the tutorial, we can start programming. Firstly, we have to decide how each pattern will be displayed.
For the circle pattern, we will use a texture for the circle itself and do processing on the fragment shader to see if a pixel is inside the circle. This demonstrates the use of textures and also writing fragment shader code.
For the polygon and star patterns, we will have the actual geometry of the shapes encoded via vertices to demonstrate the generation and usage of the vertex buffer object and the fragment buffer object.
For the fractal pattern, we will render a Mandelbrot set. Obviously this must be done on the fragment shader.
We write code to display the circle first.
First we write the vertex shader.
#version 310 es precision highp float; precision highp int; layout(std140, binding = 0) uniform SG_RI_builtin_{ float x; float y; float width; float height; float windowWidth; float windowHeight; int offscreen; } SG_RI_builtin; vec4 SG_RI_transform(vec4 prelimPosition){ prelimPosition = vec4(2.0f * (prelimPosition.x * SG_RI_builtin.width / SG_RI_builtin.windowWidth + SG_RI_builtin.x / SG_RI_builtin.windowWidth) - 1.0f, -2.0f * (prelimPosition.y * SG_RI_builtin.height / SG_RI_builtin.windowHeight + SG_RI_builtin.y / SG_RI_builtin.windowHeight) + 1.0f, prelimPosition.z, prelimPosition.w); if(SG_RI_builtin.offscreen != 0){prelimPosition = vec4(prelimPosition.x, -1.0f * prelimPosition.y, prelimPosition.z, prelimPosition.w);} return prelimPosition; } layout(location = 0) in vec2 vertex; layout(location = 0) out vec2 vertexCoords; void main(){ gl_Position = vec4(vertex.x, vertex.y, 0.0f, 1.0f); vertexCoords = vec2(2.0f * vertex.x - 1.0f, 2.0f * vertex.y - 1.0f); if(SG_RI_builtin.width > SG_RI_builtin.height){vertexCoords = vec2(vertexCoords.x * SG_RI_builtin.width / SG_RI_builtin.height, vertexCoords.y);} else{vertexCoords = vec2(vertexCoords.x, vertexCoords.y * SG_RI_builtin.height / SG_RI_builtin.width);} gl_Position = SG_RI_transform(gl_Position); }
The vertex shader takes input from the element buffer object a vec2 called vertex. It then applies transforms to change it to vertexCoords, which is sent to the fragment shader to determine how to colour each pixel.
vertexCoords is in a coordinate system centered at the center of the screen, with the minimum of the screen width and the screen height being 2 units long. This makes it easier to draw the circle, since it will just be centered at the origin with radius 0.75.
The vertex shader also directly passes the vertex variable into gl_Position which decides where the vertex should be displayed on screen. Combined with the data that we will put in the vertex buffer object later, this will create a full screen quad.
The fragment shader is also really simple.
#version 310 es precision highp float; precision highp int; layout(std140, binding = 1) uniform data_{ vec4 backgroundColour; } data; layout(binding = 0) uniform sampler2D textureSampler; layout(location = 0) in vec2 vertexCoords; layout(location = 0) out vec4 outColour; void main(){ if(vertexCoords.x * vertexCoords.x + vertexCoords.y * vertexCoords.y > 0.75f * 0.75f){outColour = data.backgroundColour;} else{outColour = texture(textureSampler, vertexCoords);} }
It simply checks if the pixel coordinate is inside the circle. If inside the circle, the fragment shader colours it using the image. Otherwise it uses the background colour.
To ensure that BuildLah ⁽㈳㈴㈳㈮㈱㈨㈠㈫ ㈧㈤㈱㈤⁾ recognises these shaders, we have to put them inside the shaders/ folder, as shaders/circle.vert and shaders/circle.frag respectively.
With the GLSL side done, we proceed to work on the C++ side.
First we make 2 files, include/SGCLPCircleDisplay.h and src/SGCLPCircleDisplay.cpp
We create a new class, SGCLPCircleDisplay, for the SG - RI powered renderer that will display the circle. Inside the class, apart from implementing all the pure virtual functions from SGRBaseRenderer, we also need to store the background colour, the vertex buffer object, the element buffer object, and the image texture.
class SGCLPCircleDisplay : public SGRBaseRenderer { public: SGCLPCircleDisplay(SGXColourRGBA bg); SGXColourRGBA backgroundColour; SGRRenderingProgramme* createRenderingProgramme() override; void initialise() override; void cleanResourcesOnDestruction() override; void uploadShaderData() override; void requestRenderCommands(SGRCommandRequest* commandRequest) override; SGRVertexBufferObject* vbo; SGRElementBufferObject* ebo; SGRImage* textureImage; };
Note that SGRBaseRenderer does NOT own the vertex buffer object and the fragment buffer object. These must be managed manually.
Inside src/SGCLPCircleDisplay.cpp we can implement all the functions on SGCLPCircleDisplay.
Firstly, we write the constructor which sets the background colour.
SGCLPCircleDisplay::SGCLPCircleDisplay(SGXColourRGBA bg){ (*this).backgroundColour = bg; (*this).vbo = nullptr; (*this).ebo = nullptr; (*this).textureImage = nullptr; }
The destructor also has nothing interesting. It just frees all the memory.
void SGCLPCircleDisplay::cleanResourcesOnDestruction(){ delete vbo; delete ebo; delete textureImage; }
The implementation of SGRBaseRenderer::createRenderingProgramme is much more interesting.
SGRRenderingProgramme* SGCLPCircleDisplay::createRenderingProgramme(){ SGRRenderingProgramme* rp = new SGRRenderingProgramme(this); (*rp).setShaderQSBFiles(":/ColoursPlusPlus/circle.vert.qsb", ":/ColoursPlusPlus/circle.frag.qsb"); (*rp).addUniformBufferObject(16, 1); (*rp).addTexture(0); (*rp).finaliseShaderResource(); (*rp).addVertexBufferObject(2 * 4); (*rp).addVertexProperty(0, 0, 0, SGRGraphicsLanguageType::Float, 2); (*rp).finaliseVertices(); (*rp).finaliseRenderingProgramme(); return rp; }
Here we provide information about how the shader will behave. Firstly, we use SGRRenderingProgramme::setShaderQSBFiles to provide the GLSL code.
Then we add a uniform buffer object of size 16 at binding point 1. 16 bytes is just enough to contain 1 vec4 for the background colour, and the binding point of 1 matches our declaration in GLSL.
layout(std140, binding = 1) uniform data_{ ... } data;
We also add a texture at binding point 0. Again note how the binding point of 0 matches the GLSL declaration of the texture sampler.
layout(binding = 0) uniform sampler2D textureSampler;
Once the uniform buffer object and texture are set up, we create a slot for a vertex buffer object using SGRRenderingProgramme::addVertexBufferObject. The argument of 2 * 4 indicates that 8 bytes (2 floats x 4 bytes per float) are used per vertex. This is just enough to contain a vec2, which is our coordinates from the vertex buffer object.
SGRRenderingProgramme::addVertexProperty is then used to tell the GPU that the 8 bytes of data is a vec2.
After the SGRRenderingProgramme (or rendering pipeline) is set up, we return a pointer to it which SG - RI internally uses for further processing.
We then can implement initialisation of the display.
void SGCLPCircleDisplay::initialise(){ vbo = new SGRVertexBufferObject(this, 4 * 2 * 4); SGLArray<float> vt(0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f); (*renderingProgramme()).updateDataBuffer(vbo, 0, 4 * 2 * 4, vt.pointerToData(0)); ebo = new SGRElementBufferObject(this, 2 * 3 * 4); SGLArray<int> et(0, 1, 2, 1, 2, 3); (*renderingProgramme()).updateDataBuffer(ebo, 0, 2 * 3 * 4, et.pointerToData(0)); textureImage = new SGRImage(":/ColoursPlusPlus/conjugates.png"); (*renderingProgramme()).updateTexture(0, textureImage); SGLArray<float> ut(backgroundColour.getRedAsFloat(), backgroundColour.getGreenAsFloat(), backgroundColour.getBlueAsFloat(), backgroundColour.getTransparencyAsFloat()); (*renderingProgramme()).updateShaderUniforms(1, 0, 16, ut.pointerToData(0)); }
Here we create the vertex buffer object, element buffer object, and texture. We then upload these to the GPU. Since these do not change throughout the rendering process, they are created and uploaded once inside SGRBaseRenderer::initialise and not touched inside SGRBaseRenderer::uploadShaderData.
Note that the values used for the vertex buffer object and element buffer object here will give a fullscreen quad in SG - RI.
The image is a random image I found on my computer, anything will work as long as it is inside the assets/ folder so BuildLah ⁽㈳㈴㈳㈮㈱㈨㈠㈫ ㈧㈤㈱㈤⁾ will include it.
Since the background colour does not change, we can also write the uniform buffer object here with the background colour data. However note that in most shader setups, uniforms constantly update and will need to be written to every frame in SGRBaseRenderer::uploadShaderData.
Lastly we tell the GPU what to do every frame.
void SGCLPCircleDisplay::uploadShaderData(){ } void SGCLPCircleDisplay::requestRenderCommands(SGRCommandRequest *commandRequest){ (*commandRequest).addVertexBufferObject(vbo, 0); (*commandRequest).chooseElementBufferObject(ebo); (*commandRequest).finaliseForDraw(); (*commandRequest).drawTriangles(2, 0); }
Since there is no per frame changing data, SGRBaseRenderer::uploadShaderData does literally nothing.
SGRBaseRenderer::requestRenderCommands binds the vertex buffer object and element buffer object before asking the GPU to draw 2 triangles to give a fullscreen quad. Note that the vertex buffer object and element buffer object must be selected every frame in SG - RI. This is different from the behaviour in OpenGL.
To actually display the renderer on screen, we need to create a SGRRendererWidget that uses it. We can add this line of code
if(SGCLPOptionsPage::chosenPattern == SGCLPOptionsPage::Circle){new SGRRendererWidget(bg, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, -1.0f, new SGCLPCircleDisplay(SGCLPOptionsPage::chosenBackgroundColour), nullptr);}
to the end of SGCLPDisplayPage::initialise so that the application creates the renderer when showing the display page.
With the code done, we can run our app again. As expected, display the circle works perfectly. We can then run a clang-tidy check and commit everything onto GitHub.
See here for the next part of the tutorial.
©2025 05524F.sg (Singapore)