From 9eeef1d4bd1f932fe3406fde18e5f5522bdc298b Mon Sep 17 00:00:00 2001 From: Nicholas Sharp Date: Sun, 3 May 2026 18:08:48 -0700 Subject: [PATCH] add capacity semantics to ManagedBuffer --- include/polyscope/render/engine.h | 4 + include/polyscope/render/managed_buffer.h | 55 +++++++ .../render/mock_opengl/mock_gl_engine.h | 1 + include/polyscope/render/opengl/gl_engine.h | 1 + src/render/managed_buffer.cpp | 138 +++++++++++++++++- src/render/mock_opengl/mock_gl_engine.cpp | 19 ++- src/render/opengl/gl_engine.cpp | 31 ++-- 7 files changed, 226 insertions(+), 23 deletions(-) diff --git a/include/polyscope/render/engine.h b/include/polyscope/render/engine.h index 38349f97..f0ba8959 100644 --- a/include/polyscope/render/engine.h +++ b/include/polyscope/render/engine.h @@ -96,6 +96,10 @@ class AttributeBuffer { virtual void setData(const std::vector>& data) = 0; virtual void setData(const std::vector>& data) = 0; + // Pre-allocate GPU memory for n elements without uploading any data. Subsequent setData() + // calls with size <= n will not need to reallocate the underlying buffer. + virtual void reserveCapacity(size_t n) = 0; + virtual uint32_t getNativeBufferID() = 0; // used to interop with external things, e.g. ImGui // == Getters diff --git a/include/polyscope/render/managed_buffer.h b/include/polyscope/render/managed_buffer.h index 26b97e63..d4b4e524 100644 --- a/include/polyscope/render/managed_buffer.h +++ b/include/polyscope/render/managed_buffer.h @@ -98,6 +98,56 @@ class ManagedBuffer : public virtual WeakReferrable { void setTextureSize(uint32_t sizeX, uint32_t sizeY, uint32_t sizeZ); std::array getTextureSize() const; + // == Resize / capacity API + + // The .size() of the buffer is the number of data elements it holds. + // + // The .capacity() of the buffer is the number of data elements it has capacity for without needing to reallocate + // (similar to std::vector). + // + // In a simple flow where we put data in a buffer once, the capacity is the same as the size and neither ever changes. + // But in settings where we e.g. incrementally add elements or change the number of data elements on each frame, we + // may want to allocate a larger capacity to avoid expensive re-allocation each time. + + // Resize the buffer to newSize elements. + // + // If newSize <= capacity(), this is a cheap constant-time operation which just updates metadata. + // + // If newSize > capacity(), this triggers a full reallocation. The new capacity is set to at least + // 2*oldCapacity (amortized doubling). Any data that was GPU-resident is first copied back to the + // host, then the GPU buffer is reallocated in-place (same buffer object, new backing memory). + // ShaderPrograms holding a reference to the GPU buffer remain valid. + // + // Returns true if a reallocation occurred, false if the resize stayed within capacity. + // + // Valid for attributes and 1D textures only; call the 2D/3D variants below for multidimensional + // textures. + bool resize(size_t newSize); + + // Resize a 2D texture. No-op (returns false) if dimensions are unchanged. Otherwise always + // triggers a reallocation (2D/3D textures have no capacity slack — capacity always equals size), + // reallocates the GPU buffer in-place, and returns true. + bool resizeTexture2D(uint32_t newSizeX, uint32_t newSizeY); + + // Resize a 3D texture. No-op (returns false) if dimensions are unchanged. Otherwise always + // triggers a reallocation (2D/3D textures have no capacity slack — capacity always equals size), + // reallocates the GPU buffer in-place, and returns true. + bool resizeTexture3D(uint32_t newSizeX, uint32_t newSizeY, uint32_t newSizeZ); + + // The maximum size the buffer can be resized to without triggering a reallocation. Always >= size(). + size_t capacity(); + + // Set the managed capacity to newCapacity. Unlike resize(), which grows capacity via amortized + // doubling, this sets the logical capacity to a precise value. Error if newCapacity < size(). + // Reallocates the GPU buffer in-place (same buffer object, new backing memory) if one exists. + // + // Note: data.capacity() is guaranteed to be >= managedCapacity, but may remain larger than + // newCapacity if the underlying vector already had more space allocated. + // + // Valid for attributes and 1D textures only; multidimensional textures always have capacity + // equal to their size, so use the 2D/3D resize() variants instead. + void setCapacity(size_t newCapacity); + // == Members for indexed data @@ -200,6 +250,11 @@ class ManagedBuffer : public virtual WeakReferrable { bool hostBufferIsPopulated; // true if the host buffer contains currently-valid data + // The buffer has capacity for at least this many elements. It is distinct from .size(), which is the actual number of + // elements currently stored in the buffer and may be smaller than the capacity. + // Any resize() operations that stay within the capacity are cheap, and do not trigger a full reallocation and copy. + size_t managedCapacity = 0; + std::shared_ptr renderAttributeBuffer; std::shared_ptr renderTextureBuffer; diff --git a/include/polyscope/render/mock_opengl/mock_gl_engine.h b/include/polyscope/render/mock_opengl/mock_gl_engine.h index cc4d2d49..b135564a 100644 --- a/include/polyscope/render/mock_opengl/mock_gl_engine.h +++ b/include/polyscope/render/mock_opengl/mock_gl_engine.h @@ -73,6 +73,7 @@ class GLAttributeBuffer : public AttributeBuffer { std::vector getDataRange_uvec4(size_t ind, size_t count) override; uint32_t getNativeBufferID() override; + void reserveCapacity(size_t n) override; protected: private: diff --git a/include/polyscope/render/opengl/gl_engine.h b/include/polyscope/render/opengl/gl_engine.h index 7e06d358..ef5ce764 100644 --- a/include/polyscope/render/opengl/gl_engine.h +++ b/include/polyscope/render/opengl/gl_engine.h @@ -101,6 +101,7 @@ class GLAttributeBuffer : public AttributeBuffer { std::vector getDataRange_uvec4(size_t ind, size_t count) override; uint32_t getNativeBufferID() override; + void reserveCapacity(size_t n) override; protected: VertexBufferHandle VBOLoc; diff --git a/src/render/managed_buffer.cpp b/src/render/managed_buffer.cpp index 67dbc7f2..14032ac6 100644 --- a/src/render/managed_buffer.cpp +++ b/src/render/managed_buffer.cpp @@ -18,7 +18,7 @@ namespace render { template ManagedBuffer::ManagedBuffer(ManagedBufferRegistry* registry_, const std::string& name_, std::vector& data_) : name(name_), uniqueID(internal::getNextUniqueID()), registry(registry_), data(data_), dataGetsComputed(false), - hostBufferIsPopulated(true) { + hostBufferIsPopulated(true), managedCapacity(data_.size()) { if (registry) { registry->addManagedBuffer(this); @@ -30,7 +30,7 @@ template ManagedBuffer::ManagedBuffer(ManagedBufferRegistry* registry_, const std::string& name_, std::vector& data_, std::function computeFunc_) : name(name_), uniqueID(internal::getNextUniqueID()), registry(registry_), data(data_), dataGetsComputed(true), - computeFunc(computeFunc_), hostBufferIsPopulated(false) { + computeFunc(computeFunc_), hostBufferIsPopulated(false), managedCapacity(0) { if (registry) { registry->addManagedBuffer(this); @@ -56,6 +56,8 @@ void ManagedBuffer::setTextureSize(uint32_t sizeX_) { deviceBufferType = DeviceBufferType::Texture1d; sizeX = sizeX_; + // Sync managedCapacity: data is expected to be populated before setTextureSize() is called. + if (data.size() > managedCapacity) managedCapacity = data.size(); } template @@ -66,6 +68,7 @@ void ManagedBuffer::setTextureSize(uint32_t sizeX_, uint32_t sizeY_) { deviceBufferType = DeviceBufferType::Texture2d; sizeX = sizeX_; sizeY = sizeY_; + if (data.size() > managedCapacity) managedCapacity = data.size(); } template @@ -77,6 +80,7 @@ void ManagedBuffer::setTextureSize(uint32_t sizeX_, uint32_t sizeY_, uint32_t sizeX = sizeX_; sizeY = sizeY_; sizeZ = sizeZ_; + if (data.size() > managedCapacity) managedCapacity = data.size(); } template @@ -85,6 +89,126 @@ std::array ManagedBuffer::getTextureSize() const { return std::array{sizeX, sizeY, sizeZ}; } +template +size_t ManagedBuffer::capacity() { + return managedCapacity; +} + +template +bool ManagedBuffer::resize(size_t newSize) { + if (deviceBufferType == DeviceBufferType::Texture2d || deviceBufferType == DeviceBufferType::Texture3d) + exception("resize() is not valid for 2D/3D texture buffers; use resizeTexture2D() or resizeTexture3D()"); + + if (newSize > managedCapacity) { + // Copy device-side data back to host BEFORE modifying data or invalidating the GPU buffer. + // ensureHostBufferPopulated() reads from renderAttributeBuffer into data; if we resize data + // first it would overwrite the resize with the old GPU contents. + ensureHostBufferPopulated(); + + // Reallocation needed: use amortized doubling + size_t newCapacity = std::max(newSize, 2 * managedCapacity); + data.reserve(newCapacity); + data.resize(newSize); + managedCapacity = newCapacity; + + // In-place reallocation: keep the same GPU buffer objects alive so ShaderPrograms + // holding shared_ptr references to them remain valid without needing to be reset. + if (renderAttributeBuffer) { + renderAttributeBuffer->reserveCapacity(managedCapacity); + } + if (renderTextureBuffer && deviceBufferType == DeviceBufferType::Texture1d) { + renderTextureBuffer->resize(static_cast(managedCapacity)); + } + hostBufferIsPopulated = true; + + if (deviceBufferType == DeviceBufferType::Texture1d) { + sizeX = static_cast(newSize); + } + + return true; + } else { + // No reallocation: just update size metadata + data.resize(newSize); + + if (deviceBufferType == DeviceBufferType::Texture1d) { + sizeX = static_cast(newSize); + } + + return false; + } +} + +template +void ManagedBuffer::setCapacity(size_t newCapacity) { + if (deviceBufferType == DeviceBufferType::Texture2d || deviceBufferType == DeviceBufferType::Texture3d) + exception("setCapacity() is not valid for 2D/3D texture buffers"); + + if (newCapacity < data.size()) + exception("setCapacity() cannot set capacity below current size (" + std::to_string(data.size()) + ")"); + + if (newCapacity == managedCapacity) return; // no-op + + // Before invalidating the GPU buffer, copy any device-side changes back to the host. + ensureHostBufferPopulated(); + + data.reserve(newCapacity); + managedCapacity = newCapacity; + + // In-place reallocation: keep the same GPU buffer objects alive. + if (renderAttributeBuffer) { + renderAttributeBuffer->reserveCapacity(managedCapacity); + } + if (renderTextureBuffer && deviceBufferType == DeviceBufferType::Texture1d) { + renderTextureBuffer->resize(static_cast(managedCapacity)); + } + hostBufferIsPopulated = true; +} + +template +bool ManagedBuffer::resizeTexture2D(uint32_t newSizeX, uint32_t newSizeY) { + checkDeviceBufferTypeIs(DeviceBufferType::Texture2d); + + if (newSizeX == sizeX && newSizeY == sizeY) return false; // no-op + + // Before invalidating the GPU buffer, copy any device-side changes back to the host. + ensureHostBufferPopulated(); + + sizeX = newSizeX; + sizeY = newSizeY; + managedCapacity = static_cast(sizeX) * sizeY; + data.resize(managedCapacity); + + if (renderTextureBuffer) { + renderTextureBuffer->resize(sizeX, sizeY); + } + hostBufferIsPopulated = true; + + return true; +} + +template +bool ManagedBuffer::resizeTexture3D(uint32_t newSizeX, uint32_t newSizeY, uint32_t newSizeZ) { + checkDeviceBufferTypeIs(DeviceBufferType::Texture3d); + + if (newSizeX == sizeX && newSizeY == sizeY && newSizeZ == sizeZ) return false; // no-op + + // Before invalidating the GPU buffer, copy any device-side changes back to the host. + ensureHostBufferPopulated(); + + sizeX = newSizeX; + sizeY = newSizeY; + sizeZ = newSizeZ; + managedCapacity = static_cast(sizeX) * sizeY * sizeZ; + data.resize(managedCapacity); + + if (renderTextureBuffer) { + renderTextureBuffer->resize(sizeX, sizeY, sizeZ); + } + hostBufferIsPopulated = true; + + return true; +} + template void ManagedBuffer::ensureHostBufferPopulated() { @@ -312,7 +436,11 @@ std::shared_ptr ManagedBuffer::getRenderAttributeBuf if (!renderAttributeBuffer) { ensureHostBufferPopulated(); // warning: the order of these matters because of how hostBufferPopulated works + // Sync managedCapacity on first GPU allocation: handles the case where data was populated + // externally before this call (e.g. class member ordering caused empty-vector-at-construction). + if (data.size() > managedCapacity) managedCapacity = data.size(); renderAttributeBuffer = generateAttributeBuffer(render::engine); + renderAttributeBuffer->reserveCapacity(managedCapacity); renderAttributeBuffer->setData(data); } return renderAttributeBuffer; @@ -324,6 +452,8 @@ std::shared_ptr ManagedBuffer::getRenderTextureBuffer( if (!renderTextureBuffer) { ensureHostBufferPopulated(); // warning: the order of these matters because of how hostBufferPopulated works + // Sync managedCapacity on first GPU allocation (same rationale as getRenderAttributeBuffer) + if (data.size() > managedCapacity) managedCapacity = data.size(); renderTextureBuffer = generateTextureBuffer(deviceBufferType, render::engine); @@ -333,7 +463,9 @@ std::shared_ptr ManagedBuffer::getRenderTextureBuffer( exception("bad call"); break; case DeviceBufferType::Texture1d: - renderTextureBuffer->resize(sizeX); + // Allocate GPU texture at the full managed capacity so future within-capacity resizes + // don't require a new GPU buffer. setData() below uploads only data.size() elements. + renderTextureBuffer->resize(static_cast(managedCapacity)); break; case DeviceBufferType::Texture2d: renderTextureBuffer->resize(sizeX, sizeY); diff --git a/src/render/mock_opengl/mock_gl_engine.cpp b/src/render/mock_opengl/mock_gl_engine.cpp index ef4877e2..a27f0ff8 100644 --- a/src/render/mock_opengl/mock_gl_engine.cpp +++ b/src/render/mock_opengl/mock_gl_engine.cpp @@ -73,19 +73,22 @@ void GLAttributeBuffer::checkArray(int testArrayCount) { } +void GLAttributeBuffer::reserveCapacity(size_t n) { + if (n <= bufferSize) return; + bufferSize = static_cast(n); + setFlag = true; +} + template void GLAttributeBuffer::setData_helper(const std::vector& data) { bind(); - // allocate if needed if (!isSet() || data.size() > bufferSize) { setFlag = true; - uint64_t newSize = data.size(); - newSize = std::max(newSize, 2 * bufferSize); // if we're expanding, at-least double + uint64_t newSize = static_cast(data.size()); bufferSize = newSize; } - // do the actual copy dataSize = data.size(); checkGLError(); @@ -411,7 +414,7 @@ void GLTextureBuffer::setData(const std::vector& data) { bind(); - if (data.size() != getTotalSize()) { + if (data.size() > getTotalSize()) { exception("OpenGL error: texture buffer data is not the right size."); } @@ -431,7 +434,7 @@ void GLTextureBuffer::setData(const std::vector& data) { bind(); - if (data.size() != getTotalSize()) { + if (data.size() > getTotalSize()) { exception("OpenGL error: texture buffer data is not the right size."); } @@ -450,7 +453,7 @@ void GLTextureBuffer::setData(const std::vector& data) { void GLTextureBuffer::setData(const std::vector& data) { bind(); - if (data.size() != getTotalSize()) { + if (data.size() > getTotalSize()) { exception("OpenGL error: texture buffer data is not the right size."); } @@ -469,7 +472,7 @@ void GLTextureBuffer::setData(const std::vector& data) { void GLTextureBuffer::setData(const std::vector& data) { bind(); - if (data.size() != getTotalSize()) { + if (data.size() > getTotalSize()) { exception("OpenGL error: texture buffer data is not the right size."); } diff --git a/src/render/opengl/gl_engine.cpp b/src/render/opengl/gl_engine.cpp index 608f0133..fe4d80dd 100644 --- a/src/render/opengl/gl_engine.cpp +++ b/src/render/opengl/gl_engine.cpp @@ -246,21 +246,26 @@ void GLAttributeBuffer::checkArray(int testArrayCount) { GLenum GLAttributeBuffer::getTarget() { return GL_ARRAY_BUFFER; } +void GLAttributeBuffer::reserveCapacity(size_t n) { + if (n <= bufferSize) return; + bind(); + glBufferData(getTarget(), n * sizeInBytes(dataType) * getArrayCount(), NULL, GL_STATIC_DRAW); + bufferSize = static_cast(n); + setFlag = true; + checkGLError(); +} template void GLAttributeBuffer::setData_helper(const std::vector& data) { bind(); - // allocate if needed if (!isSet() || data.size() > bufferSize) { setFlag = true; - uint64_t newSize = data.size(); - newSize = std::max(newSize, 2 * bufferSize); // if we're expanding, at-least double + uint64_t newSize = static_cast(data.size()); glBufferData(getTarget(), newSize * sizeof(T), NULL, GL_STATIC_DRAW); bufferSize = newSize; } - // do the actual copy dataSize = data.size(); glBufferSubData(getTarget(), 0, dataSize * sizeof(T), data.data()); @@ -614,13 +619,15 @@ void GLTextureBuffer::setData(const std::vector& data) { bind(); - if (data.size() != getTotalSize()) { + // For 1D textures, data.size() may be <= sizeX (partial upload into a capacity-allocated texture). + // For 2D/3D textures, capacity always equals size so the check is effectively ==. + if (data.size() > getTotalSize()) { exception("OpenGL error: texture buffer data is not the right size."); } switch (dim) { case 1: - glTexSubImage1D(GL_TEXTURE_1D, 0, 0, sizeX, formatF(format), type(format), &data.front().x); + glTexSubImage1D(GL_TEXTURE_1D, 0, 0, static_cast(data.size()), formatF(format), type(format), &data.front().x); break; case 2: glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, sizeX, sizeY, formatF(format), type(format), &data.front().x); @@ -637,13 +644,13 @@ void GLTextureBuffer::setData(const std::vector& data) { bind(); - if (data.size() != getTotalSize()) { + if (data.size() > getTotalSize()) { exception("OpenGL error: texture buffer data is not the right size."); } switch (dim) { case 1: - glTexSubImage1D(GL_TEXTURE_1D, 0, 0, sizeX, formatF(format), type(format), &data.front().x); + glTexSubImage1D(GL_TEXTURE_1D, 0, 0, static_cast(data.size()), formatF(format), type(format), &data.front().x); break; case 2: glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, sizeX, sizeY, formatF(format), type(format), &data.front().x); @@ -659,13 +666,13 @@ void GLTextureBuffer::setData(const std::vector& data) { void GLTextureBuffer::setData(const std::vector& data) { bind(); - if (data.size() != getTotalSize()) { + if (data.size() > getTotalSize()) { exception("OpenGL error: texture buffer data is not the right size."); } switch (dim) { case 1: - glTexSubImage1D(GL_TEXTURE_1D, 0, 0, sizeX, formatF(format), type(format), &data.front()); + glTexSubImage1D(GL_TEXTURE_1D, 0, 0, static_cast(data.size()), formatF(format), type(format), &data.front()); break; case 2: glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, sizeX, sizeY, formatF(format), type(format), &data.front()); @@ -689,13 +696,13 @@ void GLTextureBuffer::setData(const std::vector& data) { bind(); - if (data.size() != getTotalSize()) { + if (data.size() > getTotalSize()) { exception("OpenGL error: texture buffer data is not the right size."); } switch (dim) { case 1: - glTexSubImage1D(GL_TEXTURE_1D, 0, 0, sizeX, formatF(format), type(format), &dataFloat.front()); + glTexSubImage1D(GL_TEXTURE_1D, 0, 0, static_cast(data.size()), formatF(format), type(format), &dataFloat.front()); break; case 2: glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, sizeX, sizeY, formatF(format), type(format), &dataFloat.front());