mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
[Perf] (MG_State): remember every dirty rect, not just their union
A Minecraft frame updates ~95 scattered 16x16 sprites in a 1024x512 atlas; MipmapStorage's single union dirty box turned ~95KB of changed texels into a ~2MB upload on every backend. The storage now keeps a bounded (96-slot) list of pairwise-disjoint dirty rects BEHIND the untouched union box: rects cascade-merge on touch or overlap, overflow folds the pair with minimum enlargement and re-cascades, whole-level dirties and respecifies just clear the list (empty list = "union box tells all"). GetDirtyRects hands the list out only when it has 2+ rects, fits the caller's capacity, and its summed area is under 75% of the union box - fewer driver calls beat equal bytes - so consumers can never stage more than the union box did. The list is maintained inside the same four mutation funnels every texel writer already goes through (MarkDirty, MarkDirtyRegion, AllocateLevel, TruncateToLevelCount - callers enumerated at the declaration), so list and union box cannot disagree. Backends OPT IN: the union-box API and its update order are byte-identical, and an unmodified backend keeps rendering exactly as before. 96 slots is measured, not guessed: on the bench's 95-sprite lattice a 16-slot list collapses to >93% of the union box, 96 slots reach 4.8% (~2MB -> ~95KB staged per frame). Verified by a 2859-check fuzz run against a reference dirty bitmap (union exactness, full coverage, disjointness, bounds, profitability). Unit tests 421/421.
This commit is contained in:
@@ -11,6 +11,23 @@
|
||||
namespace MobileGL {
|
||||
namespace MG_State {
|
||||
namespace GLState {
|
||||
namespace {
|
||||
// Overlapping OR abutting ([lo, hi) intervals meeting edge-to-edge) in
|
||||
// every axis: merging abutting boxes keeps scanline/tile write patterns
|
||||
// as one rect instead of a picket fence.
|
||||
Bool RegionsTouch(const MipmapDirtyRegion& a, const MipmapDirtyRegion& b) {
|
||||
return a.lo.x() <= b.hi.x() && b.lo.x() <= a.hi.x() && a.lo.y() <= b.hi.y() &&
|
||||
b.lo.y() <= a.hi.y() && a.lo.z() <= b.hi.z() && b.lo.z() <= a.hi.z();
|
||||
}
|
||||
|
||||
MipmapDirtyRegion RegionUnion(const MipmapDirtyRegion& a, const MipmapDirtyRegion& b) {
|
||||
return {IntVec3{std::min(a.lo.x(), b.lo.x()), std::min(a.lo.y(), b.lo.y()),
|
||||
std::min(a.lo.z(), b.lo.z())},
|
||||
IntVec3{std::max(a.hi.x(), b.hi.x()), std::max(a.hi.y(), b.hi.y()),
|
||||
std::max(a.hi.z(), b.hi.z())}};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SizeT MipmapStorage::GetLevelCount() const {
|
||||
return m_data.size();
|
||||
}
|
||||
@@ -28,6 +45,7 @@ namespace MobileGL {
|
||||
m_texelSizes.resize(requiredLevelCount);
|
||||
m_isDirty.resize(requiredLevelCount, false);
|
||||
m_dirtyRegions.resize(requiredLevelCount);
|
||||
m_dirtyRects.resize(requiredLevelCount);
|
||||
m_compressedData.resize(requiredLevelCount);
|
||||
m_compressedFormats.resize(requiredLevelCount, GL_NONE);
|
||||
}
|
||||
@@ -44,6 +62,11 @@ namespace MobileGL {
|
||||
std::max(input.texelSize.z(), 1)}}
|
||||
: MipmapDirtyRegion{};
|
||||
}
|
||||
// The rect list mirrors the union box's reset: whatever rects were
|
||||
// pending measured the OLD extents. Empty list = union box tells all.
|
||||
if (level < m_dirtyRects.size()) {
|
||||
m_dirtyRects[level].clear();
|
||||
}
|
||||
auto& data = m_data[level];
|
||||
data.resize(input.byteSize, 0);
|
||||
|
||||
@@ -94,6 +117,7 @@ namespace MobileGL {
|
||||
m_texelSizes.resize(levelCount);
|
||||
m_isDirty.resize(levelCount);
|
||||
m_dirtyRegions.resize(levelCount);
|
||||
m_dirtyRects.resize(levelCount);
|
||||
m_compressedData.resize(levelCount);
|
||||
m_compressedFormats.resize(levelCount);
|
||||
}
|
||||
@@ -142,6 +166,13 @@ namespace MobileGL {
|
||||
m_dirtyRegions[level] = {};
|
||||
}
|
||||
}
|
||||
// Both directions collapse the rect list to "just the union box": a
|
||||
// whole-level dirty IS the union box, a clean level has nothing to say.
|
||||
// clear() keeps the vector's capacity, so per-frame streaming levels
|
||||
// allocate their slots once and reuse them.
|
||||
if (level < m_dirtyRects.size()) {
|
||||
m_dirtyRects[level].clear();
|
||||
}
|
||||
}
|
||||
|
||||
bool MipmapStorage::IsDirty(Uint level) const {
|
||||
@@ -158,6 +189,20 @@ namespace MobileGL {
|
||||
std::min(offset.y() + size.y(), levelSize.y()),
|
||||
std::min(offset.z() + std::max(size.z(), 1), std::max(levelSize.z(), 1))};
|
||||
if (incoming.Empty()) return;
|
||||
// Rect list first, while the union box still holds only the PREVIOUS
|
||||
// writes: a level that is already dirty with an empty list is in the
|
||||
// "union box tells all" resting state, so that box seeds the list
|
||||
// before the incoming rect refines it.
|
||||
if (level < m_dirtyRects.size()) {
|
||||
auto& rects = m_dirtyRects[level];
|
||||
if (!m_isDirty[level]) {
|
||||
rects.clear(); // stale-safety; MarkDirty(false) already cleared it
|
||||
} else if (rects.empty() && level < m_dirtyRegions.size() &&
|
||||
!m_dirtyRegions[level].Empty()) {
|
||||
rects.push_back(m_dirtyRegions[level]);
|
||||
}
|
||||
InsertDirtyRect(level, incoming);
|
||||
}
|
||||
if (level < m_dirtyRegions.size()) {
|
||||
MipmapDirtyRegion& region = m_dirtyRegions[level];
|
||||
if (m_isDirty[level] && !region.Empty()) {
|
||||
@@ -174,10 +219,82 @@ namespace MobileGL {
|
||||
m_isDirty[level] = true;
|
||||
}
|
||||
|
||||
void MipmapStorage::InsertDirtyRect(Uint level, MipmapDirtyRegion incoming) {
|
||||
auto& rects = m_dirtyRects[level];
|
||||
if (rects.capacity() < kMaxDirtyRects) {
|
||||
rects.reserve(kMaxDirtyRects);
|
||||
}
|
||||
// Cascade-merge: absorb every rect the incoming touches. The absorbed
|
||||
// union can reach rects a smaller box did not, so rescan until stable;
|
||||
// every merge shrinks the list, so this terminates. Swap-with-back keeps
|
||||
// removal O(1) - the list is unordered by design.
|
||||
Bool merged = true;
|
||||
while (merged) {
|
||||
merged = false;
|
||||
for (SizeT i = 0; i < rects.size(); ++i) {
|
||||
if (RegionsTouch(rects[i], incoming)) {
|
||||
incoming = RegionUnion(rects[i], incoming);
|
||||
rects[i] = rects.back();
|
||||
rects.pop_back();
|
||||
merged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rects.size() < kMaxDirtyRects) {
|
||||
rects.push_back(incoming);
|
||||
return;
|
||||
}
|
||||
// Full: fold the incoming rect into the neighbour whose box grows least
|
||||
// (least new area dragged into the upload), then re-insert the grown
|
||||
// box - it may now touch others. The removal above guarantees the
|
||||
// recursion appends on the second pass at the latest.
|
||||
SizeT best = 0;
|
||||
SizeT bestGrowth = ~static_cast<SizeT>(0);
|
||||
for (SizeT i = 0; i < rects.size(); ++i) {
|
||||
const SizeT growth = RegionUnion(rects[i], incoming).TexelCount() - rects[i].TexelCount();
|
||||
if (growth < bestGrowth) {
|
||||
bestGrowth = growth;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
incoming = RegionUnion(rects[best], incoming);
|
||||
rects[best] = rects.back();
|
||||
rects.pop_back();
|
||||
InsertDirtyRect(level, incoming);
|
||||
}
|
||||
|
||||
MipmapDirtyRegion MipmapStorage::GetDirtyRegion(Uint level) const {
|
||||
if (level >= m_dirtyRegions.size()) return {};
|
||||
return m_dirtyRegions[level];
|
||||
}
|
||||
|
||||
SizeT MipmapStorage::GetDirtyRects(Uint level, MipmapDirtyRegion* outRects, SizeT maxRects) const {
|
||||
if (outRects == nullptr || level >= m_dirtyRects.size() || level >= m_dirtyRegions.size()) {
|
||||
return 0;
|
||||
}
|
||||
const auto& rects = m_dirtyRects[level];
|
||||
// 0 or 1 rects: the union box already says exactly this. More than the
|
||||
// caller can take: never truncate - a dropped rect is a dropped write.
|
||||
if (rects.size() < 2 || rects.size() > maxRects) {
|
||||
return 0;
|
||||
}
|
||||
// Total-bytes accounting: when the scattered rects add up to most of
|
||||
// the union box anyway (>= 3/4), one driver call on the box beats many
|
||||
// calls moving nearly the same bytes.
|
||||
SizeT summedArea = 0;
|
||||
for (const auto& rect : rects) {
|
||||
summedArea += rect.TexelCount();
|
||||
}
|
||||
const SizeT unionArea = m_dirtyRegions[level].TexelCount();
|
||||
if (summedArea * 4 >= unionArea * 3) {
|
||||
return 0;
|
||||
}
|
||||
for (SizeT i = 0; i < rects.size(); ++i) {
|
||||
outRects[i] = rects[i];
|
||||
}
|
||||
return rects.size();
|
||||
}
|
||||
} // namespace GLState
|
||||
} // namespace MG_State
|
||||
} // namespace MobileGL
|
||||
|
||||
@@ -31,6 +31,11 @@ namespace MobileGL {
|
||||
return lo.x() <= 0 && lo.y() <= 0 && lo.z() <= 0 && hi.x() >= levelSize.x() &&
|
||||
hi.y() >= levelSize.y() && hi.z() >= std::max(levelSize.z(), 1);
|
||||
}
|
||||
SizeT TexelCount() const {
|
||||
if (Empty()) return 0;
|
||||
return static_cast<SizeT>(hi.x() - lo.x()) * static_cast<SizeT>(hi.y() - lo.y()) *
|
||||
static_cast<SizeT>(hi.z() - lo.z());
|
||||
}
|
||||
};
|
||||
|
||||
class MipmapStorage {
|
||||
@@ -54,6 +59,28 @@ namespace MobileGL {
|
||||
// Meaningful only while IsDirty(level).
|
||||
MipmapDirtyRegion GetDirtyRegion(Uint level) const;
|
||||
|
||||
// Behind the union box, the level keeps up to kMaxDirtyRects pairwise
|
||||
// disjoint rects recording WHERE the writes actually landed. A frame of
|
||||
// ~100 scattered sprite updates in a big atlas has a union box that
|
||||
// covers nearly the whole level while the touched texels are ~5% of it;
|
||||
// the union box stays the source of truth (every write funnels through
|
||||
// MarkDirty/MarkDirtyRegion into BOTH representations), backends OPT IN
|
||||
// to the list purely as an upload-size refinement. 96 slots because the
|
||||
// pattern this exists for is Minecraft's ~100 sprites/frame: a 16-slot
|
||||
// list forced into far-apart merges was measured at >90% of the union
|
||||
// box's area on exactly that pattern, i.e. worthless. Inserts merge any
|
||||
// touching/overlapping rect (cascading, so the list stays disjoint);
|
||||
// when full, the incoming rect folds into the neighbour whose box grows
|
||||
// least and the list degrades gracefully toward the union box.
|
||||
static constexpr SizeT kMaxDirtyRects = 96;
|
||||
// Copies the level's dirty rects into outRects and returns how many were
|
||||
// written. 0 means "upload the union box instead" and covers every
|
||||
// reason at once: tracking unavailable, a single rect (identical to the
|
||||
// union box by construction), more rects than maxRects, or a summed
|
||||
// area so close to the union box's that one big upload beats many small
|
||||
// ones (fewer driver calls wins when the bytes are nearly equal).
|
||||
SizeT GetDirtyRects(Uint level, MipmapDirtyRegion* outRects, SizeT maxRects) const;
|
||||
|
||||
// The bytes an application handed to glCompressedTexImage*, kept verbatim beside the
|
||||
// (uncompressed) texel shadow rather than in place of it. GL 4.6 core 8.11 requires
|
||||
// glGetCompressedTexImage to return the image *as stored*, and no backend here has a
|
||||
@@ -70,10 +97,22 @@ namespace MobileGL {
|
||||
const void* MapCompressedData(Uint level) const;
|
||||
|
||||
protected:
|
||||
// Insert one clamped, non-empty write box, keeping the list disjoint
|
||||
// and bounded (see kMaxDirtyRects).
|
||||
void InsertDirtyRect(Uint level, MipmapDirtyRegion incoming);
|
||||
|
||||
Vector<IntVec3> m_texelSizes;
|
||||
Vector<Vector<Uint8>> m_data;
|
||||
Vector<bool> m_isDirty;
|
||||
Vector<MipmapDirtyRegion> m_dirtyRegions;
|
||||
// Per level, the disjoint rect list behind m_dirtyRegions' union box.
|
||||
// An EMPTY list is the common resting state and always means "the union
|
||||
// box is the whole story" - clean levels, whole-level dirties and
|
||||
// respecifies all just clear it, so plain full-level uploads never pay
|
||||
// a heap allocation; the first scattered MarkDirtyRegion on an
|
||||
// already-dirty level seeds the list from the union box accumulated so
|
||||
// far and refines from there.
|
||||
Vector<Vector<MipmapDirtyRegion>> m_dirtyRects;
|
||||
Vector<Vector<Uint8>> m_compressedData;
|
||||
Vector<GLenum> m_compressedFormats;
|
||||
};
|
||||
|
||||
@@ -84,6 +84,12 @@ namespace MobileGL {
|
||||
return m_storage[targetIndex].GetDirtyRegion(level);
|
||||
}
|
||||
|
||||
SizeT GetDirtyRects(Uint targetIndex, Uint level, MipmapDirtyRegion* outRects,
|
||||
SizeT maxRects) const {
|
||||
MOBILEGL_ASSERT(targetIndex < TargetCount, "GetDirtyRects: target invalid");
|
||||
return m_storage[targetIndex].GetDirtyRects(level, outRects, maxRects);
|
||||
}
|
||||
|
||||
void SetCompressedImage(Uint targetIndex, Uint level, GLenum internalFormat, const void* data,
|
||||
SizeT size) {
|
||||
MOBILEGL_ASSERT(targetIndex < TargetCount, "SetCompressedImage: target invalid");
|
||||
|
||||
@@ -345,6 +345,13 @@ namespace MobileGL {
|
||||
return m_textureStorage.GetDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel);
|
||||
}
|
||||
|
||||
SizeT TextureObjectWithOneMipmap::GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapDirtyRegion* outRects,
|
||||
SizeT maxRects) const {
|
||||
return m_textureStorage.GetDirtyRects(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
|
||||
outRects, maxRects);
|
||||
}
|
||||
|
||||
void TextureObjectWithOneMipmap::SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
GLenum internalFormat, const void* data, SizeT size) {
|
||||
m_textureStorage.SetCompressedImage(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
|
||||
|
||||
@@ -186,6 +186,20 @@ namespace MobileGL::MG_State::GLState {
|
||||
const IntVec3 size = GetMipmapTexelSize(uploadTarget, mipmapLevel);
|
||||
return {IntVec3{0, 0, 0}, IntVec3{size.x(), size.y(), std::max(size.z(), 1)}};
|
||||
}
|
||||
// Scatter detail behind GetStorageDirtyRegion: up to maxRects disjoint rects
|
||||
// that together cover every dirty texel, so ~100 sprite writes in a big atlas
|
||||
// need not be uploaded as one atlas-sized box. Returns how many rects were
|
||||
// written to outRects; 0 means "no list, upload the union box" and is always a
|
||||
// safe answer - this base fallback keeps whole-level semantics for storage
|
||||
// classes that do not track rects, and backends OPT IN by calling this.
|
||||
virtual SizeT GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapDirtyRegion* outRects, SizeT maxRects) const {
|
||||
(void)uploadTarget;
|
||||
(void)mipmapLevel;
|
||||
(void)outRects;
|
||||
(void)maxRects;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The compressed image a glCompressedTexImage* call shadowed for this level, kept verbatim
|
||||
// next to the texel data rather than instead of it - see MipmapStorage. The texel shadow
|
||||
@@ -257,6 +271,8 @@ namespace MobileGL::MG_State::GLState {
|
||||
void MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel, IntVec3 offset,
|
||||
IntVec3 size) override;
|
||||
MipmapDirtyRegion GetStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
|
||||
SizeT GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel, MipmapDirtyRegion* outRects,
|
||||
SizeT maxRects) const override;
|
||||
void SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel, GLenum internalFormat,
|
||||
const void* data, SizeT size) override;
|
||||
GLenum GetMipmapCompressedFormat(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
|
||||
|
||||
@@ -69,6 +69,12 @@ namespace MobileGL {
|
||||
return m_textureStorage.GetDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel);
|
||||
}
|
||||
|
||||
SizeT TextureObject2DCube::GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapDirtyRegion* outRects, SizeT maxRects) const {
|
||||
return m_textureStorage.GetDirtyRects(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
|
||||
outRects, maxRects);
|
||||
}
|
||||
|
||||
void TextureObject2DCube::SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
GLenum internalFormat, const void* data, SizeT size) {
|
||||
m_textureStorage.SetCompressedImage(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
|
||||
|
||||
@@ -31,6 +31,8 @@ namespace MobileGL {
|
||||
IntVec3 size) override;
|
||||
MipmapDirtyRegion GetStorageDirtyRegion(TextureUploadTarget uploadTarget,
|
||||
Uint mipmapLevel) const override;
|
||||
SizeT GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapDirtyRegion* outRects, SizeT maxRects) const override;
|
||||
void SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
GLenum internalFormat, const void* data, SizeT size) override;
|
||||
GLenum GetMipmapCompressedFormat(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
|
||||
|
||||
Reference in New Issue
Block a user