I still teach OpenGL ES 3.0

Hacker News by 20 min read 128x views
I still teach OpenGL ES 3.0

Share Post

Little C++ OpenGL ES 3.0 example compiled to the web alongside emscripten

Introduction

It’s been a few years that I have been giving this Introduction to Computer Graphics component (a trimester) at SAE Institute Geneva and I have continually been wondering if and whenever I would need to passage my way from OpenGL ES 3.0 to additional contemporary API.

Back in November, I was at the Graphics Programming Conference (my blog article here), and this topic reemerged for me during the conversations alongside group from the industry as fine as from the talks. From the Teardown conversation (all the conference’s talks are accessible on the website), the first equivalent worked in OpenGL 3.3 and from the X-Plane talk, the rendering motor was using OpenGL 2.1. But the two those talks were concerning how they transitioned to additional contemporary API to have improved graphics. This appears akin the sunset of OpenGL.

Two talks really caught my notice concerning learning for Computer Graphics: the conversation Bridging Pixels and Code: Teaching Computer Graphics to Technical Artists from Matthieu Delaere that concentration on students creating their own rasterizer from scratch and the conversation from Mike Shah that focuses on the next steps following the tutorial. The second conversation really made me think concerning if the genuine idea to do the passage to contemporary API for my way made motionless sense.

What have to be taken into document for a passage is that all the courses need to be translated to this new API/framework and updated depending on the new idea added. So if there is a passage to be made, it needs to be really value it difference to OpenGL ES 3.0.

The way content

The goal of the introduction in device graphics component is for the students to execute a 3d environment alongside a graphics API. What is crucial to comprehend is that they should not execute a generic 3d renderer, but specifically a 3d scene, so the final executable is not expected to be data-driven. For example, it would be entirely ok to have specified implementation:

void DrawScene(const Pipeline& pipeline, const Camera& camera) { //Cull the environment from the camera POV //Draw all 3d entity by hand } 

Since whenever I started to provision this module, I have been following the learnopengl.com construction but using OpenGL ES 3.0 instead. Each division is akin a catalog of samples that have to be implemented and tested (Hello Triangle, Hello Texture, etc…)

But why specifically this type of OpenGL? OpenGL ES 3.0 is a mobile graphics API, released in August 2012, so concerning 14 years ago (which is a LONG period in device graphics). I chose it since it is the most cross-platform type of OpenGL in my opinion, alongside this type you can run the identical C++ program (with several #ifdef) and the identical shaders for:

  • Windows: during I encountered several controller issues throughout the year, OpenGL ES 3.0 is compatible alongside OpenGL 4.3, so I fair assume OpenGL 4.3 on Windows not to worry.
  • Linux: several of my workstations footwear on Linux by default, and it is fine custom to compile the identical C++ code alongside another compiler.
  • Android: this one is a bit complex as the genuine android app boots from Java and calls the native lib containing the code.
  • Nintendo Switch: we have two devkits at academy and apart from removing glew or glad and replacing it alongside the embedding OpenGL wrapper, it plant fine.
  • WebGL2: my favorite logic of using OpenGL ES 3.0 is that it is compatible alongside WebGL2 alongside emscripten (except drift color attachment that is an extension…), which allows my students to harbor their samples to their own website/blog. It is the case alongside this blog article anywhere I merely put the demo out of emscripten on the web page.
  • iOS or MacOSX: unfortunately not supported anymore, but none of my students activity on a Mac as this is a Games Programming bachelor.

The academy devkit alongside the rotating cubes running

The identical demo but operating on the Switch

Compare to additional contemporary API, synchronization is taken attention of by the controller in OpenGL. This allows to skip most of the setup of a lot of boilerplate initialization. This is nice, but is paid alongside the disbursal of the controller overhead added for this.

However, among the hassles of the archetypal way is having the students’ laptop chooses the incorrect GPU on laptop (using the Intel unified GPU alternatively of the Nvidia GPU). Last year, you fair needed to set the entire Nvidia configuration to be on “Performant” mode, but alongside a new Windows update, you need to set individually the use that is to be performant in the Windows settings, not the Nvidia settings anymore. Also, there is this trick for Windows:

#ifdef __cplusplus extern "C" { #endif  __declspec(dllexport) DWORD NvOptimusEnablement = 1; __declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; #ifdef __cplusplus } #endif 

And this (supposedely) enables the discrete GPU and not the unified one (I am blessed this is a solved issue in contemporary API akin Vulkan). Let’s see how we tackle another subjects!

Core concepts

Every week, the students need to complete concerning 2-3 samples on a particular characteristic (for example: execute a example that uses SSAO using the former idea from the deferred rendering sample). When operating on their samples, the students normally commencement to build their own abstractions. This year, we decided to activity together and build the idea in a average git repository specified that those idea could be used by all the students of the category as shortly as it was implemented. This is item that I desire to continue in the next years.

Pipeline

I really dislike OpenGL naming… Pipelines are called program, so whenever my students comes from learnopengl.com, they name their pipelines shaderProgram. Anyway, we created an idea called Pipeline that loads alongside a vertex shader and a part shader (also a particular abstraction) that looks akin this:

class Shader { public: //shader mark being GL_VERTEX_SHADER or GL_FRAGMENT_SHADER void Load(std::string_view path, GLenum shader_target); //[...] class Pipeline { public: void Load(const Shader& vertex_shader, const Shader& fragment_shader); //[...] 

Shaders are “compiled” alongside glslangValidator that I configure alongside CMake. This allows to inspect for syntax errors during compile-time alternatively of seeing a weird error in the run-time compilation of the glsl shader by the driver.

Because of OpenGL implementation of uniform buffer, we added a bunch of function to set those uniforms and to recover the uniform locations (and cache them for forthcoming retrieval):

class Pipeline { public: //[...] void SetInt(std::string_view uniform_name, int new_value); void SetFloat(std::string_view uniform_name, float new_value); template<typename T> requires core::IsVector3<T, float> void SetVec3(const std::string_view uniform_name, const T& v) { const GLint loc = GetUniformLocation(uniform_name); glUniform3f(loc, v.x, v.y, v.z); } private: GLint GetUniformLocation(std::string_view name); std::unordered_map<std::string, GLint, StringHash, StringEqual> uniform_location_map_; 

Several things to unpack here:

  • SetVec3 uses the idea IsVector3 that inspect if the category has the three attributes x, y, z at the accurate type.
  • StringHash and StringEqual allows the use of std::string_view without the need to create a std::string whenever trying to discover a uniform location in the map.

Vertex inputs

One of the weird item for me going into OpenGL is how recollection is allocated (the glGen, glBind, and afterward upload the data and likely uplaod at the identical time). For example, for a vertex input buffer:

int vbo_; glGenBuffers(vboCount, &vbo_); glBindBuffer(GL_ARRAY_BUFFER, vbo_); glBufferData(GL_ARRAY_BUFFER, vertex_buffer.size, vertex_buffer.data, GL_STATIC_DRAW); 

For vertex inputs, you have those buffers that are to be setup in a Vertex Array Object, kind of akin a receptacle of vertex buffers, to define how the pipeline is going to peruse the vertex buffers, alongside item akin this:

int vao_; glGenVertexArrays(1, &vao_); glBindVertexArray(vao_); glBindBuffer(GL_ARRAY_BUFFER, vbo_); glVertexAttribPointer( vertex_input_location, // in the shader (location = 0)  vertexAttribData.size, // how many of the primitive: drift => 1, vec2 => 2, vec3 => 3, vec4 => 4 vertexAttribData.type, GL_FALSE, vertexAttribData.stride, (void*)vertexAttribData.offset); glEnableVertexAttribArray(vertexAttribData.index); //when using instancing glVertexDivisor(vertexAttribData.index, 1); 

All this is distracted in the VertexInput category alongside the Buffer abstraction:

 template<GLenum target> class Buffer { public: void Bind() const; void Load(); /** * @brief Allocate and upload the data in the buffer */ template<typename T> void UploadRange(std::span<const T> range, GLenum usage = GL_STATIC_DRAW); /** * @brief Update the data in the buffer (to be used following using UploadRange */ template <typename T> void UpdateRange(std::span<const T> range, GLintptr offset = 0, GLenum usage = GL_STATIC_DRAW); }; using VertexBuffer = Buffer<GL_ARRAY_BUFFER>; using IndexBuffer = Buffer<GL_ELEMENT_ARRAY_BUFFER>; struct VertexBufferAttribute { GLuint location; GLint size; GLenum type; GLsizei stride; size_t offset; bool is_instanced = false; }; class VertexInput { public: void Load(); void BindVertexBuffer(const VertexBuffer& vbo, std::span<const VertexBufferAttribute> attributes); void BindIndexBuffer(const IndexBuffer& ebo); void Bind(); }; 
  • VertexBufferAttribute::is_instanced allows Buffer to be binded as per case buffer as we don’t have assistance for SSBO in OpenGL ES 3.0 (section below).

Textures

The logic for texture in OpenGL is counter-intuitive. One has to comprehend the difference between texture name (the grip stated by glGenTextures) and the texture component (GL_TEXTURE0 for example). It confuses a lot of my students all year. So binding a texture to a uniform example looks akin this:

glUniform1i(uniform_location, 0); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE2D, my_texture); 

This is put in a function in the pipeline to appearance this:

pipeline_.SetTexture("uniform_name", my_texture, 0); //with implementation looking akin this: void Pipeline::SetTexture(std::string_view uniform_name, const Texture& texture, int texture_unit) { const car uniformLocation = GetUniformLocation(uniformName); glUniform1i(uniformLocation, textureUnit); glActiveTexture(GL_TEXTURE0 + textureUnit); texture.Bind(); } 

For loading, in the commencement of the module, we commencement alongside stb_image to burden JPG, PNG, BMP, etc… This header-only archive decompresses the images specified that we can upload them to the GPU, item that looks akin this:

image.pixels = stbi_load(imagePath.data(), &image.width, &image.height, &image.comp, 0); glGenTextures(1, &my_texture); glBindTexture(GL_TEXTURE2D, name_); switch (image.comp) { case 3: glTexImage2D(GL_TEXTURE2D, 0, GL_RGB, image.width, image.height, 0, GL_RGB, GL_UNSIGNED_BYTE, image.pixels); break; case 4: glTexImage2D(GL_TEXTURE2D, 0, GL_RGBA, image.width, image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image.pixels); break; 

All this is way hidden in a Texture idea alongside Load and Bind. Then for cubemaps, we add:

class Texture { public: //[...] void LoadCubemaps(std::span<const std::string_view, 6> paths); } 

The Texture holds the “target” (GL_TEXTURE2D or GL_TEXTURE_CUBE_MAP, why not texture3d?) and afterward the Bind volition tie the accurate target.

Model Loading

We use Assimp as it simplifies the setup of the loading of 3d models and additionally allows to import and export in a lot of distinct formats. This would appearance akin this:

 void Model::LoadModel(std::string_view path) { Assimp::Importer importer; environment = importer.ReadFile(path.data(), aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_GenNormals | aiProcess_CalcTangentSpace); if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode) { ... } ProcessNode(scene->mRootNode, scene); } void Model::ProcessNode(aiNode* node, const aiScene* scene) { // procedure all the node's meshes (if any) for (unsigned int i = 0; i < node->mNumMeshes; i++) { aiMesh* mesh = scene->mMeshes[node->mMeshes[i]]; meshes_.push_back(ProcessMesh(mesh, scene)); } // afterward do the identical for all of its children for (unsigned int i = 0; i < node->mNumChildren; i++) { ProcessNode(node->mChildren[i], scene); } } Mesh Model::ProcessMesh(aiMesh* mesh, const aiScene* scene) { // Processing the meshes 

It is additionally used in the identical way in learnopengl.com, so the students can have a operating implementation to inspect whenever they encounter a mistake.

Draw commands

In OpenGL, the controller is adding our commands into its own command buffer. This forbids multi-threading diagram commands generation (compare to additional contemporary API) and it gives this emotion that example happens immediately. Because of learnopengl.com, most of my students put their diagram call in the Mesh contained in their Model. So it looks akin this:

void Model::Draw(const Pipeline& pipeline) { pipeline.Bind(); for(auto& mesh: meshes) { mesh.Draw(pipeline); } } void Mesh::Draw(const Pipeline& pipeline) { material_.Bind(pipeline); //binding all textures to their samples glDrawElements(GL_TRIANGLES, indices_count, GL_UNSIGNED_INT, nullptr); //or depending on the abstraction vao_.Draw(); } 

After operating on my device graphics publishing company (I wrote a blog article here), I really favor a divided method anywhere a environment has materials (pipeline + texture reference) and meshes, and that models do not be as environment abstraction. However again, it is not concerning making a data-driven renderer, but fair execute a 3d scene.

Render passes & Framebuffer

Finally, the final big OpenGL center idea is concerning multi passes. The archetypal fractional of the component is spent in one pass. Post-processing is the archetypal example of multi-pass, anywhere my students have to use a framebuffer to diagram their scene, and afterward use another pipeline and the render targets as a sampled texture. The archetypal example is multiple variations of post-processing (like grayscale, inversing the color). Setting a certain framebuffer in OpenGL is starting a new render continue (RenderDoc volition normally compose it as Color Pass). With the students, we worked out an Framebuffer idea that use a FramebufferCreateInfo struct (very akin to Vulkan) alongside the color attachments and the degree color:

 struct ColorAttachmentInfo { GLenum mark = GL_TEXTURE_2D; GLint internal_format = GL_RGB8; bool is_active = false; }; struct DepthAttachmentInfo { GLenum mark = GL_TEXTURE_2D; GLenum internal_format = GL_DEPTH24_STENCIL8; bool is_active = false; bool is_rbo = true; }; struct FramebufferCreateInfo { //OpenGL has maximum 16 color attachments std::array<ColorAttachmentInfo, 16> color_attachment_infos{}; DepthAttachmentInfo depth_stencil_attachment_info{}; core::Vec2I size{}; }; class Framebuffer { void Load(const FramebufferCreateInfo& info); void Bind(); static void BindBackbuffer(); const Texture& color_attachments(int index); const Texture& depth_attachment(); }; 
  • RBO (render buffer object, yet another category of buffers) is kind of akin a GPU write-only benevolent of resource.

Usually, we desire one or multiple color attachments as Texture and a depth-buffer as RBO, apart from for a shade pass, anywhere we desire a depth-only continue (that outputs to a Texture).

A big stage in using framebuffer in the component comes alongside the introduction to HDR (High-Dynamic Range), basically the fact that we can use color in a bigger range than [0 - 255]. One of specified applications is deferred rendering and the innovation of G-Buffer (Geometry Buffer that contains position, normals and baseColor for example) to minimize the amount of lighting calculation per pixel. So first, you render a color continue on a G-Buffer alongside multiple color attachments all containing crucial geometry value (position, normal, basis color) and afterward there is a ray continue anywhere lighting is calculated per pixel. This method see a huge achievement betterment whenever example a lot of lights on the screen. With our abstraction, you fair need to use a HDR category of inner format (for example GL_RGB16F).

Of course, not all detect outputs HDR, so we need a way to go rear to LDR (Low-Dynamic Range, [0 - 255]) and this is done at the end of the lighting continue part shader:

//Reinhard tonemapping vec3 result = lighting / (lighting + vec3(1.0)); FragColor = vec4(result, 1.0); 

Missing contemporary OpenGL features in ES 3.0

Using OpenGL ES 3.0 (to authorize use of WebGL 2.0) has motionless a disbursal though. Some center concepts of contemporary rendering are explained in way satisfied but not implemented (except if the students desire to get rid of WebGL2 compatibility).

SSBO

As of OpenGL 4.3 and OpenGL ES 3.1, we can define shader retention buffer object. This is helpful to eliminate the need to continue by the vertex inputs whenever doing instancing. Of course, we need to had another category of buffer that can be binded to an uniform. This would appearance akin this:

//in C++ glBufferData(GL_SHADER_STORAGE_BUFFER, instancing_buffer.size, instancing_buffer.data, GL_DYNAMIC_DRAW); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, binding_point, ssbo); //in shader layout(std430, binding = 3) buffer layoutName { vec4 data_SSBO[]; }; void main() { vec4 instancing_data = data_SSBO[gl_InstanceID]; } 

Compute shader

Compute shader lone appeared in OpenGL ES 3.1 (so not WebGL2). Coming rear to the post-processing example explained before, alternatively of giving the color attachment of the environment as a sampler2D, we could provision it as an image2D and really do the post-processing in compute. Typically, all the-whole-screen benevolent of shader are easier to execute alongside a compute pipeline alongside a sole shader, fairly than a graphics pipeline alongside a aircraft in NDC (Normal Device Coordinate) going from [-1, 1] fair to regain the entire display in the vertex shader and implementing the transformation in the part shader.

SPIR-V

Starting alongside OpenGL 4.6 (not equal accessible in OpenGL ES 3.2), we can burden SPIR-V alternatively of plain content GLSL. This authorize for quick loading of shader as the format is binary and it allows to add a compilation stage to shaders (something we kind of do anyhow alongside glslandValidator).

Bindless

Modern OpenGL desktop allows for “bindless” rendering anywhere textures are not binded to a texture component but can be referenced via a 64-bit grip stored in a buffer (enabling admission to practically an unlimited textures without costly province changes) alongside the expansion ARB_bindless_texture (supported on Nvidia and AMD, but not on Intel).

For buffers, ARB_buffer_storage (core in OpenGL 4.4 and as an expansion EXT_buffer_storage since OpenGL ES 3.1) allows to keep the GPU pointer to enables persistent mapped buffers, avoiding repeated map/unmap cycles.

This is not accessible in OpenGL ES 3.0, so the workarounds are normally texture atlases or texture arrays, blended alongside batching (draw calls alongside distinct vertex inputs, but the identical textures) to evade the costly toggle of textures.

Indirect Drawing

Another mighty characteristic that is unavailable in OpenGL ES 3.0 is indirect drawing. In our module, the CPU is issuing all the diagram calls alongside glDrawArrays and glDrawElements or their instanced versions. glDrawArraysIndirect and glDrawElementsIndirect are part of center since OpenGL 4.0 and OpenGL ES 3.1 allowing diagram parameters to arrive from a GL_DRAW_INDIRECT_BUFFER. More power comes alongside Multi-Draw Indirect (core in OpenGL 4.3 and expansion EXT_multi_draw_indirect since OpenGL ES 3.1), which allows through glMultiDrawArraysIndirect and glMultiDrawElementsIndirect calls to dispatch complete arrays of diagram commands which blended alongside compute shaders that enables completely GPU-driven rendering pipelines anywhere the GPU performs frustum culling, LOD selection, and occlusion culling. ARB_indirect_parameters allows equal the diagram figure itself be GPU-determined.

Debugging

For my students, what used to be the most period they were spending was alongside RenderDoc. Now I see additional students fair using LLM to inspect their code and see what is incorrect (even one of my students who stated “they told me there was an error there”). However, the exam explicitly forbids LLM to solve, so my students motionless have to go through the debugging procedure by themselves if they desire to pass.

Starting alongside OpenGL 4.3, KHR_debug is part of center and allows to have the controller sends error, warning, debug messages through a callback. As OpenGL 4.3 is completely compatible, I realized during penning this blog article that we could use it on Desktop to have improved debug capabilities (registering OpenGL objects alongside glObjectLabel alongside a macro for example).

Missing features from Modern API

Sticking alongside OpenGL method additionally missing a lot concerning newer features of contemporary API akin Vulkan and DX12. Of course, contemporary API are additional than fair features, they are additionally another mental model. But let’s concentration on the features.

Raytracing

With all their promotion on RTX, Nvidia certainly brought raytracing in all gamer’s mouth whenever talking concerning device graphics. Of course, raytracing exists since before the advent of graphics card. However in 2018, DXR was released, allowing for hardware raytracing on the GPU using new benevolent of shaders (closest-hit, miss, raygen, etc…) and Vulkan followed alongside the Nvidia expansion and afterward the generic extension.

Most games are motionless living in a hybrid rendering earth anywhere several parts are done in rasterization and several are done in raytracing, alongside tradition options for players. In its State of GPU Hardware, Dmytro “Boolka” Bulatov mentions that lone games akin Indiana Jones and the Great Circle (released in delayed 2024) that puts DXR as a requirement. So for me, as a teacher, I motionless showcase the hardware raytracing API, its advantages and disadvantages, and how the forthcoming looks like, but as it is an introduction to device graphics, I can’t ask all my students to have to execute a hardware raytracing pipeline.

Multithreaded rendering

When thinking concerning contemporary CPU architecture, we are living in a multi-core earth now, but most of what we program in games is motionless happening in one thread on one core. To really unlock the power of our CPU, we can distinct our activity between multiple threads. A average form is to have a threading example alongside Game/Cull/Render on the CPU, or having the loading done async and those motionless activity alongside OpenGL.

However, certain games (like Halo Infinite) necessitate the use of multiple threads to create all the GPU commands. This is certainly imaginable alongside DX12 and Vulkan, but not in OpenGL anywhere the render states are in the controller implementation in one thread.

The results

Here are several of the plant of my students during this component complete the years:

What concerning a framework?

I affection SDL GPU API. I am currently porting my device graphics publishing company to it, and it allows to mark Vulkan, DirectX and Metal at the identical time. However, there is motionless a bit of bloat for an introduction way for me to use SDL GPU. I heard several fine things concerning bgfx additionally and raylib as well. WebGPU, on top of adding a new shader language, is extremely akin to Vulkan 1.0 that caused me additional ache that joy.

When having students operating on their samples and they do a error in OpenGL, it can frequently be beautiful apparent why there is a mistake, but facing a framework, the code of the archive has additionally to be taken into account. And my goal is to instruct them a graphics API and build abstractions on top of it.

Conclusion

OpenGL ES 3.0 has motionless a lot of advantages to be taught equal in 2026 (especially WebGL2), at the disbursal of going through the battle of the bequest of this API. 14 years is a lot of period in Computer Graphics. For example, at the end of his graphics API blog post, Adam Sawicki does not propose OpenGL as a choice (as it is rarely used anymore).

This way has allowed throughout the years all of my students to detect how graphics programming work. For several of them, it was their archetypal stage in their device graphics occupation and I am glad that I helped them in that. I desire to compose another blog article concerning why we are not yet switching to Vulkan (talking concerning synchronization, PSO, controller overhead), as I have a few things to say concerning this particular API. Don’t hesitate to comment on the social media or fair dispatch me an e-mail (I am curious to cognize how another teachers are doing and what group operating in the industry desire to see from graphics programming students). Here the rotating cubes demo operating on Android:

Showing rotating cubes on Android

Other Article Hacker News
↑
Close Right Ads
Close Left Ads