Skip to content

Conversation

@revmischa
Copy link
Member

Adds a callback mechanism for loading textures from non-filesystem sources.

Applications can register a callback via projectm_set_texture_load_event_callback()
to intercept texture loading requests. The callback can provide texture data in two ways:

  1. Raw pixel data (RGBA/RGB) with width, height, and channel count
  2. An existing OpenGL texture ID

If the callback doesn't provide data, the normal filesystem-based loading continues.

This is useful for:

  • Embedding textures in application resources
  • Loading textures from archives or databases
  • Procedurally generating textures

Fixes #870

Adds a callback that allows applications to provide textures from
non-filesystem sources like archives, network, or procedurally generated
content. The callback receives the texture name and can return either
raw pixel data or an existing OpenGL texture ID.

Fixes #870
Move TextureLoadData and TextureLoadCallback from TextureManager.hpp
to a separate TextureTypes.hpp header. This allows ProjectM.hpp to
include only the necessary types without pulling in internal
dependencies that aren't installed.
Copy link

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

Adds a texture-loading callback pathway so applications can satisfy preset texture requests from non-filesystem sources (embedded resources, archives, procedural generation, etc.), with a C API entry point and propagation into the renderer’s TextureManager.

Changes:

  • Introduces Renderer::TextureLoadCallback / TextureLoadData types for callback-based texture provision.
  • Extends Renderer::TextureManager to consult the callback before falling back to filesystem loading.
  • Adds C API + wrapper plumbing (projectm_set_texture_load_event_callback) and propagates callback through ProjectM / texture manager recreation.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/libprojectM/Renderer/TextureTypes.hpp Adds callback/data types for custom texture loading.
src/libprojectM/Renderer/TextureManager.hpp Exposes callback setter and stores callback.
src/libprojectM/Renderer/TextureManager.cpp Invokes callback-first loading path (texture ID or pixel data) before filesystem scan.
src/libprojectM/ProjectMCWrapper.hpp Stores C callback + user-data in wrapper instance.
src/libprojectM/ProjectMCWrapper.cpp Implements projectm_set_texture_load_event_callback and bridges C->C++ callback.
src/libprojectM/ProjectM.hpp Exposes SetTextureLoadCallback on C++ API and stores callback.
src/libprojectM/ProjectM.cpp Propagates callback into newly created TextureManager instances.
src/libprojectM/CMakeLists.txt Installs new public headers for the C++ interface.
src/api/include/projectM-4/callbacks.h Adds C API types and registration function for texture-load callback.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +197 to +213
int channels = static_cast<int>(loadData.channels > 0 ? loadData.channels : 4);

unsigned int tex = SOIL_create_OGL_texture(
loadData.data,
&width, &height, channels,
SOIL_CREATE_NEW_ID,
SOIL_FLAG_MULTIPLY_ALPHA);

if (tex != 0)
{
uint32_t memoryBytes = width * height * channels;
auto newTexture = std::make_shared<Texture>(unqualifiedName, tex, GL_TEXTURE_2D, width, height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (pixel data)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
}
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

loadData.channels is passed straight through to SOIL_create_OGL_texture(). If a callback accidentally supplies a value other than 3 or 4, SOIL/OpenGL behavior is undefined. Please validate channels (accept only 3/4, or map 0 to 4) and fall back to filesystem loading (or log a warning) when an unsupported channel count is provided.

Suggested change
int channels = static_cast<int>(loadData.channels > 0 ? loadData.channels : 4);
unsigned int tex = SOIL_create_OGL_texture(
loadData.data,
&width, &height, channels,
SOIL_CREATE_NEW_ID,
SOIL_FLAG_MULTIPLY_ALPHA);
if (tex != 0)
{
uint32_t memoryBytes = width * height * channels;
auto newTexture = std::make_shared<Texture>(unqualifiedName, tex, GL_TEXTURE_2D, width, height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (pixel data)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
}
// Validate channel count: accept only 3/4, or map 0 to 4. Fall back to filesystem loading otherwise.
int channels = 4;
bool validChannels = true;
if (loadData.channels == 0)
{
channels = 4;
}
else if (loadData.channels == 3 || loadData.channels == 4)
{
channels = static_cast<int>(loadData.channels);
}
else
{
LOG_WARN("[TextureManager] Texture load callback for \"" + unqualifiedName
+ "\" returned unsupported channel count (" + std::to_string(loadData.channels)
+ "); falling back to filesystem loading.");
validChannels = false;
}
if (validChannels)
{
unsigned int tex = SOIL_create_OGL_texture(
loadData.data,
&width, &height, channels,
SOIL_CREATE_NEW_ID,
SOIL_FLAG_MULTIPLY_ALPHA);
if (tex != 0)
{
uint32_t memoryBytes = width * height * channels;
auto newTexture = std::make_shared<Texture>(unqualifiedName, tex, GL_TEXTURE_2D, width, height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (pixel data)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +130 to +131
* @note If providing raw pixel data, the data pointer must remain valid until
* projectM has finished processing it (i.e., until the callback returns).
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The doc says raw pixel data only needs to remain valid "until the callback returns", but the implementation consumes data after the callback returns (the upload happens in TextureManager::TryLoadingTexture after invoking the callback). This mismatch can cause use-after-free if callers provide stack/temporary buffers as the comment suggests. Please update the documentation to match the actual lifetime requirement (e.g., data must outlive the callback invocation and remain valid until projectM finishes creating the GL texture), or adjust the API/implementation so projectM consumes/copies the pixel data before returning to the caller.

Suggested change
* @note If providing raw pixel data, the data pointer must remain valid until
* projectM has finished processing it (i.e., until the callback returns).
* @note If providing raw pixel data, the data pointer must remain valid beyond the
* lifetime of the callback and remain valid until projectM has finished creating
* and uploading the corresponding GL texture. Do not use stack-allocated or other
* short-lived buffers unless their lifetime covers this entire period.

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +111
typedef struct projectm_texture_load_data {
const unsigned char* data; /**< Pointer to raw pixel data (RGBA/RGB format, bottom-to-top). Can be NULL. */
unsigned int width; /**< Width of the texture in pixels. */
unsigned int height; /**< Height of the texture in pixels. */
unsigned int channels; /**< Number of color channels (3 for RGB, 4 for RGBA). */
unsigned int texture_id; /**< An existing OpenGL texture ID to use. Set to 0 if not used. */
} projectm_texture_load_data;
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The texture_id field doesn’t specify ownership semantics (whether projectM will delete the GL texture or treat it as externally owned). Given the callback use case (e.g., video frames), this needs to be explicitly documented, and the implementation should match (see Texture destructor behavior). Please clarify ownership and, if the texture is externally owned, ensure projectM does not call glDeleteTextures() for callback-provided IDs.

Copilot uses AI. Check for mistakes.
Comment on lines +183 to +189
auto newTexture = std::make_shared<Texture>(unqualifiedName, loadData.textureId,
GL_TEXTURE_2D, loadData.width, loadData.height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
uint32_t memoryBytes = loadData.width * loadData.height * (loadData.channels > 0 ? loadData.channels : 4);
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (texture ID)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

When the callback provides textureId, this wraps it in Renderer::Texture, whose destructor always calls glDeleteTextures(). That means projectM will delete an application-provided texture ID when the texture is purged or the instance is destroyed. Please either (a) add a non-owning texture wrapper/flag for externally-managed IDs, or (b) explicitly document and enforce that ownership is transferred to projectM (and ensure callers can safely manage lifetime).

Suggested change
auto newTexture = std::make_shared<Texture>(unqualifiedName, loadData.textureId,
GL_TEXTURE_2D, loadData.width, loadData.height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
uint32_t memoryBytes = loadData.width * loadData.height * (loadData.channels > 0 ? loadData.channels : 4);
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (texture ID)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
// The provided textureId is owned by the application. To avoid
// projectM deleting an externally managed texture, clone its
// contents into a new texture that is owned by projectM.
int width = static_cast<int>(loadData.width);
int height = static_cast<int>(loadData.height);
int channels = static_cast<int>(loadData.channels > 0 ? loadData.channels : 4);
if (width > 0 && height > 0 && channels > 0)
{
std::vector<unsigned char> pixels(static_cast<std::size_t>(width) *
static_cast<std::size_t>(height) *
static_cast<std::size_t>(channels));
GLenum format = GL_RGBA;
if (channels == 1)
{
format = GL_RED;
}
else if (channels == 3)
{
format = GL_RGB;
}
glBindTexture(GL_TEXTURE_2D, loadData.textureId);
glGetTexImage(GL_TEXTURE_2D, 0, format, GL_UNSIGNED_BYTE, pixels.data());
unsigned int tex = SOIL_create_OGL_texture(
pixels.data(),
&width, &height, channels,
SOIL_CREATE_NEW_ID,
SOIL_FLAG_MULTIPLY_ALPHA);
if (tex != 0)
{
uint32_t memoryBytes = static_cast<uint32_t>(width) *
static_cast<uint32_t>(height) *
static_cast<uint32_t>(channels);
auto newTexture = std::make_shared<Texture>(unqualifiedName, tex,
GL_TEXTURE_2D, width, height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (cloned texture ID)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +183 to +189
auto newTexture = std::make_shared<Texture>(unqualifiedName, loadData.textureId,
GL_TEXTURE_2D, loadData.width, loadData.height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
uint32_t memoryBytes = loadData.width * loadData.height * (loadData.channels > 0 ? loadData.channels : 4);
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (texture ID)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

For the textureId callback path, loadData.width/height are used to construct the Texture and estimate memory, but they are not validated and may be left as 0 by the callback. If the size is required elsewhere, this can lead to incorrect behavior. Consider requiring non-zero width/height in the API contract (and ignoring the callback result otherwise), or querying the texture size via glGetTexLevelParameteriv when not provided.

Suggested change
auto newTexture = std::make_shared<Texture>(unqualifiedName, loadData.textureId,
GL_TEXTURE_2D, loadData.width, loadData.height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
uint32_t memoryBytes = loadData.width * loadData.height * (loadData.channels > 0 ? loadData.channels : 4);
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (texture ID)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
// Ensure we have valid texture dimensions. If the callback did not provide them,
// attempt to query the size from OpenGL.
GLint texWidth = static_cast<GLint>(loadData.width);
GLint texHeight = static_cast<GLint>(loadData.height);
if (texWidth <= 0 || texHeight <= 0)
{
GLint prevBinding = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevBinding);
glBindTexture(GL_TEXTURE_2D, loadData.textureId);
glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &texWidth);
glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &texHeight);
// Restore previous binding
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(prevBinding));
}
if (texWidth > 0 && texHeight > 0)
{
uint32_t width = static_cast<uint32_t>(texWidth);
uint32_t height = static_cast<uint32_t>(texHeight);
uint32_t channels = (loadData.channels > 0 ? loadData.channels : 4);
auto newTexture = std::make_shared<Texture>(unqualifiedName, loadData.textureId,
GL_TEXTURE_2D, width, height, true);
m_textures[lowerCaseUnqualifiedName] = newTexture;
uint32_t memoryBytes = width * height * channels;
m_textureStats.insert({lowerCaseUnqualifiedName, {memoryBytes}});
LOG_DEBUG("[TextureManager] Loaded texture \"" + unqualifiedName + "\" from callback (texture ID)");
return {newTexture, m_samplers.at({wrapMode, filterMode}), name, unqualifiedName};
}
else
{
LOG_WARN("[TextureManager] Texture callback provided texture ID without valid size for \"" + unqualifiedName + "\"; ignoring callback texture ID.");
}

Copilot uses AI. Check for mistakes.
@revmischa revmischa closed this Jan 25, 2026
@revmischa revmischa reopened this Jan 25, 2026
@revmischa revmischa closed this Jan 25, 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.

[FEATURE] Add callback to API to support loading textures from non-filesystem sources

1 participant