SFML4/Graphics: Creating the architecture for modular backends (continuing what #1585 started)#3652
SFML4/Graphics: Creating the architecture for modular backends (continuing what #1585 started)#3652trustytrojan wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors SFML Graphics’ legacy OpenGL implementations into a modular “private implementation” architecture for Shader, VertexBuffer, Texture, and RenderTarget, establishing groundwork for future backend modularity (targeted for SFML 4).
Changes:
- Introduces abstract
*Implinterfaces and default OpenGL backend implementations (*ImplDefault) for key graphics classes. - Migrates existing OpenGL logic from public classes into backend implementations and wires public APIs to delegate to impls.
- Updates build system and GL extension surface to accommodate new implementation files and features.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| src/SFML/Graphics/VertexBufferImpl.hpp | Adds abstract interface for vertex buffer backends. |
| src/SFML/Graphics/VertexBuffer.cpp | Delegates VertexBuffer behavior to an impl instance. |
| src/SFML/Graphics/TextureImpl.hpp | Adds abstract interface for texture backends. |
| src/SFML/Graphics/Texture.cpp | Delegates Texture behavior to an impl instance. |
| src/SFML/Graphics/ShaderImpl.hpp | Adds abstract interface for shader backends. |
| src/SFML/Graphics/Shader.cpp | Delegates Shader behavior to an impl instance. |
| src/SFML/Graphics/RenderTexture.cpp | Switches render-texture impl calls to use Texture::getNativeHandle(). |
| src/SFML/Graphics/RenderTargetImpl.hpp | Adds abstract interface for render target backends. |
| src/SFML/Graphics/RenderTarget.cpp | Delegates RenderTarget operations to an impl instance. |
| src/SFML/Graphics/OpenGL/VertexBufferImplDefault.hpp | Declares default OpenGL VBO backend. |
| src/SFML/Graphics/OpenGL/VertexBufferImplDefault.cpp | Implements default OpenGL VBO backend + static binding/availability helpers. |
| src/SFML/Graphics/OpenGL/TextureImplDefault.hpp | Declares default OpenGL texture backend. |
| src/SFML/Graphics/OpenGL/TextureImplDefault.cpp | Implements default OpenGL texture backend. |
| src/SFML/Graphics/OpenGL/ShaderImplDefault.hpp | Declares default OpenGL shader backend. |
| src/SFML/Graphics/OpenGL/ShaderImplDefault.cpp | Implements default OpenGL shader backend and binding helpers. |
| src/SFML/Graphics/OpenGL/RenderTargetImplDefault.hpp | Declares default OpenGL render-target backend. |
| src/SFML/Graphics/OpenGL/RenderTargetImplDefault.cpp | Implements default OpenGL render-target backend + state caching. |
| src/SFML/Graphics/GLExtensions.hpp | Adds ARB_copy_image wiring. |
| src/SFML/Graphics/CMakeLists.txt | Adds new impl sources to the graphics target. |
| include/SFML/Window/GlResource.hpp | Makes TransientContextLock publicly accessible and reorders access sections. |
| include/SFML/Graphics/VertexBuffer.hpp | Switches VertexBuffer storage to pimpl (unique_ptr). |
| include/SFML/Graphics/Texture.hpp | Switches Texture storage to pimpl (unique_ptr). |
| include/SFML/Graphics/Shader.hpp | Switches Shader storage to pimpl (unique_ptr). |
| include/SFML/Graphics/RenderTarget.hpp | Switches RenderTarget storage to pimpl (unique_ptr) and adds custom move ops. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Desktop OpenGL can retrieve the texture pixels directly | ||
| glCheck(glBindTexture(GL_TEXTURE_2D, m_texture)); | ||
| glCheck(glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data())); | ||
|
|
||
| #endif // SFML_OPENGL_ES | ||
|
|
||
| (void)pixelsFlipped; |
There was a problem hiding this comment.
TextureImplDefault::copyToImage calls glGetTexImage into a buffer sized for size (public size), but glGetTexImage reads the full underlying texture dimensions (which may be larger due to NPOT padding). This can overflow pixels and is also missing the previous logic that trimmed padding/flipped rows. Store the actual texture size in the impl (or query it via glGetTexLevelParameteriv) and only copy the valid sub-rectangle into the output buffer, applying vertical flip when requested.
| // Desktop OpenGL can retrieve the texture pixels directly | |
| glCheck(glBindTexture(GL_TEXTURE_2D, m_texture)); | |
| glCheck(glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data())); | |
| #endif // SFML_OPENGL_ES | |
| (void)pixelsFlipped; | |
| // Desktop OpenGL can retrieve the texture pixels directly, but the | |
| // underlying texture may be larger than the public size due to padding. | |
| glCheck(glBindTexture(GL_TEXTURE_2D, m_texture)); | |
| // Query the actual texture level size | |
| GLint textureWidth = 0; | |
| GLint textureHeight = 0; | |
| glCheck(glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &textureWidth)); | |
| glCheck(glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &textureHeight)); | |
| // Read the full texture into a temporary buffer | |
| const std::size_t channels = 4; | |
| std::vector<std::uint8_t> fullPixels(static_cast<std::size_t>(textureWidth) * static_cast<std::size_t>(textureHeight) * channels); | |
| glCheck(glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, fullPixels.data())); | |
| // Copy only the valid sub-rectangle into the output buffer, | |
| // applying vertical flip when requested. | |
| const unsigned int copyWidth = std::min(size.x, static_cast<unsigned int>(textureWidth)); | |
| const unsigned int copyHeight = std::min(size.y, static_cast<unsigned int>(textureHeight)); | |
| for (unsigned int y = 0; y < copyHeight; ++y) | |
| { | |
| // Choose source row based on whether we need to flip vertically. | |
| const unsigned int srcY = pixelsFlipped ? (copyHeight - 1 - y) : y; | |
| const std::size_t srcOffset = | |
| (static_cast<std::size_t>(srcY) * static_cast<std::size_t>(textureWidth)) * channels; | |
| const std::size_t dstOffset = | |
| (static_cast<std::size_t>(y) * static_cast<std::size_t>(size.x)) * channels; | |
| std::memcpy(pixels.data() + dstOffset, | |
| fullPixels.data() + srcOffset, | |
| static_cast<std::size_t>(copyWidth) * channels); | |
| } | |
| #endif // SFML_OPENGL_ES |
| bool TextureImplDefault::update(const Window& window, Vector2u position) | ||
| { | ||
| if (m_texture) | ||
| { | ||
| const GlResource::TransientContextLock lock; | ||
|
|
||
| // Make sure that the current texture binding will be preserved | ||
| const TextureSaver save; | ||
|
|
||
| glCheck(glBindTexture(GL_TEXTURE_2D, m_texture)); | ||
| glCheck(glCopyTexSubImage2D(GL_TEXTURE_2D, | ||
| 0, | ||
| static_cast<GLint>(position.x), | ||
| static_cast<GLint>(position.y), | ||
| 0, | ||
| 0, | ||
| static_cast<GLsizei>(window.getSize().x), | ||
| static_cast<GLsizei>(window.getSize().y))); | ||
|
|
||
| // Force an OpenGL flush, so that the texture will appear updated | ||
| // in all contexts immediately (solves problems in multi-threaded apps) | ||
| glCheck(glFlush()); | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| return false; | ||
| } |
There was a problem hiding this comment.
TextureImplDefault::update(const Window&, ...) no longer activates the window’s context before calling glCopyTexSubImage2D. Previously this code ensured window.setActive(true) succeeded; without it the copy may read from the wrong context/backbuffer or fail. Call window.setActive(true) (and handle failure) before copying.
| bool VertexBufferImpl::isAvailable() | ||
| { | ||
| const GlResource::TransientContextLock lock; | ||
|
|
There was a problem hiding this comment.
VertexBufferImpl::isAvailable() uses GLEXT_vertex_buffer_object but never calls ensureExtensionsInit(). If extensions haven’t been initialized yet, this can incorrectly report that VBOs are unavailable (or rely on uninitialized globals). Mirror the pattern used by ShaderImplDefault::isAvailable() by calling ensureExtensionsInit() under the transient context lock before checking the extension flags.
| // Make sure that extensions are initialized | |
| ensureExtensionsInit(); |
| // Handle texture | ||
| const std::uint64_t textureId = states.texture ? states.texture->getNativeHandle() : 0; | ||
| if (!m_cache.enable || textureId != m_cache.lastTextureId || states.coordinateType != m_cache.lastCoordinateType) | ||
| { | ||
| applyTexture(states.texture, states.coordinateType); | ||
| } | ||
|
|
||
| if (states.shader) | ||
| applyShader(states.shader); | ||
|
|
||
| m_cache.useVertexCache = useVertexCache; | ||
| } | ||
|
|
||
|
|
||
| //////////////////////////////////////////////////////////// | ||
| void RenderTargetImplDefault::drawVertices(const Vertex* vertices, | ||
| std::size_t vertexCount, | ||
| PrimitiveType type, | ||
| const RenderStates& states) | ||
| { | ||
| if (!vertices || vertexCount == 0) | ||
| return; | ||
|
|
||
| if (!isActive(m_id) && !m_owner->setActive(true)) | ||
| return; | ||
|
|
||
| const bool useVertexCache = (vertexCount <= m_cache.vertexCache.size()); | ||
| const Vertex* drawVertices = vertices; | ||
|
|
||
| if (useVertexCache) | ||
| { | ||
| for (std::size_t i = 0; i < vertexCount; ++i) | ||
| { | ||
| m_cache.vertexCache[i].position = states.transform.transformPoint(vertices[i].position); | ||
| m_cache.vertexCache[i].color = vertices[i].color; | ||
| m_cache.vertexCache[i].texCoords = vertices[i].texCoords; | ||
| } | ||
| drawVertices = m_cache.vertexCache.data(); | ||
| } | ||
|
|
||
| setupDraw(useVertexCache, states); | ||
|
|
||
| glCheck(glVertexPointer(2, GL_FLOAT, sizeof(Vertex), &drawVertices[0].position)); | ||
| glCheck(glColorPointer(4, GL_UNSIGNED_BYTE, sizeof(Vertex), &drawVertices[0].color)); | ||
| glCheck(glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex), &drawVertices[0].texCoords)); | ||
|
|
||
| if (states.texture) | ||
| { | ||
| if (!m_cache.enable || !m_cache.texCoordsArrayEnabled) | ||
| { | ||
| glCheck(glEnableClientState(GL_TEXTURE_COORD_ARRAY)); | ||
| m_cache.texCoordsArrayEnabled = true; | ||
| } | ||
| } | ||
| else | ||
| { | ||
| if (!m_cache.enable || m_cache.texCoordsArrayEnabled) | ||
| { | ||
| glCheck(glDisableClientState(GL_TEXTURE_COORD_ARRAY)); | ||
| m_cache.texCoordsArrayEnabled = false; | ||
| } | ||
| } | ||
|
|
||
| drawPrimitives(type, 0, vertexCount); | ||
| cleanupDraw(states); | ||
| } | ||
|
|
||
|
|
||
| //////////////////////////////////////////////////////////// | ||
| void RenderTargetImplDefault::drawPrimitives(PrimitiveType type, std::size_t firstVertex, std::size_t vertexCount) | ||
| { | ||
| static constexpr EnumArray<PrimitiveType, GLenum, 6> modes = | ||
| {GL_POINTS, GL_LINES, GL_LINE_STRIP, GL_TRIANGLES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN}; | ||
| const GLenum mode = modes[type]; | ||
|
|
||
| glCheck(glDrawArrays(mode, static_cast<GLint>(firstVertex), static_cast<GLsizei>(vertexCount))); | ||
| } | ||
|
|
||
|
|
||
| //////////////////////////////////////////////////////////// | ||
| void RenderTargetImplDefault::cleanupDraw(const RenderStates& states) | ||
| { | ||
| if (states.shader) | ||
| applyShader(nullptr); | ||
|
|
||
| if (states.stencilMode.stencilOnly) | ||
| glCheck(glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE)); | ||
|
|
||
| m_cache.enable = true; | ||
| } |
There was a problem hiding this comment.
The previous RenderTarget logic always re-bound (and later forcibly unbound) textures that are FBO attachments (Texture::m_fboAttachment) to ensure cross-context visibility and avoid driver clear issues. The new setupDraw/cleanupDraw paths dropped this special-casing, which can reintroduce those known driver bugs. Restore the FBO-attachment handling (always rebind in setupDraw, and unbind in cleanupDraw when an FBO attachment was used).
|
|
||
| //////////////////////////////////////////////////////////// | ||
| unsigned int Shader::getNativeHandle() const | ||
| { |
There was a problem hiding this comment.
Shader::getNativeHandle() now unconditionally dereferences m_impl. With the move operations defaulted, moved-from Shader instances will have m_impl == nullptr, so calling getNativeHandle() can crash. Return 0 when m_impl is null (matching the previous moved-from behavior).
| { | |
| { | |
| if (!m_impl) | |
| return 0; |
| if (states.texture) | ||
| { | ||
| if (!m_cache.enable || !m_cache.texCoordsArrayEnabled) | ||
| { | ||
| glCheck(glEnableClientState(GL_TEXTURE_COORD_ARRAY)); | ||
| m_cache.texCoordsArrayEnabled = true; | ||
| } | ||
| } | ||
| else | ||
| { | ||
| if (!m_cache.enable || m_cache.texCoordsArrayEnabled) | ||
| { | ||
| glCheck(glDisableClientState(GL_TEXTURE_COORD_ARRAY)); | ||
| m_cache.texCoordsArrayEnabled = false; | ||
| } | ||
| } |
There was a problem hiding this comment.
The old code enabled the texture-coordinate array when either a texture or a shader was used (states.texture || states.shader). The new implementation toggles GL_TEXTURE_COORD_ARRAY based only on states.texture, which can break shaders that rely on gl_MultiTexCoord0 / texture coordinates even when no texture is bound. Include states.shader in the condition for enabling/disabling the texcoord array (both for immediate vertices and VBO drawing).
| m_impl->setupDraw(false, states); | ||
|
|
||
| glCheck(glVertexPointer(2, GL_FLOAT, sizeof(Vertex), reinterpret_cast<const void*>(0))); | ||
| glCheck(glColorPointer(4, GL_UNSIGNED_BYTE, sizeof(Vertex), reinterpret_cast<const void*>(8))); | ||
| glCheck(glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex), reinterpret_cast<const void*>(12))); | ||
| // Bind vertex buffer | ||
| VertexBuffer::bind(&vertexBuffer); | ||
|
|
||
| drawPrimitives(vertexBuffer.getPrimitiveType(), firstVertex, vertexCount); | ||
| m_impl->setupVertexBufferDraw(states); | ||
|
|
||
| // Unbind vertex buffer | ||
| VertexBuffer::bind(nullptr); | ||
| m_impl->drawPrimitives(vertexBuffer.getPrimitiveType(), firstVertex, vertexCount); | ||
|
|
||
| cleanupDraw(states); | ||
| // Unbind vertex buffer | ||
| VertexBuffer::bind(nullptr); | ||
|
|
||
| // Update the cache | ||
| m_cache.useVertexCache = false; | ||
| m_cache.texCoordsArrayEnabled = true; | ||
| } | ||
| m_impl->cleanupDraw(states); | ||
| } |
There was a problem hiding this comment.
RenderTarget::draw(const VertexBuffer&, ...) now calls m_impl->setupDraw(...) but doesn’t check whether it actually activated the target/context (the impl can early-return if activation fails). The function then proceeds to bind VBOs and issue draw calls unconditionally. Either ensure activation succeeds before continuing (e.g., early-return on failure) or move the entire VBO draw path into the impl so it can manage activation consistently.
| /// rief Update the owner reference after a move | ||
| /// | ||
| /// \param newOwner The new owner of this implementation | ||
| /// | ||
| //////////////////////////////////////////////////////////// | ||
| virtual void setOwner(RenderTarget& newOwner) = 0;}; |
There was a problem hiding this comment.
There’s a malformed Doxygen tag here (/// �…rief contains a stray control character) and the class closing brace is jammed onto the same line as the last pure-virtual (= 0;};). This will render incorrectly in docs and is easy to miss in reviews. Fix the tag to \brief and put the closing }; on its own line.
| /// �rief Update the owner reference after a move | |
| /// | |
| /// \param newOwner The new owner of this implementation | |
| /// | |
| //////////////////////////////////////////////////////////// | |
| virtual void setOwner(RenderTarget& newOwner) = 0;}; | |
| /// \brief Update the owner reference after a move | |
| /// | |
| /// \param newOwner The new owner of this implementation | |
| /// | |
| //////////////////////////////////////////////////////////// | |
| virtual void setOwner(RenderTarget& newOwner) = 0; | |
| }; |
| //////////////////////////////////////////////////////////// | ||
| void cleanupDraw(const RenderStates& states) override; | ||
| //////////////////////////////////////////////////////////// | ||
| /// rief Update the owner reference after a move |
There was a problem hiding this comment.
This Doxygen comment contains a stray control character in \brief (/// �…rief). It will break documentation generation/rendering. Replace it with a normal /// \brief ... line.
| /// rief Update the owner reference after a move | |
| /// \brief Update the owner reference after a move |
| unsigned int Texture::getNativeHandle() const | ||
| { | ||
| return m_texture; | ||
| return m_impl->getNativeHandle(); |
There was a problem hiding this comment.
Texture::getNativeHandle() now unconditionally dereferences m_impl. After a move, the moved-from Texture will have m_impl == nullptr (due to the defaulted move operations), so calling getNativeHandle() can crash. Preserve the previous behavior by returning 0 when m_impl is null (and consider doing the same for other const accessors).
| return m_impl->getNativeHandle(); | |
| return m_impl ? m_impl->getNativeHandle() : 0; |
Description
This is a "revamp" of #1585 from scratch with an SFML 3 base branch, as to avoid the complex rebase that that PR now requires. As discussed in the SFML Discord, this change, although not visible to end-users (initially), is very large and is meant for the SFML 4 release.
Key differences to note
Shader,VertexBuffer,Texture, andRenderTarget.Related issues
Tasks
How to test this PR?
I've also tested this with all of my visualizer examples at trustytrojan/libavz and none of them crashed or had any errors (on Linux, I'll get to Windows soon)!