Skip to content

SFML4/Graphics: Creating the architecture for modular backends (continuing what #1585 started)#3652

Open
trustytrojan wants to merge 2 commits into
SFML:masterfrom
trustytrojan:modular-graphics-backend
Open

SFML4/Graphics: Creating the architecture for modular backends (continuing what #1585 started)#3652
trustytrojan wants to merge 2 commits into
SFML:masterfrom
trustytrojan:modular-graphics-backend

Conversation

@trustytrojan
Copy link
Copy Markdown
Contributor

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

  • I simply took the architectural philosophy that Modular rendering backends #1585 had and implemented the private-implementation pattern for the same four classes that Modular rendering backends #1585 also worked on: Shader, VertexBuffer, Texture, and RenderTarget.
  • No new graphics backends were implemented here. I am not an OpenGL expert, so I did not write any new OpenGL code. I simply moved the existing legacy OpenGL code into private-implementations. Future PRs can use the architecture setup by this one to easily add newer OpenGL implementations.

Related issues

Tasks

  • Tested on Linux
  • Tested on Windows
  • Tested on macOS
  • Tested on iOS
  • Tested on Android

How to test this PR?

  • Pass the unit tests
  • Run the examples
  • Pass CI

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)!

Copilot AI review requested due to automatic review settings January 28, 2026 02:25
trustytrojan added a commit to trustytrojan/libavz that referenced this pull request Jan 28, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 *Impl interfaces 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.

Comment on lines +387 to +393
// 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;
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
Comment on lines +228 to +255
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;
}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
bool VertexBufferImpl::isAvailable()
{
const GlResource::TransientContextLock lock;

Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// Make sure that extensions are initialized
ensureExtensionsInit();

Copilot uses AI. Check for mistakes.
Comment on lines +586 to +675
// 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;
}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

////////////////////////////////////////////////////////////
unsigned int Shader::getNativeHandle() const
{
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
{
{
if (!m_impl)
return 0;

Copilot uses AI. Check for mistakes.
Comment on lines +632 to +647
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;
}
}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +236 to 249
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);
}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +215 to +220
/// rief Update the owner reference after a move
///
/// \param newOwner The new owner of this implementation
///
////////////////////////////////////////////////////////////
virtual void setOwner(RenderTarget& newOwner) = 0;};
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// �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;
};

Copilot uses AI. Check for mistakes.
////////////////////////////////////////////////////////////
void cleanupDraw(const RenderStates& states) override;
////////////////////////////////////////////////////////////
/// rief Update the owner reference after a move
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Doxygen comment contains a stray control character in \brief (/// �…rief). It will break documentation generation/rendering. Replace it with a normal /// \brief ... line.

Suggested change
/// rief Update the owner reference after a move
/// \brief Update the owner reference after a move

Copilot uses AI. Check for mistakes.
unsigned int Texture::getNativeHandle() const
{
return m_texture;
return m_impl->getNativeHandle();
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
return m_impl->getNativeHandle();
return m_impl ? m_impl->getNativeHandle() : 0;

Copilot uses AI. Check for mistakes.
@trustytrojan trustytrojan changed the title Graphics: Creating the architecture for modular backends (continuing what #1585 started) SFML4/Graphics: Creating the architecture for modular backends (continuing what #1585 started) Feb 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants