diff --git a/indra/llrender/llgltexture.cpp b/indra/llrender/llgltexture.cpp index 4f116bc851..50f8befe71 100644 --- a/indra/llrender/llgltexture.cpp +++ b/indra/llrender/llgltexture.cpp @@ -49,6 +49,7 @@ LLGLTexture::LLGLTexture(const LLImageRaw* raw, bool usemipmaps) mUseMipMaps = usemipmaps ; // Create an empty image of the specified size and width mGLTexturep = new LLImageGL(raw, usemipmaps) ; + mGLTexturep->setOwner(this); mFullWidth = mGLTexturep->getWidth(); mFullHeight = mGLTexturep->getHeight(); mComponents = mGLTexturep->getComponents(); @@ -57,6 +58,11 @@ LLGLTexture::LLGLTexture(const LLImageRaw* raw, bool usemipmaps) LLGLTexture::~LLGLTexture() { + // A deferred alpha analysis may outlive this texture, with the GL texture it holds. + if (mGLTexturep && mGLTexturep->getOwner() == this) + { + mGLTexturep->setOwner(nullptr); + } cleanup(); } @@ -131,6 +137,7 @@ void LLGLTexture::generateGLTexture() if(mGLTexturep.isNull()) { mGLTexturep = new LLImageGL(mFullWidth, mFullHeight, mComponents, mUseMipMaps) ; + mGLTexturep->setOwner(this); } } diff --git a/indra/llrender/llgltexture.h b/indra/llrender/llgltexture.h index 65fa05aa77..dde491949a 100644 --- a/indra/llrender/llgltexture.h +++ b/indra/llrender/llgltexture.h @@ -97,6 +97,9 @@ class LLGLTexture : public LLTexture virtual void dump(); // debug info to LL_INFOS() + // The GL texture's alpha mask answer changed (LLImageGL::getIsAlphaMask), on the main thread. + virtual void onAlphaMaskChanged() {} + virtual const LLUUID& getID() const; void setBoostLevel(S32 level); diff --git a/indra/llrender/llimagegl.cpp b/indra/llrender/llimagegl.cpp index 41d4bc353f..d95f5d6bee 100644 --- a/indra/llrender/llimagegl.cpp +++ b/indra/llrender/llimagegl.cpp @@ -33,6 +33,7 @@ #include "linden_common.h" #include "llimagegl.h" +#include "llgltexture.h" #include "llerror.h" #include "llimage.h" @@ -2655,18 +2656,37 @@ void LLImageGL::setAlphaAnalysis(const AlphaAnalysis& analysis) return; } - mIsMask = analysis.mIsMask; + setIsMask(analysis.mIsMask); mMaxAlpha = analysis.mMaxAlpha; mMinAlpha = analysis.mMinAlpha; } void LLImageGL::clearAlphaAnalysis() { + // Provisional, while an analysis is out: no answer, so the owner is not told, and the next answer is weighed + // against the last one, and against this if anything read it meanwhile. mIsMask = false; + mProvisionalRead.store(false, std::memory_order_relaxed); mMaxAlpha = 255; mMinAlpha = 0; } +void LLImageGL::setIsMask(bool is_mask) +{ + // A face built while the provisional answer stood went by it, as by an answer. + const bool provisional_read = mProvisionalRead.exchange(false, std::memory_order_relaxed); + const bool changed = mAnsweredMask != is_mask || (provisional_read && is_mask); + mIsMask = is_mask; + if (changed) + { + mAnsweredMask = is_mask; + if (mOwner && on_main_thread()) + { + mOwner->onAlphaMaskChanged(); + } + } +} + void LLImageGL::publishPendingAlpha() { if (mPendingAlpha.mHasPickMask) @@ -2679,7 +2699,7 @@ void LLImageGL::publishPendingAlpha() } if (mPendingAlpha.mHasAnalysis) { - mIsMask = mPendingAlpha.mAnalysis.mIsMask; + setIsMask(mPendingAlpha.mAnalysis.mIsMask); mMaxAlpha = mPendingAlpha.mAnalysis.mMaxAlpha; mMinAlpha = mPendingAlpha.mAnalysis.mMinAlpha; } @@ -3104,6 +3124,11 @@ bool LLImageGL::getBoundRecently() const bool LLImageGL::getIsAlphaMask() const { llassert_always(!sSkipAnalyzeAlpha); + if (mIsMask != mAnsweredMask) + { + // the provisional no-mask of a mask texture, which a face built now goes by + mProvisionalRead.store(true, std::memory_order_relaxed); + } return mIsMask; } @@ -3130,6 +3155,8 @@ void LLImageGL::setNeedsAlphaAndPickMask(bool need_mask) { mAlphaOffset = INVALID_OFFSET ; clearAlphaAnalysis(); + // no mask from now on: an answer + setIsMask(false); // Invalidate pending jobs ++mAlphaAnalysisSerial; @@ -3563,7 +3590,7 @@ void LLImageGL::analyzeAlpha(const void* data_in, U32 w, U32 h) // Only apply if no newer analysis has been requested if (mAlphaAnalysisSerial == request_serial) { - mIsMask = analysis.mIsMask; + setIsMask(analysis.mIsMask); mMaxAlpha = analysis.mMaxAlpha; mMinAlpha = analysis.mMinAlpha; } diff --git a/indra/llrender/llimagegl.h b/indra/llrender/llimagegl.h index 98fad493ea..1007203b73 100644 --- a/indra/llrender/llimagegl.h +++ b/indra/llrender/llimagegl.h @@ -49,6 +49,7 @@ #define LL_IMAGEGL_THREAD_CHECK 0 //set to 1 to enable thread debugging for ImageGL class LLWindow; +class LLGLTexture; #define BYTES_TO_MEGA_BYTES(x) ((x) >> 20) #define MEGA_BYTES_TO_BYTES(x) ((x) << 20) @@ -371,6 +372,13 @@ class LLImageGL : public LLThreadSafeRefCount U64 getContentStamp() const { return mContentStamp.load(std::memory_order_relaxed); } bool getIsAlphaMask() const; + // The texture this one was made for, told on the main thread whenever an analysis answers getIsAlphaMask + // differently from the last one (LLGLTexture::onAlphaMaskChanged): a face built into a pass by the old answer, or + // while a deferred analysis was out and the texture counted as no mask, belongs in another by the new one. The + // provisional no-mask while an analysis is out is no answer, but an answer of mask after it was read is a change + // for the faces built by it. Cleared by the owner as it goes. + void setOwner(LLGLTexture* owner) { mOwner = owner; } + LLGLTexture* getOwner() const { return mOwner; } // The highest alpha of the level its alpha was last analysed at (analyzeAlpha), or 255 where it was not: no // alpha channel, an alpha it cannot read, or an analysis not yet back. U8 getMaxAlpha() const { return mMaxAlpha; } @@ -468,6 +476,7 @@ class LLImageGL : public LLThreadSafeRefCount // Sets the published analysis to what a texture without a readable alpha holds: no mask, and opaque. void clearAlphaAnalysis(); + void setIsMask(bool is_mask); void publishPendingAlpha(); void discardPendingAlpha(); @@ -486,6 +495,9 @@ class LLImageGL : public LLThreadSafeRefCount bool mAutoGenMips = false; bool mIsMask; + bool mAnsweredMask = false; // what the last analysis answered, mIsMask but for the provisional clear + mutable std::atomic mProvisionalRead{ false }; // getIsAlphaMask answered the provisional no-mask + LLGLTexture* mOwner = nullptr; U8 mMaxAlpha; U8 mMinAlpha; bool mNeedsAlphaAndPickMask; diff --git a/indra/llrender/llvertexbuffer.cpp b/indra/llrender/llvertexbuffer.cpp index 6611abd49b..9f566a2c50 100644 --- a/indra/llrender/llvertexbuffer.cpp +++ b/indra/llrender/llvertexbuffer.cpp @@ -376,6 +376,9 @@ class LLVBOPool // Of those, the stores kept for reuse. virtual U64 getVramBytesPooled() const = 0; virtual LLVertexBuffer::PoolStats getStats() const = 0; + // Frees kept stores freed at least `age` ago that the GPU is done with, until LLTimer::getTotalSeconds() reaches + // deadline. True while any is kept. + virtual bool trim(std::chrono::steady_clock::duration age, F64 deadline) { return false; } }; // VBO Pool for Apple GPUs (as in M1/M2 etc, not Intel macs) @@ -639,6 +642,34 @@ class LLDefaultVBOPool final : public LLVBOPool } + bool trim(std::chrono::steady_clock::duration age, F64 deadline) override + { + const Time cutoff = std::chrono::steady_clock::now() - age; + for (auto* pool : { &mVBOPool, &mIBOPool }) + { + for (Pool::iterator iter = pool->begin(); iter != pool->end(); ) + { + // oldest at the back + auto& entries = iter->second; + while (!entries.empty() && entries.back().mAge < cutoff && finished_with(entries.back().mFrame)) + { + if (LLTimer::getTotalSeconds() >= deadline) + { + return true; + } + auto& entry = entries.back(); + ll_aligned_free_16(entry.mData); + delete_buffers(1, &entry.mGLName); + llassert(mReserved >= iter->first); + mReserved -= iter->first; + entries.pop_back(); + } + iter = entries.empty() ? pool->erase(iter) : std::next(iter); + } + } + return mReserved > 0; + } + // clean periodically (clean gets called for every alloc/free) void clean() { @@ -806,6 +837,37 @@ LLVertexBuffer::PoolStats LLVertexBuffer::getPoolStats() return sVBOPool ? sVBOPool->getStats() : PoolStats(); } +// Until when updatePoolTrim trims the pool, 0 when it does not. +static F64 sPoolTrimUntil = 0.0; + +//static +void LLVertexBuffer::trimPoolSoon() +{ + // A store freed in the first five seconds is a second old before the window closes. + constexpr F64 POOL_TRIM_SECONDS = 6.0; + sPoolTrimUntil = LLTimer::getTotalSeconds() + POOL_TRIM_SECONDS; +} + +//static +void LLVertexBuffer::updatePoolTrim() +{ + if (sPoolTrimUntil == 0.0 || !sVBOPool) + { + return; + } + const F64 now = LLTimer::getTotalSeconds(); + if (now >= sPoolTrimUntil) + { + sPoolTrimUntil = 0.0; + return; + } + LL_PROFILE_ZONE_SCOPED_CATEGORY_VERTEX; + // A store kept less than a second is likely to be taken again, as LODs and rebuilds swap geometry. + using namespace std::chrono_literals; + constexpr F64 POOL_TRIM_BUDGET = 0.0005; + sVBOPool->trim(1s, now + POOL_TRIM_BUDGET); +} + //============================================================================ // //static diff --git a/indra/llrender/llvertexbuffer.h b/indra/llrender/llvertexbuffer.h index 20c4165144..7a89926d17 100644 --- a/indra/llrender/llvertexbuffer.h +++ b/indra/llrender/llvertexbuffer.h @@ -350,6 +350,13 @@ class LLVertexBuffer final : public LLRefCount // Of those, the ones it keeps for reuse. static U64 getBytesPooled(); + // Lets the stores the pool keeps go over the next few seconds, rather than at its next clean, once they have been + // kept a second and the GPU is done with them: for when a lot of geometry has just been freed, a removed region's + // or the place a teleport left. A frame's budget at a time, in updatePoolTrim. + static void trimPoolSoon(); + // Once a frame, on the main thread. + static void updatePoolTrim(); + // What the buffer pool has handed out since it was made: stores it had to make, stores it reused, and of those the // ones freed in a frame the GPU had not been shown finished with, which it gave a new store under the same name. struct PoolStats diff --git a/indra/llrender/tests/llimagegl_test.cpp b/indra/llrender/tests/llimagegl_test.cpp index 024cf9b6f2..830b0388a1 100644 --- a/indra/llrender/tests/llimagegl_test.cpp +++ b/indra/llrender/tests/llimagegl_test.cpp @@ -37,6 +37,7 @@ #include "../algpubuffer.h" #include "../alstagingring.h" #include "../alvramusage.h" +#include "../llgltexture.h" #include "../llimagegl.h" #include "../llgl.h" #include "../llrender.h" @@ -1959,4 +1960,101 @@ namespace REQUIRE(LLImageGL::dataFormatComponents(entry.first) == entry.second); } } + + // A texture that counts what its GL texture tells it of the mask answer. + struct MaskWatcher : public LLGLTexture + { + MaskWatcher() : LLGLTexture(32, 32, 4, false) {} + void onAlphaMaskChanged() override { ++mChanges; } + S32 mChanges = 0; + }; + + // A square RGBA image whose alpha is 0 in its top half and opaque below (a mask), or 128 everywhere (no mask). + LLPointer makeAlphaRaw(bool mask, U16 side = 32) + { + LLPointer raw = new LLImageRaw(side, side, 4); + U8* data = raw->getData(); + for (U32 i = 0; i < (U32)side * side; ++i) + { + data[i * 4 + 0] = data[i * 4 + 1] = data[i * 4 + 2] = 0x80; + data[i * 4 + 3] = mask ? (i < (U32)side * side / 2 ? 0 : 255) : 128; + } + return raw; + } + + // The faces of a texture are built into pools by its mask answer, so the texture hears of every change of it, and + // only of a change; once it is gone, the GL texture it made tells nobody. + TEST_CASE_METHOD(llimagegl_data, "LLImageGL: the texture it was made for hears each change of its mask answer", "[LLImageGL]") + { + LLPointer owner = new MaskWatcher(); + owner->generateGLTexture(); + LLPointer img = owner->getGLTexture(); + REQUIRE(img->getOwner() == owner.get()); + + // Small enough to be analysed as it uploads, not on a worker. + REQUIRE(img->createGLTexture(0, makeAlphaRaw(true).get())); + CHECK(img->getIsAlphaMask()); + CHECK(owner->mChanges == 1); + + UNSCOPED_INFO("the same answer again is no change"); + REQUIRE(img->createGLTexture(0, makeAlphaRaw(true).get())); + CHECK(owner->mChanges == 1); + + REQUIRE(img->createGLTexture(0, makeAlphaRaw(false).get())); + CHECK_FALSE(img->getIsAlphaMask()); + CHECK(owner->mChanges == 2); + + owner = nullptr; + CHECK(img->getOwner() == nullptr); + REQUIRE(img->createGLTexture(0, makeAlphaRaw(true).get())); + CHECK(img->getIsAlphaMask()); + } + + // An image of 64 px or more is analysed on a worker, and counts as no mask until the answer comes back. That + // provisional no-mask is no answer: the texture hears of an answer that differs from the last one, or of a mask + // answer after the provisional no-mask was read. + TEST_CASE_METHOD(llimagegl_data, "LLImageGL: a deferred alpha analysis tells the texture its answer when it comes back", "[LLImageGL]") + { + // The queues the analysis is posted to and answered on, as the viewer's. The main one has to exist before the + // image, which looks it up when built. + LL::WorkQueue mainloop("mainloop"); + LL::WorkQueue general("General"); + auto settle = [&]() + { + for (U32 i = 0; i < 16; ++i) + { + general.runPending(); + mainloop.runPending(); + } + }; + + LLPointer owner = new MaskWatcher(); + owner->generateGLTexture(); + LLPointer img = owner->getGLTexture(); + + REQUIRE(img->createGLTexture(0, makeAlphaRaw(true, 64).get())); + CHECK_FALSE(img->getIsAlphaMask()); + CHECK(owner->mChanges == 0); + settle(); + CHECK(img->getIsAlphaMask()); + CHECK(owner->mChanges == 1); + + UNSCOPED_INFO("the same answer after another deferral, its provisional no-mask unread, is no change"); + REQUIRE(img->createGLTexture(0, makeAlphaRaw(true, 64).get())); + settle(); + CHECK(img->getIsAlphaMask()); + CHECK(owner->mChanges == 1); + + UNSCOPED_INFO("read meanwhile it is one: a face built by the provisional no-mask went to the blend pass"); + REQUIRE(img->createGLTexture(0, makeAlphaRaw(true, 64).get())); + CHECK_FALSE(img->getIsAlphaMask()); + settle(); + CHECK(img->getIsAlphaMask()); + CHECK(owner->mChanges == 2); + + REQUIRE(img->createGLTexture(0, makeAlphaRaw(false, 64).get())); + settle(); + CHECK_FALSE(img->getIsAlphaMask()); + CHECK(owner->mChanges == 3); + } } diff --git a/indra/llrender/tests/llvertexbuffer_gl_test.cpp b/indra/llrender/tests/llvertexbuffer_gl_test.cpp index abf49967d4..26c460e58b 100644 --- a/indra/llrender/tests/llvertexbuffer_gl_test.cpp +++ b/indra/llrender/tests/llvertexbuffer_gl_test.cpp @@ -33,6 +33,8 @@ #include "llheadlessgl_fixture.h" +#include "lltimer.h" + #include #include @@ -484,4 +486,75 @@ namespace CHECK(at(LEFT) == packed(green)); CHECK(glGetError() == GLenum(GL_NO_ERROR)); } + + // A trim lets go of the stores the pool keeps for reuse once they have been kept a second and the GPU is done with + // them, and of none sooner: a store the next LODs and rebuilds take straight back stays. + TEST_CASE_METHOD(llvertexbuffer_gl_data, "llvertexbuffer_gl: a pool trim frees kept stores once they are a second old", "[llvertexbuffer_gl]") + { + if (gGLManager.mIsApple) + { + SKIP("Apple's GPUs pool no buffers"); + } + setUp(); + // A size no other case makes, so that the pool's entries of it are this case's. + constexpr U32 QUADS = 43; + constexpr U32 COUNT = 16; + auto make_all = [](std::vector>& buffers) + { + buffers.clear(); + for (U32 i = 0; i < COUNT; ++i) + { + buffers.push_back(makeBuffer(4 * QUADS, 6 * QUADS)); + } + }; + auto trim = []() + { + LLVertexBuffer::trimPoolSoon(); + // a frame's budget at a time, until nothing more goes + for (U32 i = 0; i < 1000; ++i) + { + const U64 pooled = LLVertexBuffer::getBytesPooled(); + LLVertexBuffer::updatePoolTrim(); + if (LLVertexBuffer::getBytesPooled() == pooled) + { + break; + } + } + }; + + std::vector> buffers; + make_all(buffers); + buffers.clear(); + ALFrameFences& frames = gGL.frameFences(); + gGL.endFrame(); + frames.waitFinished(frames.frame() - 1); + + trim(); + { + INFO("kept less than a second, every store of the size comes back from the pool"); + const LLVertexBuffer::PoolStats before = LLVertexBuffer::getPoolStats(); + make_all(buffers); + const LLVertexBuffer::PoolStats after = LLVertexBuffer::getPoolStats(); + CHECK(after.mHits - before.mHits == 2 * COUNT); + CHECK(after.mMisses == before.mMisses); + } + + buffers.clear(); + gGL.endFrame(); + frames.waitFinished(frames.frame() - 1); + ms_sleep(1100); + const U64 pooled = LLVertexBuffer::getBytesPooled(); + const U64 allocated = LLVertexBuffer::getBytesAllocated(); + trim(); + { + INFO("kept a second, every store of the size is gone and the counts follow"); + CHECK(LLVertexBuffer::getBytesPooled() < pooled); + CHECK(allocated - LLVertexBuffer::getBytesAllocated() == pooled - LLVertexBuffer::getBytesPooled()); + const LLVertexBuffer::PoolStats before = LLVertexBuffer::getPoolStats(); + make_all(buffers); + const LLVertexBuffer::PoolStats after = LLVertexBuffer::getPoolStats(); + CHECK(after.mMisses - before.mMisses == 2 * COUNT); + } + CHECK(glGetError() == GLenum(GL_NO_ERROR)); + } } diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 4d4cbe60d6..d060cc1c5b 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -265,6 +265,7 @@ if(AL_BUILD_VIEWER) allightboxdirectory.cpp almotionpredictor.cpp alobjectproperties.cpp + alregionretirement.cpp alagentlocationformat.cpp alcrashreporter.cpp alcrashreporter_run.cpp @@ -1145,6 +1146,9 @@ if(AL_BUILD_VIEWER) allightboxdirectory.h almotionpredictor.h alobjectproperties.h + alobjectroster.h + alprobereach.h + alregionretirement.h alagentlocationformat.h alcrashreporter.h alfeedback.h @@ -2538,6 +2542,9 @@ if(AL_BUILD_TESTS) tests/algradehistory_test.cpp tests/almotionpredictor_test.cpp tests/alnotecardembedded_test.cpp + tests/alobjectroster_test.cpp + tests/alprobereach_test.cpp + tests/alregionretirement_test.cpp tests/alrecoverykeeper_test.cpp tests/alsceneexplorerpredicate_test.cpp tests/alscopedata_test.cpp diff --git a/indra/newview/alobjectroster.h b/indra/newview/alobjectroster.h new file mode 100644 index 0000000000..5b578d0bc9 --- /dev/null +++ b/indra/newview/alobjectroster.h @@ -0,0 +1,203 @@ +/** + * @file alobjectroster.h + * @brief ALObjectRoster: a list of ref-counted objects that know their place in it, and the queue of those leaving + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llerror.h" +#include "llpointer.h" + +#include +#include +#include +#include + +// The objects of a list, each holding its own position in it, and a queue of +// those marked for removal, which leave a few at a time. +// +// An object marked for removal stays in the list, where loops over the list +// still meet it, until the queue reaches it: then it leaves by a swap with the +// last object and a pop, and the queue lets go of its reference. Positions are +// therefore not stable across a removal. The queue's length is the number of +// objects waiting to leave, and an object is queued at most once however often +// it is marked, so the count and the queue cannot disagree. +// +// T derives from LLRefCount and provides +// S32 getObjectListIndex() const; +// void setObjectListIndex(S32 index); // -1: in no list +// bool isPendingRemoval() const; +// void setPendingRemoval(); +// An object may be queued without being in the list; leaving then only drops +// the queue's reference. +// +// Not thread-safe; the owner serialises access. An object's destructor, run as +// it leaves, may mark others for removal: they join the queue's back. +template +class ALObjectRoster +{ +public: + using item_t = LLPointer; + using items_t = std::vector; + // Read-only: a write through an iterator would leave the objects' recorded positions behind. + using const_iterator = typename items_t::const_iterator; + + // Objects a budgeted drain removes a frame whatever its time budget: a + // floor, and a share of the queue that grows with it, so a queue that + // grows faster than the budget drains still shrinks. + static constexpr size_t MIN_DRAIN = 1; + static constexpr size_t DRAIN_SHARE = 256; + + static size_t drainFloor(size_t queued) + { + return std::min(queued, std::max(MIN_DRAIN, queued / DRAIN_SHARE)); + } + + ALObjectRoster() = default; + ~ALObjectRoster() { clear(); } + ALObjectRoster(const ALObjectRoster&) = delete; + ALObjectRoster& operator=(const ALObjectRoster&) = delete; + + size_t size() const { return mItems.size(); } + bool empty() const { return mItems.empty(); } + + const_iterator begin() const { return mItems.cbegin(); } + const_iterator end() const { return mItems.cend(); } + + // Object at position i. Positions hold only until the next removal. + T* operator[](size_t i) const { return mItems[i].get(); } + + size_t queued() const { return mQueue.size(); } + + // Appends obj and takes a reference to it. Refuses an object already in a + // list, or one already marked for removal. + bool add(T* obj) + { + llassert(obj); + if (!obj || obj->getObjectListIndex() >= 0 || obj->isPendingRemoval()) + { + return false; + } + obj->setObjectListIndex(static_cast(mItems.size())); + mItems.emplace_back(obj); + return true; + } + + // Marks obj for removal and queues it. False when it was already marked. + bool queueRemoval(T* obj) + { + llassert(obj); + if (!obj || obj->isPendingRemoval()) + { + return false; + } + obj->setPendingRemoval(); + mQueue.emplace_back(obj); + return true; + } + + // Removes queued objects, oldest first, while within(removed so far) says + // to go on. Returns how many left. + template + size_t drain(Within&& within) + { + size_t removed = 0; + while (!mQueue.empty() && within(removed)) + { + item_t obj = std::move(mQueue.front()); + mQueue.pop_front(); + leave(obj); + ++removed; + } + return removed; + } + + // Removes every queued object select(obj) picks, whatever the budget, and + // keeps the others queued in order. Returns how many left. + template + size_t drainIf(Select&& select) + { + std::vector leaving; + auto kept = std::stable_partition(mQueue.begin(), mQueue.end(), [&](const item_t& obj) { return !select(obj.get()); }); + std::move(kept, mQueue.end(), std::back_inserter(leaving)); + mQueue.erase(kept, mQueue.end()); + for (item_t& obj : leaving) + { + leave(obj); + } + return leaving.size(); + } + + // Drops every object and the queue. Positions are reset before any + // reference is released, so an object that outlives the list reads as in + // no list. + void clear() + { + for (const item_t& obj : mItems) + { + obj->setObjectListIndex(-1); + } + items_t items; + items.swap(mItems); + std::deque queue; + queue.swap(mQueue); + } + + // Every object's recorded position is its position, and every queued + // object is marked. + bool consistent() const + { + for (size_t i = 0; i < mItems.size(); ++i) + { + if (mItems[i].isNull() || mItems[i]->getObjectListIndex() != static_cast(i)) + { + return false; + } + } + return std::all_of(mQueue.begin(), mQueue.end(), [](const item_t& obj) { return obj->isPendingRemoval(); }); + } + +private: + // Takes obj out of the list, if it is in it, by moving the last object + // into its place, then drops the reference the caller held for the queue. + void leave(item_t& obj) + { + const S32 index = obj->getObjectListIndex(); + if (index >= 0) + { + llassert(static_cast(index) < mItems.size() && mItems[index] == obj); + item_t removed = std::move(mItems[index]); + const size_t last = mItems.size() - 1; + if (static_cast(index) != last) + { + mItems[index] = std::move(mItems[last]); + mItems[index]->setObjectListIndex(index); + } + mItems.pop_back(); + obj->setObjectListIndex(-1); + } + obj = nullptr; + } + + items_t mItems; + std::deque mQueue; +}; diff --git a/indra/newview/alprobereach.h b/indra/newview/alprobereach.h new file mode 100644 index 0000000000..1aa595d7db --- /dev/null +++ b/indra/newview/alprobereach.h @@ -0,0 +1,100 @@ +/** + * @file alprobereach.h + * @brief ALProbeReach: which reflection probes the view can reach, and the cube slots dynamic allocation asks for + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llmath.h" +#include "v3math.h" + +#include + +struct ALProbeReach +{ + // A probe affects only the pixels inside its influence volume, so one whose sphere lies wholly beyond draw + // distance affects nothing drawn: every probe of a far region, and of the region a teleport left, is one. + static bool inReach(const LLVector3& origin, F32 radius, const LLVector3& camera, F32 draw_distance) + { + return inReach(dist_vec(origin, camera) - radius, draw_distance); + } + + // Metres past draw distance a probe in reach may go before it is out of it, so a probe at the edge does not lose + // its cube slot, and need its twelve passes again, each time it crosses. + static constexpr F32 REACH_MARGIN = 16.f; + + // The same, from the distance between the camera and the sphere's surface, negative inside it, and whether the + // probe was in reach before. + static bool inReach(F32 distance_to_sphere, F32 draw_distance, bool was_in_reach = false) + { + return distance_to_sphere <= std::max(draw_distance, 0.f) + (was_in_reach ? REACH_MARGIN : 0.f); + } + + // Probes that matter, counted for dynamic allocation in steps of this many: every change of the count + // reallocates the cube array and regenerates every probe, so it moves only a step at a time. + static constexpr U32 RELEVANT_STEP = 16; + + // The count dynamic allocation sizes from, given the probes that matter now and the count it sized from last: + // up a step as soon as they need it, down only once they are more than a step fewer, so a probe at the edge of + // draw distance does not reallocate the cube array each time it crosses. + static U32 stepRelevant(U32 relevant, U32 current) + { + const U32 stepped = ((relevant + RELEVANT_STEP - 1) / RELEVANT_STEP) * RELEVANT_STEP; + return (stepped > current || stepped + RELEVANT_STEP < current) ? stepped : current; + } + + // The cube slots RenderReflectionProbeDynamicAllocation asks for at probe_level, from the probes that matter + // (stepped), within probe_count, the slots RenderReflectionProbeCount allows; -1 asks for all of them. + static U32 dynamicProbeCount(S32 probe_level, S32 allocation, U32 relevant, U32 probe_count) + { + if (allocation <= -1) + { + return probe_count; + } + + U32 count = 256; + if (probe_level == 0) + { + count = 1; + } + else if (probe_level == 1) + { + count = relevant; + } + else if (probe_level == 2) + { + count = std::max(relevant, 128u); + } + + if (allocation > 1) + { + // The count in units of allocation, rounded, times 16: the nearest multiple of 16 at an allocation of 16, + // and the count scaled by 16 / allocation at any other. + count = ((count + allocation / 2) / allocation) * 16; + } + else + { + count += allocation; + } + return llclamp(count, 1u, probe_count); + } +}; diff --git a/indra/newview/alregionretirement.cpp b/indra/newview/alregionretirement.cpp new file mode 100644 index 0000000000..0db6197eb7 --- /dev/null +++ b/indra/newview/alregionretirement.cpp @@ -0,0 +1,282 @@ +/** + * @file alregionretirement.cpp + * @brief ALRegionRetirement: removing the regions a teleport left behind, once the arrival has settled + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alregionretirement.h" + +#include "llagent.h" +#include "llagentcamera.h" +#include "llappviewer.h" +#include "lltexturefetch.h" +#include "llviewercontrol.h" +#include "llviewerobject.h" +#include "llviewerobjectlist.h" +#include "llvoavatar.h" +#include "message.h" +#include "llviewerregion.h" +#include "llvertexbuffer.h" +#include "llviewertexturelist.h" +#include "llworld.h" + +namespace +{ + // What a frame spends on the retiring region's objects, killing or passing over them, beyond a floor of one looked + // at, so every frame advances: a kill takes a linkset, or an avatar and everything it wears. + constexpr F64 KILL_BUDGET_SECONDS = 0.0005; + // How long the dead objects may take to leave the object list, a frame's budget at a time, before the region is + // removed anyway and removeRegion destroys the rest. + constexpr F64 MAX_DRAIN_WAIT_SECONDS = 5.0; + + void collect_local_ids(const LLViewerObject* objectp, std::vector& ids) + { + ids.push_back(objectp->getLocalID()); + for (const LLViewerObject* childp : objectp->getChildren()) + { + collect_local_ids(childp, ids); + } + } + + bool kept(LLViewerRegion* regionp) + { + LLViewerRegion* agent_region = gAgent.getRegion(); + if (!agent_region) + { + return true; + } + std::vector neighbours; + agent_region->getNeighboringRegions(neighbours); + const bool neighbour = std::find(neighbours.begin(), neighbours.end(), regionp) != neighbours.end(); + // Near the agent, or near the camera, which can be a draw distance away from it and see as far again. + const LLVector3d origin = regionp->getOriginGlobal(); + const F64 width = regionp->getWidth(); + const F64 draw_distance = gAgentCamera.mDrawDistance; + return ALRegionRetirement::keeps(regionp == agent_region, neighbour, origin, width, gAgent.getPositionGlobal(), + draw_distance) + || ALRegionRetirement::keeps(false, false, origin, width, gAgentCamera.getCameraPositionGlobal(), + draw_distance); + } +} + +ALRegionRetirement::ALRegionRetirement() = default; + +ALRegionRetirement::~ALRegionRetirement() = default; + +void ALRegionRetirement::update() +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_NETWORK; + static LLCachedControl retire_departed(gSavedSettings, "AlchemyRetireDepartedRegions", true); + + watchArrival(); + if (mTeleporting) + { + // The teleport may lead back to the region being retired: its arrival must find it gone, not half killed. + if (mRegionp) + { + finish(); + } + mQueue.clear(); + return; + } + + if (mArrivalPending && settled(LLFrameTimer::getTotalSeconds() - mArrivalTime, + LLAppViewer::getTextureFetch() ? LLAppViewer::getTextureFetch()->getNumRequests() : 0, + (S32)gTextureList.mCreateTextureList.size())) + { + mArrivalPending = false; + // What the teleport freed waits in the buffer pool otherwise. Not before now: the arrival's own geometry, built + // meanwhile, takes those stores back. + LLVertexBuffer::trimPoolSoon(); + if (retire_departed) + { + chooseRegions(); + } + } + + if (!retire_departed) + { + // the region being retired, if any, is finished; no other is started + mQueue.clear(); + } + + if (!mRegionp) + { + startNext(); + } + if (mRegionp) + { + killSome(); + } +} + +void ALRegionRetirement::forget(LLViewerRegion* regionp) +{ + if (regionp == mRegionp) + { + mRegionp = nullptr; + mObjects.clear(); + mKilledIds.clear(); + } +} + +void ALRegionRetirement::watchArrival() +{ + if (gAgent.getTeleportState() != LLAgent::TELEPORT_NONE) + { + mTeleporting = true; + mArrivalPending = false; + return; + } + if (mTeleporting) + { + mTeleporting = false; + mArrivalPending = true; + mArrivalTime = LLFrameTimer::getTotalSeconds(); + } +} + +void ALRegionRetirement::chooseRegions() +{ + for (LLViewerRegion* regionp : LLWorld::getInstance()->getRegionList()) + { + if (!kept(regionp) && std::find(mQueue.begin(), mQueue.end(), regionp->getHandle()) == mQueue.end()) + { + mQueue.push_back(regionp->getHandle()); + } + } +} + +void ALRegionRetirement::startNext() +{ + while (!mRegionp && !mQueue.empty()) + { + const U64 handle = mQueue.front(); + mQueue.pop_front(); + + // The agent may have moved since the regions were chosen. + LLViewerRegion* regionp = LLWorld::getInstance()->getRegionFromHandle(handle); + if (!regionp || kept(regionp)) + { + continue; + } + + mRegionp = regionp; + mRegionp->setRetiring(true); + mObjects.clear(); + mKilledIds.clear(); + gObjectList.getRegionObjects(mRegionp, mObjects); + mObjectCount = mObjects.size(); + mStartTime = LLFrameTimer::getTotalSeconds(); + mKilledTime = mStartTime; + mFrames = 0; + LL_INFOS("RegionRetirement") << "Retiring " << mRegionp->getName() << " (" << mRegionp->getHost() << "), " + << mObjectCount << " objects" << LL_ENDL; + } +} + +void ALRegionRetirement::killSome() +{ + // The agent or the camera came near it, or draw distance grew, while it was being retired. What was killed comes + // back: the cached objects from the region's cache as its idle update resumes (a cached object's drawable hands + // its entry back as it dies), and every one from the simulator, asked again as a cache miss is. + if (kept(mRegionp)) + { + LL_WARNS("RegionRetirement") << mRegionp->getName() << " is near the view again while being retired; keeping it" + << " and asking again for the " << mKilledIds.size() << " objects killed" << LL_ENDL; + mRegionp->setRetiring(false); + for (U32 local_id : mKilledIds) + { + mRegionp->addCacheMissFull(local_id); + } + mRegionp = nullptr; + mObjects.clear(); + mKilledIds.clear(); + return; + } + + ++mFrames; + if (!mObjects.empty()) + { + const F64 start = LLTimer::getTotalSeconds(); + bool examined = false; + while (!mObjects.empty() && (!examined || LLTimer::getTotalSeconds() - start < KILL_BUDGET_SECONDS)) + { + examined = true; + LLPointer objectp = std::move(mObjects.back()); + mObjects.pop_back(); + // Roots only: a root's death takes its children and its control avatar in the same call, so no frame sees + // a live linkset with dead parts. What no root here takes, removeRegion's killObjects does. + const LLVOAvatar* avatarp = objectp->asAvatar(); + if (!objectp->isDead() && objectp->getRegion() == mRegionp && !objectp->getParent() + && !(avatarp && avatarp->isControlAvatar())) + { + collect_local_ids(objectp, mKilledIds); + gObjectList.noteReleasedTextures(objectp, true); + gObjectList.killObject(objectp); + } + } + if (mObjects.empty()) + { + mKilledTime = LLFrameTimer::getTotalSeconds(); + } + return; + } + + if (gObjectList.getNumDeadObjects() == 0 || LLFrameTimer::getTotalSeconds() - mKilledTime > MAX_DRAIN_WAIT_SECONDS) + { + finish(); + } +} + +void ALRegionRetirement::finish() +{ + LLViewerRegion* regionp = mRegionp; + mRegionp = nullptr; + mObjects.clear(); + mKilledIds.clear(); + if (!regionp || regionp == gAgent.getRegion()) + { + if (regionp) + { + regionp->setRetiring(false); + } + return; + } + + const std::string name = regionp->getName(); + const LLHost host = regionp->getHost(); + const F64 began = LLTimer::getTotalSeconds(); + regionp->saveObjectCache(); + const F64 saved = LLTimer::getTotalSeconds(); + LLWorld::getInstance()->removeRegion(host); + // As DisableSimulator does: what the simulator still sends is dropped by the message system, not handled for a + // region that is gone. + gMessageSystem->disableCircuit(host); + const F64 removed = LLTimer::getTotalSeconds(); + + LL_INFOS("RegionRetirement") << "Retired " << name << ": " << mObjectCount << " objects retired over " << mFrames + << " frames, removed " << (LLFrameTimer::getTotalSeconds() - mStartTime) + << " s after it began; object cache saved in " << (saved - began) * 1000.0 + << " ms, region removed in " << (removed - saved) * 1000.0 << " ms" << LL_ENDL; +} diff --git a/indra/newview/alregionretirement.h b/indra/newview/alregionretirement.h new file mode 100644 index 0000000000..a847876890 --- /dev/null +++ b/indra/newview/alregionretirement.h @@ -0,0 +1,115 @@ +/** + * @file alregionretirement.h + * @brief ALRegionRetirement: removing the regions a teleport left behind, once the arrival has settled + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llpointer.h" +#include "v3dmath.h" + +#include +#include +#include +#include + +class LLViewerObject; +class LLViewerRegion; + +// After a teleport the region left behind stays until its simulator sends DisableSimulator, about a minute later, +// with its objects, avatars, terrain and probes. Once the arrival has settled, every region the agent is neither in +// nor near is retired: marked so that its simulator creates no new objects, its objects killed a frame's budget at a +// time, then removed, and its circuit closed, as DisableSimulator removes one. One that comes near the view again +// meanwhile is kept, and what was killed of it is asked for again. +class ALRegionRetirement +{ +public: + // An arrival has settled this long after it at the earliest, and this long at the latest whatever the queues. + static constexpr F64 MIN_SETTLE_SECONDS = 5.0; + static constexpr F64 MAX_SETTLE_SECONDS = 15.0; + // Below these the texture fetcher and the create queue count as calm. + static constexpr S32 CALM_FETCH_REQUESTS = 32; + static constexpr S32 CALM_CREATE_QUEUE = 8; + + // A region stays when it is the agent's, a neighbour connected to it, or when its square comes within draw + // distance plus one region width of the agent in the ground plane. + static bool keeps(bool agent_region, bool connected_neighbour, const LLVector3d& region_origin, F64 region_width, + const LLVector3d& agent_pos, F64 draw_distance) + { + if (agent_region || connected_neighbour) + { + return true; + } + const F64 dx = std::max({ region_origin.mdV[VX] - agent_pos.mdV[VX], 0.0, + agent_pos.mdV[VX] - (region_origin.mdV[VX] + region_width) }); + const F64 dy = std::max({ region_origin.mdV[VY] - agent_pos.mdV[VY], 0.0, + agent_pos.mdV[VY] - (region_origin.mdV[VY] + region_width) }); + const F64 reach = std::max(draw_distance, 0.0) + region_width; + return dx * dx + dy * dy <= reach * reach; + } + + static bool settled(F64 seconds_since_arrival, S32 fetch_requests, S32 create_queue) + { + if (seconds_since_arrival < MIN_SETTLE_SECONDS) + { + return false; + } + return seconds_since_arrival >= MAX_SETTLE_SECONDS + || (fetch_requests <= CALM_FETCH_REQUESTS && create_queue <= CALM_CREATE_QUEUE); + } + + ALRegionRetirement(); + ~ALRegionRetirement(); + ALRegionRetirement(const ALRegionRetirement&) = delete; + ALRegionRetirement& operator=(const ALRegionRetirement&) = delete; + + // Once a frame, before the regions' own updates. + void update(); + + // regionp is about to be deleted, by whatever path. + void forget(LLViewerRegion* regionp); + +private: + void watchArrival(); + void chooseRegions(); + void startNext(); + void killSome(); + // Removes the region being retired now, whatever is left of it. + void finish(); + + bool mTeleporting = false; + bool mArrivalPending = false; + F64 mArrivalTime = 0.0; + + // handles of the regions waiting their turn + std::deque mQueue; + + // the region being retired, and what of it is left to kill + LLViewerRegion* mRegionp = nullptr; + std::vector> mObjects; + // the local ids of every object killed so far, asked of the simulator again if the retirement is called off + std::vector mKilledIds; + size_t mObjectCount = 0; + F64 mStartTime = 0.0; + F64 mKilledTime = 0.0; + U32 mFrames = 0; +}; diff --git a/indra/newview/app_settings/settings_alchemy.xml b/indra/newview/app_settings/settings_alchemy.xml index d91d98b045..40e80e44b1 100644 --- a/indra/newview/app_settings/settings_alchemy.xml +++ b/indra/newview/app_settings/settings_alchemy.xml @@ -2872,6 +2872,17 @@ Value 4 + AlchemyRetireDepartedRegions + + Comment + Once a teleport's arrival has settled, remove the regions it left behind that are neither the agent's, a neighbour of it, nor within draw distance plus a region width, instead of keeping them until their simulators disconnect them about a minute later. Their objects are killed a few each frame first. + Persist + 1 + Type + Boolean + Value + 1 + AlchemyRevokeObjectPerms Comment diff --git a/indra/newview/llappviewer.cpp b/indra/newview/llappviewer.cpp index 62e64ac220..d33d7f29be 100644 --- a/indra/newview/llappviewer.cpp +++ b/indra/newview/llappviewer.cpp @@ -32,6 +32,7 @@ // Viewer includes #include "llversioninfo.h" +#include "llvertexbuffer.h" #include "llfeaturemanager.h" #include "lluictrlfactory.h" #include "lltexteditor.h" @@ -5833,11 +5834,15 @@ void LLAppViewer::idle() { LLDrawable::cleanupDeadDrawables(); } + // what the dead drawables' geometry left in the buffer pool, after a region went + LLVertexBuffer::updatePoolTrim(); } // - // After this point, in theory we should never see a dead object - // in the various object/drawable lists. + // After this point, in theory we should never see a dead drawable + // in the drawable lists. The object list keeps dead objects until + // cleanDeadObjects reaches them, a frame's budget at a time; every + // lookup lost them when they died. // ////////////////////////////////////// diff --git a/indra/newview/llface.cpp b/indra/newview/llface.cpp index f564e288e9..340d2ddfa3 100644 --- a/indra/newview/llface.cpp +++ b/indra/newview/llface.cpp @@ -2105,6 +2105,21 @@ bool LLFace::calcPixelArea(F32& cos_angle_to_view_dir, F32& radius) LLViewerCamera* camera = LLViewerCamera::getInstance(); + // An active drawable's face extents are in the drawable's frame: its box in agent space is theirs transformed. + LLVector4a box_center = center; + LLVector4a box_size = size; + if (!isState(LLFace::RIGGED) && mDrawablep && !mDrawablep->isStatic() + && mVObjp.notNull() && mVObjp->getPartitionType() != LLViewerRegion::PARTITION_PARTICLE) + { + LLVector4a box[2]; + matMulBoundBox(getRenderMatrix(), mExtents, box); + box_center.setAdd(box[0], box[1]); + box_center.mul(0.5f); + box_size.setSub(box[1], box[0]); + box_size.mul(0.5f); + } + mBoxInFrustum = camera->AABBInFrustum(box_center, box_size) != 0; + F32 size_squared = size.dot3(size).getF32(); LLVector4a lookAt; LLVector4a t; @@ -2131,7 +2146,7 @@ bool LLFace::calcPixelArea(F32& cos_angle_to_view_dir, F32& radius) //if has media, check if the face is out of the view frustum. if(hasMedia()) { - if(!camera->AABBInFrustum(center, size)) + if(!mBoxInFrustum) { mImportanceToCamera = 0.f ; return false ; diff --git a/indra/newview/llface.h b/indra/newview/llface.h index 7d11d8a923..51404bb65a 100644 --- a/indra/newview/llface.h +++ b/indra/newview/llface.h @@ -268,6 +268,8 @@ class alignas(16) LLFace // true if face was recently in the main camera frustum according to LLViewerTextureList updates bool mInFrustum = false; + // true if the face's box was in the main camera's frustum when calcPixelArea last measured it + bool mBoxInFrustum = false; // value of gFrameCount the last time the face was touched by LLViewerTextureList::updateImageDecodePriority U32 mLastTextureUpdate = 0; diff --git a/indra/newview/llreflectionmap.cpp b/indra/newview/llreflectionmap.cpp index 9f7bc0d48d..e578e0d98d 100644 --- a/indra/newview/llreflectionmap.cpp +++ b/indra/newview/llreflectionmap.cpp @@ -373,6 +373,11 @@ bool LLReflectionMap::isRelevant() const return false; } + if (!mInReach) + { // wholly beyond draw distance, as is every probe of the region a teleport left + return false; + } + if (mViewerObject && probe_level > 0) { // not an automatic probe return true; diff --git a/indra/newview/llreflectionmap.h b/indra/newview/llreflectionmap.h index 996925d406..52541178f5 100644 --- a/indra/newview/llreflectionmap.h +++ b/indra/newview/llreflectionmap.h @@ -144,6 +144,9 @@ class alignas(16) LLReflectionMap : public LLRefCount // would be treated as one until the manager deletes it. bool mOrphaned = false; + // its influence sphere comes within draw distance of the camera (ALProbeReach), as the manager found this frame + bool mInReach = true; + // fade in parameter for this probe F32 mFadeIn = 0.f; diff --git a/indra/newview/llreflectionmapmanager.cpp b/indra/newview/llreflectionmapmanager.cpp index 2596e3f86d..ca52623f26 100644 --- a/indra/newview/llreflectionmapmanager.cpp +++ b/indra/newview/llreflectionmapmanager.cpp @@ -35,6 +35,8 @@ #include +#include "alprobereach.h" +#include "llagentcamera.h" #include "llviewercamera.h" #include "llspatialpartition.h" #include "llviewerregion.h" @@ -301,40 +303,12 @@ void LLReflectionMapManager::update() { U32 probe_count_temp = mDynamicProbeCount; - if (mRenderReflectionProbeDynamicAllocation > -1) - { - if (mRenderReflectionProbeLevel == 0) - { - mDynamicProbeCount = 1; - } - else if (mRenderReflectionProbeLevel == 1) - { - mDynamicProbeCount = (U32)mProbes.size(); - } - else if (mRenderReflectionProbeLevel == 2) - { - mDynamicProbeCount = llmax((U32)mProbes.size(), 128); - } - else - { - mDynamicProbeCount = 256; - } - - if (mRenderReflectionProbeDynamicAllocation > 1) - { - // Round mDynamicProbeCount to the nearest increment of 16 - mDynamicProbeCount = ((mDynamicProbeCount + mRenderReflectionProbeDynamicAllocation / 2) / mRenderReflectionProbeDynamicAllocation) * 16; - mDynamicProbeCount = llclamp(mDynamicProbeCount, 1, mRenderReflectionProbeCount); - } - else - { - mDynamicProbeCount = llclamp(mDynamicProbeCount + mRenderReflectionProbeDynamicAllocation, 1, mRenderReflectionProbeCount); - } - } - else - { - mDynamicProbeCount = mRenderReflectionProbeCount; - } + // Sized from the probes that matter, not every probe registered: a far region's, or the one a teleport left, + // held slots it could not use. + mDynamicProbeCount = ALProbeReach::dynamicProbeCount(mRenderReflectionProbeLevel, + mRenderReflectionProbeDynamicAllocation, + mRelevantProbeCount, + mRenderReflectionProbeCount); mDynamicProbeCount = llmin(mDynamicProbeCount, LL_MAX_REFLECTION_PROBE_COUNT); @@ -405,6 +379,26 @@ void LLReflectionMapManager::update() return; } + // Probes track the viewer objects they are attached to, then which of them the view can reach this frame is worked + // out, before anything this frame asks whether one is relevant. The distance measured here is the one the sort + // below orders by. + { + const F32 draw_distance = gAgentCamera.mDrawDistance; + U32 relevant = 0; + for (auto& probe : mProbes) + { + probe->syncToViewerObject(); + + LLVector4a d; + d.setSub(camera_pos, probe->mOrigin); + probe->mDistance = d.getLength3().getF32() - probe->mRadius; + probe->mInReach = probe == mDefaultProbe + || ALProbeReach::inReach(probe->mDistance, draw_distance, probe->mInReach); + relevant += (probe == mDefaultProbe || probe->isRelevant()) ? 1 : 0; + } + mRelevantProbeCount = ALProbeReach::stepRelevant(relevant, mRelevantProbeCount); + } + bool did_update = false; @@ -431,8 +425,7 @@ void LLReflectionMapManager::update() } } - // Make probes track the viewer objects they are attached to, then work out which automatic - // probes a manual probe has made redundant. + // Work out which automatic probes a manual probe has made redundant. // // An automatic probe is a fallback for space nobody placed a probe in. Where a placed probe // has swallowed its whole influence volume it can no longer change a single pixel -- see @@ -454,8 +447,6 @@ void LLReflectionMapManager::update() for (auto& probe : mProbes) { - probe->syncToViewerObject(); - if (probe != mDefaultProbe && probe->mViewerObject && probe->mComplete && probe->mCubeIndex != -1 && probe->mFadeIn >= 1.f && probe->isRelevant()) { @@ -519,10 +510,7 @@ void LLReflectionMapManager::update() probe->mDistance = FLT_MAX; continue; } - - LLVector4a d; - d.setSub(camera_pos, probe->mOrigin); - probe->mDistance = d.getLength3().getF32() - probe->mRadius; + // a relevant probe keeps the distance its reach was measured at } std::sort(mProbes.begin()+1, mProbes.end(), CompareProbeDistance()); diff --git a/indra/newview/llreflectionmapmanager.h b/indra/newview/llreflectionmapmanager.h index ed2fb0ef80..c04175cda8 100644 --- a/indra/newview/llreflectionmapmanager.h +++ b/indra/newview/llreflectionmapmanager.h @@ -289,6 +289,8 @@ class alignas(16) LLReflectionMapManager U32 mReflectionProbeCount; U32 mDynamicProbeCount; + // the probes relevant last frame, the default probe among them, in ALProbeReach's steps, for dynamic allocation + U32 mRelevantProbeCount = 0; // cached settings from gSavedSettings S32 mRenderReflectionProbeDetail = -1; diff --git a/indra/newview/llviewerobject.h b/indra/newview/llviewerobject.h index 636579863d..d5494ffcec 100644 --- a/indra/newview/llviewerobject.h +++ b/indra/newview/llviewerobject.h @@ -265,6 +265,11 @@ class LLViewerObject U32 getCRC() const { return mTotalCRC; } S32 getListIndex() const { return mListIndex; } void setListIndex(S32 idx) { mListIndex = idx; } + // Position in LLViewerObjectList's list of every object (ALObjectRoster). + S32 getObjectListIndex() const { return mObjectListIndex; } + void setObjectListIndex(S32 idx) { mObjectListIndex = idx; } + bool isPendingRemoval() const { return mPendingRemoval; } + void setPendingRemoval() { mPendingRemoval = true; } virtual bool isFlexible() const { return false; } virtual bool isSculpted() const { return false; } @@ -804,6 +809,12 @@ class LLViewerObject // index into LLViewerObjectList::mActiveObjects or -1 if not in list S32 mListIndex; + // index into LLViewerObjectList::mObjects or -1 if not in it + S32 mObjectListIndex = -1; + + // dead, and queued to leave LLViewerObjectList::mObjects + bool mPendingRemoval = false; + // last index data for mIndexAndLocalIDToUUID U32 mRegionIndex; diff --git a/indra/newview/llviewerobjectlist.cpp b/indra/newview/llviewerobjectlist.cpp index fee6d58157..08ed753199 100644 --- a/indra/newview/llviewerobjectlist.cpp +++ b/indra/newview/llviewerobjectlist.cpp @@ -67,6 +67,7 @@ #include "llmeshrepository.h" #include "u64.h" #include "llviewertexturelist.h" +#include "llfetchedgltfmaterial.h" #include "lldatapacker.h" // [SL:KB] - Patch: World-Derender | Checked: 2011-12-15 (Catznip-3.2.1) #include "alderenderlist.h" @@ -102,7 +103,6 @@ LLViewerObjectList::LLViewerObjectList() { mCurLazyUpdateIndex = 0; mCurBin = 0; - mNumDeadObjects = 0; mNumOrphans = 0; mNumNewObjects = 0; mWasPaused = false; @@ -122,7 +122,6 @@ void LLViewerObjectList::destroy() resetObjectBeacons(); mActiveObjects.clear(); - mDeadObjects.clear(); mMapObjects.clear(); mUUIDObjectMap.clear(); } @@ -464,6 +463,12 @@ void LLViewerObjectList::processObjectUpdate(LLMessageSystem *mesgsys, return; } + if (regionp->isRetiring()) + { + // its objects are being killed, and nothing new of it is wanted + return; + } + // A variable block can be as large as the packet that carried it, and the // message reader never yields a packet larger than MAX_BUFFER_SIZE. U8 compressed_dpbuffer[MAX_BUFFER_SIZE]; @@ -637,16 +642,6 @@ void LLViewerObjectList::processObjectUpdate(LLMessageSystem *mesgsys, mesgsys->getU8Fast(_PREHASH_ObjectData, _PREHASH_PCode, pcode, i); } -#ifdef IGNORE_DEAD - if (mDeadObjects.find(fullid) != mDeadObjects.end()) - { - mNumDeadObjectUpdates++; - //LL_INFOS() << "update for a dead object:" << fullid << LL_ENDL; - recorder.objectUpdateFailure(); - continue; - } -#endif - // [SL:KB] - Patch: World-Derender | Checked: 2012-06-08 (Catznip-3.3) // Don't recreate derendered objects (update the core object information so we'll have enough information to rerequest it later if needed) if ( (OUT_FULL == update_type) || (OUT_FULL_COMPRESSED == update_type) ) @@ -752,6 +747,12 @@ void LLViewerObjectList::processCachedObjectUpdate(LLMessageSystem *mesgsys, return; } + if (regionp->isRetiring()) + { + // its objects are being killed, and nothing new of it is wanted + return; + } + LLViewerStatsRecorder& recorder = LLViewerStatsRecorder::instance(); for (S32 i = 0; i < num_objects; i++) @@ -783,9 +784,12 @@ void LLViewerObjectList::processCachedObjectUpdate(LLMessageSystem *mesgsys, void LLViewerObjectList::dirtyAllObjectInventory() { - for (vobj_list_t::iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) + for (vobj_list_t::const_iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) { - (*iter)->dirtyInventory(); + if (!(*iter)->isDead()) + { + (*iter)->dirtyInventory(); + } } } @@ -1106,7 +1110,8 @@ void LLViewerObjectList::update(LLAgent &agent) } */ - sample(LLStatViewer::NUM_OBJECTS, mObjects.size()); + // the dead waiting for cleanDeadObjects are no longer objects + sample(LLStatViewer::NUM_OBJECTS, mObjects.size() - llmin(mObjects.size(), mObjects.queued())); sample(LLStatViewer::NUM_ACTIVE_OBJECTS, idle_count); } @@ -1393,9 +1398,13 @@ void LLViewerObjectList::fetchPhisicsFlagsCoro(std::string url) void LLViewerObjectList::clearDebugText() { - for (vobj_list_t::iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) + for (vobj_list_t::const_iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) { - (*iter)->restoreHudText(); + // a dead object would get its floating text back until it is destroyed + if (!(*iter)->isDead()) + { + (*iter)->restoreHudText(); + } } } @@ -1404,15 +1413,11 @@ void LLViewerObjectList::cleanupReferences(LLViewerObject *objectp) { LL_PROFILE_ZONE_SCOPED_CATEGORY_NETWORK; - bool new_dead_object = true; - if (mDeadObjects.find(objectp->mID) != mDeadObjects.end()) + // Queued by the object itself, not by its id: an id killed, created again and killed again before the queue + // reaches it is two objects, each queued once. + if (!mObjects.queueRemoval(objectp)) { LL_INFOS() << "Object " << objectp->mID << " already on dead list!" << LL_ENDL; - new_dead_object = false; - } - else - { - mDeadObjects.insert(objectp->mID); } // Cleanup any references we have to this object @@ -1444,12 +1449,7 @@ void LLViewerObjectList::cleanupReferences(LLViewerObject *objectp) removeFromMap(objectp); } - // Don't clean up mObject references, these will be cleaned up more efficiently later! - - if(new_dead_object) - { - mNumDeadObjects++; - } + // mObjects keeps the object until cleanDeadObjects reaches it in the queue. } bool LLViewerObjectList::killObject(LLViewerObject *objectp) @@ -1493,18 +1493,65 @@ void LLViewerObjectList::killObjects(LLViewerRegion *regionp) LLViewerObject *objectp; - for (vobj_list_t::iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) + for (vobj_list_t::const_iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) { objectp = *iter; if (objectp->mRegionp == regionp) { + noteReleasedTextures(objectp, false); killObject(objectp); } } - // Have to clean right away because the region is becoming invalid. - cleanDeadObjects(false); + // The region's dead objects leave now, whatever the budget: its partitions, which hold their drawables, go + // next, and so does the region their mRegionp points at. + mObjects.drainIf([regionp](LLViewerObject* objectp) { return objectp->mRegionp == regionp; }); +} + +void LLViewerObjectList::noteReleasedTextures(LLViewerObject* objectp, bool with_children) +{ + for (U8 te = 0; te < objectp->getNumTEs(); ++te) + { + for (LLViewerTexture* tex : { objectp->getTEImage(te), objectp->getTENormalMap(te), objectp->getTESpecularMap(te) }) + { + if (LLViewerFetchedTexture* fetched = LLViewerTextureManager::staticCastToFetchedTexture(tex)) + { + fetched->noteReleasedWithRegion(); + } + } + const LLTextureEntry* tep = objectp->getTE(te); + const auto* mat = tep ? static_cast(tep->getGLTFRenderMaterial()) : nullptr; + if (mat) + { + for (const LLPointer& tex : + { mat->mBaseColorTexture, mat->mNormalTexture, mat->mMetallicRoughnessTexture, mat->mEmissiveTexture }) + { + if (tex.notNull()) + { + tex->noteReleasedWithRegion(); + } + } + } + } + if (with_children) + { + for (LLViewerObject* childp : objectp->getChildren()) + { + noteReleasedTextures(childp, true); + } + } +} + +void LLViewerObjectList::getRegionObjects(const LLViewerRegion* regionp, std::vector>& objects) const +{ + for (const LLPointer& objectp : mObjects) + { + if (objectp->mRegionp == regionp && !objectp->isDead()) + { + objects.push_back(objectp); + } + } } void LLViewerObjectList::killAllObjects() @@ -1526,7 +1573,7 @@ void LLViewerObjectList::killAllObjects() mMapObjects.clear(); LLViewerObject *objectp; - for (vobj_list_t::iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) + for (vobj_list_t::const_iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) { objectp = *iter; objectp->setOnActiveList(false); @@ -1542,8 +1589,6 @@ void LLViewerObjectList::killAllObjects() // Direct clear instead of cleanDeadObjects - all objects are already dead mObjects.clear(); - mDeadObjects.clear(); - mNumDeadObjects = 0; if(!mObjects.empty()) { @@ -1552,59 +1597,24 @@ void LLViewerObjectList::killAllObjects() } } -void LLViewerObjectList::cleanDeadObjects(bool use_timer) +void LLViewerObjectList::cleanDeadObjects() { - if (!mNumDeadObjects) + if (!mObjects.queued()) { - // No dead objects, don't need to scan object list. return; } LL_PROFILE_ZONE_SCOPED; - S32 num_removed = 0; - LLViewerObject *objectp; - - vobj_list_t::reverse_iterator target = mObjects.rbegin(); - - vobj_list_t::iterator iter = mObjects.begin(); - for ( ; iter != mObjects.end(); ) - { - // Scan for all of the dead objects and put them all on the end of the list with no ref count ops - objectp = *iter; - if (objectp == NULL) - { //we caught up to the dead tail - break; - } - - if (objectp->isDead()) - { - LLPointer::swap(*iter, *target); - *target = NULL; - ++target; - num_removed++; - - if (num_removed == mNumDeadObjects || iter->isNull()) - { - // We've cleaned up all of the dead objects or caught up to the dead tail - break; - } - } - else - { - ++iter; - } - } - - llassert(num_removed == mNumDeadObjects); - - //erase as a block - mObjects.erase(mObjects.begin()+(mObjects.size()-mNumDeadObjects), mObjects.end()); - - // We've cleaned the global object list, now let's do some paranoia testing on objects - // before blowing away the dead list. - mDeadObjects.clear(); - mNumDeadObjects = 0; + // Each destructor releases the object's volume, texture references and the rest it holds, so the deaths of a + // crowd or a region are spread over frames. Every object is out of every lookup from the moment it died. + constexpr F64 DEAD_OBJECT_BUDGET = 0.0005; + const size_t min_count = ALObjectRoster::drainFloor(mObjects.queued()); + const F64 start = LLTimer::getTotalSeconds(); + const size_t destroyed = mObjects.drain( + [&](size_t removed) { return removed < min_count || LLTimer::getTotalSeconds() - start < DEAD_OBJECT_BUDGET; }); + LL_PROFILE_ZONE_NUM(destroyed); + LL_PROFILE_PLOT("ObjectList: dead objects queued", (int64_t)mObjects.queued()); } void LLViewerObjectList::removeFromActiveList(LLViewerObject* objectp) @@ -1778,7 +1788,7 @@ void LLViewerObjectList::shiftObjects(const LLVector3 &offset) LLViewerObject *objectp; - for (vobj_list_t::iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) + for (vobj_list_t::const_iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) { objectp = *iter; // There could be dead objects on the object list, so don't update stuff if the object is dead. @@ -1800,7 +1810,7 @@ void LLViewerObjectList::shiftObjects(const LLVector3 &offset) void LLViewerObjectList::repartitionObjects() { - for (vobj_list_t::iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) + for (vobj_list_t::const_iterator iter = mObjects.begin(); iter != mObjects.end(); ++iter) { LLViewerObject* objectp = *iter; if (!objectp->isDead()) @@ -2066,7 +2076,7 @@ LLViewerObject *LLViewerObjectList::createObjectViewer(const LLPCode pcode, LLVi mUUIDObjectMap[fullid] = objectp; mPresenceSignal(fullid, true); - mObjects.push_back(objectp); + mObjects.add(objectp); updateActive(objectp); @@ -2094,7 +2104,7 @@ LLViewerObject *LLViewerObjectList::createObjectFromCache(const LLPCode pcode, L regionp->getHost().getAddress(), regionp->getHost().getPort(), objectp); - mObjects.push_back(objectp); + mObjects.add(objectp); updateActive(objectp); @@ -2135,7 +2145,7 @@ LLViewerObject *LLViewerObjectList::createObject(const LLPCode pcode, LLViewerRe gMessageSystem->getSenderPort(), objectp); - mObjects.push_back(objectp); + mObjects.add(objectp); updateActive(objectp); diff --git a/indra/newview/llviewerobjectlist.h b/indra/newview/llviewerobjectlist.h index e486d3290d..cefd2c6f34 100644 --- a/indra/newview/llviewerobjectlist.h +++ b/indra/newview/llviewerobjectlist.h @@ -39,6 +39,7 @@ // project includes #include "llviewerobject.h" +#include "alobjectroster.h" #include "lleventcoro.h" #include "llcoros.h" @@ -79,6 +80,11 @@ class LLViewerObjectList bool killObject(LLViewerObject *objectp); void killObjects(LLViewerRegion *regionp); // Kill all objects owned by a particular region. + // Marks the textures objectp shows, and with_children its children's, as let go with a region (the lazy flush + // takes them at once). + void noteReleasedTextures(LLViewerObject* objectp, bool with_children); + // The live objects of regionp, for a caller that kills them a few at a time. + void getRegionObjects(const LLViewerRegion* regionp, std::vector>& objects) const; // An object into the list or out of it, by its id, as it happens, for // whoever follows objects by id to hear of it rather than poll. One @@ -88,7 +94,9 @@ class LLViewerObjectList boost::signals2::connection onPresence(const presence_signal_t::slot_type& slot) { return mPresenceSignal.connect(slot); } void killAllObjects(); - void cleanDeadObjects(const bool use_timer = true); // Clean up the dead object list. + // Destroys dead objects, oldest first, as many as a frame's budget allows, and at least a share of the queue + // that grows with it. + void cleanDeadObjects(); // Simulator and viewer side object updates... void processUpdateCore(LLViewerObject* objectp, void** data, U32 block, const EObjectUpdateType update_type, @@ -142,6 +150,8 @@ class LLViewerObjectList void updateAvatarVisibility(); inline S32 getNumObjects() { return (S32) mObjects.size(); } + // Dead objects still in the list, waiting for cleanDeadObjects. + inline S32 getNumDeadObjects() const { return (S32) mObjects.queued(); } inline S32 getNumActiveObjects() { return (S32) mActiveObjects.size(); } void addToMap(LLViewerObject *objectp); @@ -205,7 +215,6 @@ class LLViewerObjectList S32 mNumUnknownUpdates; S32 mNumDeadObjectUpdates; - S32 mNumDeadObjects; protected: std::vector mOrphanParents; // LocalID/ip,port of orphaned objects std::vector mOrphanChildren; // UUID's of orphaned objects @@ -214,13 +223,12 @@ class LLViewerObjectList typedef std::vector > vobj_list_t; - vobj_list_t mObjects; + // Every object, the dead among them until cleanDeadObjects destroys them. + ALObjectRoster mObjects; std::vector > mActiveObjects; vobj_list_t mMapObjects; - uuid_set_t mDeadObjects; - boost::unordered_map> mUUIDObjectMap; presence_signal_t mPresenceSignal; diff --git a/indra/newview/llviewerregion.h b/indra/newview/llviewerregion.h index ff62168ecf..a2d07d0070 100644 --- a/indra/newview/llviewerregion.h +++ b/indra/newview/llviewerregion.h @@ -145,6 +145,10 @@ class LLViewerRegion: public LLCapabilityProvider // implements this interface bool isAlive(); // can become false if circuit disconnects + // Left behind by a teleport and being removed (ALRegionRetirement): its simulator creates no new objects here. + bool isRetiring() const { return mRetiring; } + void setRetiring(bool retiring) { mRetiring = retiring; } + void setWaterHeight(F32 water_level); F32 getWaterHeight() const; @@ -610,6 +614,7 @@ class LLViewerRegion: public LLCapabilityProvider // implements this interface bool mCacheDirty; bool mGLTFOverridesDirty; bool mAlive; // can become false if circuit disconnects + bool mRetiring = false; bool mSimulatorFeaturesReceived; bool mReleaseNotesRequested; bool mDead; //if true, this region is in the process of deleting. diff --git a/indra/newview/llviewertexture.cpp b/indra/newview/llviewertexture.cpp index 49124472c1..32007f0a1e 100644 --- a/indra/newview/llviewertexture.cpp +++ b/indra/newview/llviewertexture.cpp @@ -439,10 +439,45 @@ void LLViewerTextureManager::init() gTextureManagerBridgep = new LLViewerTextureManagerBridge; } +namespace +{ + // The textures the emergency purge has yet to visit, a frame's budget at a time. Keys, not references: a + // reference held here would count an unreferenced texture as referenced in every other visit meanwhile, and + // restart its lazy flush wait. + std::vector sEmergencyPurge; + + void continue_emergency_purge() + { + if (sEmergencyPurge.empty()) + { + return; + } + LL_PROFILE_ZONE_NAMED_CATEGORY_TEXTURE("emergency purge"); + constexpr F64 PURGE_BUDGET = 0.001; + constexpr U32 MIN_VISITS = 64; + const F64 start = LLTimer::getTotalSeconds(); + U32 visited = 0; + while (!sEmergencyPurge.empty() && (visited < MIN_VISITS || LLTimer::getTotalSeconds() - start < PURGE_BUDGET)) + { + // held for its visit only, as the update loop's copy holds what it visits (min_refs counts on that) + LLPointer image = gTextureList.findImage(sEmergencyPurge.back()); + sEmergencyPurge.pop_back(); + if (image) + { + gTextureList.updateImageDecodePriority(image, false /*will modify gTextureList otherwise!*/); + } + ++visited; + } + LL_PROFILE_ZONE_NUM(visited); + } +} + void LLViewerTextureManager::cleanup() { stop_glerror(); + sEmergencyPurge.clear(); + delete gTextureManagerBridgep; LLImageGL::sDefaultGLTexture = NULL; LLViewerTexture::sNullImagep = NULL; @@ -558,16 +593,22 @@ void LLViewerTexture::updateClass() if (is_sys_low || over_pct > 2.f) { // if we're low on system memory, emergency purge off screen textures to avoid a death spiral + // Every texture visited at the slammed bias, over the next frames rather than in this one. LL_WARNS() << "Low system memory detected, emergency downrezzing off screen textures" << LL_ENDL; - for (auto& image : gTextureList) + if (sEmergencyPurge.empty()) { - gTextureList.updateImageDecodePriority(image, false /*will modify gTextureList otherwise!*/); + for (const LLPointer& image : gTextureList) + { + sEmergencyPurge.emplace_back(image->getID(), (ETexListType)image->getTextureListType()); + } } } } was_low = is_low; + continue_emergency_purge(); + if (is_low) { // ramp up discard bias over time to free memory @@ -940,6 +981,9 @@ void LLViewerTexture::resetTextureStats() { mMaxVirtualSize = 0.0f; mMaxVirtualSizeResetCounter = 0; + // The sliced face walk's carried maxima would put the old sizes back on its next visit. + mFaceWalkMax = 0.f; + mFaceWalkRotationMax = 0.f; } //virtual @@ -2204,6 +2248,35 @@ bool LLViewerFetchedTexture::processFetchResults(S32& desired_discard, S32 curre return true; } +void LLViewerFetchedTexture::onAlphaMaskChanged() +{ + if (mGLTexturep.isNull()) + { + return; + } + const bool is_mask = mGLTexturep->getIsAlphaMask(); + + // A face of an alpha pool goes to a mask pass or a blend pass by the answer it was built with: one built by the + // other answer, or while a deferred analysis was out and the texture counted as no mask, stays in the wrong one + // until its group is rebuilt. The pool does not say which pass it took, so every face of one whose pass the answer + // decides is rebuilt; now a mask, only those that can render as one. + for (U32 i = 0; i < mNumFaces[LLRender::DIFFUSE_MAP]; ++i) + { + LLFace* facep = mFaceList[LLRender::DIFFUSE_MAP][i]; + if (!facep || !facep->isInAlphaPool() || (is_mask && !facep->canRenderAsMask())) + { + continue; + } + LLDrawable* drawablep = facep->getDrawable(); + LLSpatialGroup* group = drawablep ? drawablep->getSpatialGroup() : nullptr; + if (group && !group->isDead()) + { + group->dirtyGeom(); + gPipeline.markRebuild(group); + } + } +} + bool LLViewerFetchedTexture::updateFetch() { LL_PROFILE_ZONE_SCOPED_CATEGORY_TEXTURE; @@ -4065,7 +4138,10 @@ F32 LLViewerMediaTexture::getMaxVirtualSize() if(!mMaxVirtualSizeResetCounter) { - addTextureStats(0.f, false);//reset + // A new window: the size goes back to what the faces seen in it ask for, so the interest of media turned + // away from decays and its plugin drops priority. addTextureStats only ever raises it. + mMaxVirtualSize = 0.f; + resetMaxVirtualSizeResetCounter(); } if(mIsPlaying) //media is playing diff --git a/indra/newview/llviewertexture.h b/indra/newview/llviewertexture.h index 2217c9e19a..95562a3f23 100644 --- a/indra/newview/llviewertexture.h +++ b/indra/newview/llviewertexture.h @@ -350,6 +350,23 @@ class LLViewerFetchedTexture : public LLViewerTexture void destroyTexture() ; + // An object showing it died with its region (LLViewerObjectList::noteReleasedTextures): once unreferenced, soon + // after, the lazy flush takes it without its wait. + void noteReleasedWithRegion() { mReleasedWithRegion = (F64)LLFrameTimer::getUptimeSeconds(); } + bool releasedWithRegionWithin(F64 seconds) const + { + return mReleasedWithRegion >= 0.0 && (F64)LLFrameTimer::getUptimeSeconds() - mReleasedWithRegion < seconds; + } + // Still referenced a while after the mark, by something other than the dead objects that set it and are destroyed + // within seconds: a later release is an ordinary one. + void forgetReleasedWithRegion(F64 after_seconds) + { + if (mReleasedWithRegion >= 0.0 && (F64)LLFrameTimer::getUptimeSeconds() - mReleasedWithRegion >= after_seconds) + { + mReleasedWithRegion = -1.0; + } + } + virtual void processTextureStats() ; bool needsAux() const { return mNeedsAux; } @@ -365,6 +382,9 @@ class LLViewerFetchedTexture : public LLViewerTexture bool updateFetch(); + // Rebuilds the groups of faces built into a mask or a blend pass by another answer than the analysis now gives. + void onAlphaMaskChanged() override; + void clearFetchedResults(); //clear all fetched results, for debug use. // Override the computation of discard levels if we know the exact output @@ -503,6 +523,7 @@ class LLViewerFetchedTexture : public LLViewerTexture S32 mMinDiscardLevel; S8 mDesiredDiscardLevel; // The discard level we'd LIKE to have - if we have it and there's space S8 mMinDesiredDiscardLevel; // The minimum discard level we'd like to have + F64 mReleasedWithRegion = -1.0; // LLFrameTimer::getUptimeSeconds() when noteReleasedWithRegion last ran, or -1 bool mNeedsAux; // We need to decode the auxiliary channels bool mHasAux; // We have aux channels diff --git a/indra/newview/llviewertexturelist.cpp b/indra/newview/llviewertexturelist.cpp index a5de0ff6ae..c91d150780 100644 --- a/indra/newview/llviewertexturelist.cpp +++ b/indra/newview/llviewertexturelist.cpp @@ -963,11 +963,23 @@ void LLViewerTextureList::updateImageDecodePriority(LLViewerFetchedTexture* imag F32 cos_angle_to_view_dir; ++faces_walked; + // A face whose group no camera has drawn asks for nothing. Geometry is no sign of it: a group is + // built as soon as an object joins it, seen or not. + LLDrawable* drawable = face->getDrawable(); + LLSpatialGroup* group = drawable ? drawable->getSpatialGroup() : nullptr; + if (!group || !group->getAnyVisible()) + { + continue; + } + if ((gFrameCount - face->mLastTextureUpdate) > 10) { // only call calcPixelArea at most once every 10 frames for a given face // this helps eliminate redundant calls to calcPixelArea for faces that have multiple textures // assigned to them, such as is the case with GLTF materials or Blinn-Phong materials - face->mInFrustum = face->calcPixelArea(cos_angle_to_view_dir, radius); + // Seen: its drawable was drawn this frame or the last, and its own box is in view. + face->mInFrustum = face->calcPixelArea(cos_angle_to_view_dir, radius) + && face->mBoxInFrustum + && drawable->isRecentlyVisible(); face->mLastTextureUpdate = gFrameCount; ++sPixelAreaCalcCount; } @@ -1094,9 +1106,12 @@ void LLViewerTextureList::updateImageDecodePriority(LLViewerFetchedTexture* imag // Flush formatted images using a lazy flush // S32 num_refs = imagep->getNumRefs(); - if (num_refs <= min_refs && flush_images) + if (num_refs <= min_refs) { - if (imagep->getLastReferencedTimer()->getElapsedTimeF32() > lazy_flush_timeout) + // Unreferenced: without flush_images (the emergency purge) it neither goes nor has its wait restarted. + const F64 unreferenced = imagep->getLastReferencedTimer()->getElapsedTimeF64(); + if (flush_images + && (unreferenced > lazy_flush_timeout || imagep->releasedWithRegionWithin(2.0 * lazy_flush_timeout))) { // Remove the unused image from the image list deleteImage(imagep); @@ -1107,6 +1122,8 @@ void LLViewerTextureList::updateImageDecodePriority(LLViewerFetchedTexture* imag { // still referenced outside of image list, reset timer imagep->getLastReferencedTimer()->reset(); + // not by its region's dead objects, which are gone by then (ALRegionRetirement waits at most 5 s for them) + imagep->forgetReleasedWithRegion(10.0); if (imagep->hasSavedRawImage()) { diff --git a/indra/newview/llworld.cpp b/indra/newview/llworld.cpp index abe7c1861d..c11fde4ced 100644 --- a/indra/newview/llworld.cpp +++ b/indra/newview/llworld.cpp @@ -41,6 +41,7 @@ #include "llregionhandle.h" #include "llsurface.h" #include "lltrans.h" +#include "llvertexbuffer.h" #include "llviewercamera.h" #include "llviewertexture.h" #include "llviewertexturelist.h" @@ -130,7 +131,8 @@ LLViewerRegion* LLWorld::addRegion(const U64 ®ion_handle, const LLHost &host) { LLHost old_host = regionp->getHost(); // region already exists! - if (host == old_host && regionp->isAlive()) + // One being retired has lost objects its simulator will not send again. + if (host == old_host && regionp->isAlive() && !regionp->isRetiring()) { // This is a duplicate for the same host and it's alive, don't bother. LL_INFOS() << "Region already exists and is alive, using existing region" << LL_ENDL; @@ -147,6 +149,10 @@ LLViewerRegion* LLWorld::addRegion(const U64 ®ion_handle, const LLHost &host) { LL_WARNS() << "LLWorld::addRegion exists, but isn't alive. Removing old region and creating new" << LL_ENDL; } + else if (regionp->isRetiring()) + { + LL_INFOS() << "LLWorld::addRegion exists, but is being retired. Removing old region and creating new" << LL_ENDL; + } // Save capabilities seed URL seedUrl = regionp->getCapability("Seed"); @@ -268,6 +274,8 @@ void LLWorld::removeRegion(const LLHost &host) from_region_handle(regionp->getHandle(), &x, &y); LL_INFOS() << "Removing region " << x << ":" << y << LL_ENDL; + mRegionRetirement.forget(regionp); + mRegionList.remove(regionp); mActiveRegionList.remove(regionp); mCulledRegionList.remove(regionp); @@ -280,6 +288,9 @@ void LLWorld::removeRegion(const LLHost &host) //llassert_always(!gObjectList.hasMapObjectInRegion(regionp)) ; delete regionp; // Delete last to prevent use after free + + // The geometry only the region held goes now rather than at the pool's next clean. + LLVertexBuffer::trimPoolSoon(); } @@ -681,6 +692,9 @@ void LLWorld::updateRegions(F32 max_update_time) { LLViewerRegion::sLastCameraUpdated = LLViewerOctreeEntryData::getCurrentFrame() + 1; } + // Before the regions' updates, since it may remove one. + mRegionRetirement.update(); + LLViewerRegion::calcNewObjectCreationThrottle(); if(LLViewerRegion::isNewObjectCreationThrottleDisabled()) { @@ -702,7 +716,8 @@ void LLWorld::updateRegions(F32 max_update_time) iter != mRegionList.end(); ++iter) { LLViewerRegion* regionp = *iter; - if(regionp != self_regionp) + // A retiring region creates nothing more from its object cache. + if(regionp != self_regionp && !regionp->isRetiring()) { region_list.insert(regionp); } diff --git a/indra/newview/llworld.h b/indra/newview/llworld.h index b7a74d9e10..35f6c7f7cc 100644 --- a/indra/newview/llworld.h +++ b/indra/newview/llworld.h @@ -39,6 +39,7 @@ #include "llstring.h" #include "llviewerpartsim.h" #include "llviewertexture.h" +#include "alregionretirement.h" #include @@ -215,6 +216,8 @@ class LLWorld final : public LLSimpleton region_remove_signal_t mRegionRemovedSignal; + ALRegionRetirement mRegionRetirement; + // Number of points on edge static const U32 mWidth; diff --git a/indra/newview/tests/alobjectroster_test.cpp b/indra/newview/tests/alobjectroster_test.cpp new file mode 100644 index 0000000000..bb838cc11e --- /dev/null +++ b/indra/newview/tests/alobjectroster_test.cpp @@ -0,0 +1,358 @@ +/** + * @file alobjectroster_test.cpp + * @brief Unit tests for the object list and its queue of objects leaving it + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include + +#include "../alobjectroster.h" + +#include "llrefcount.h" + +#include +#include +#include + +namespace +{ + // An object as the viewer's list sees one: an id the simulator gave it, which a second object can carry after + // the first died, and the region it belongs to. + struct Obj : public LLRefCount + { + static S32 sLive; + static std::vector sDestroyed; // serials, in the order the objects were destroyed + + Obj(U32 id, U32 region = 0) : mId(id), mRegion(region), mSerial(++sSerials) { ++sLive; } + ~Obj() override + { + --sLive; + sDestroyed.push_back(mSerial); + if (mOnDestroy) + { + mOnDestroy(); + } + } + + S32 getObjectListIndex() const { return mIndex; } + void setObjectListIndex(S32 index) { mIndex = index; } + bool isPendingRemoval() const { return mPending; } + void setPendingRemoval() { mPending = true; } + + static U32 sSerials; + + U32 mId; + U32 mRegion; + U32 mSerial; + S32 mIndex = -1; + bool mPending = false; + std::function mOnDestroy; + }; + S32 Obj::sLive = 0; + U32 Obj::sSerials = 0; + std::vector Obj::sDestroyed; + + using Roster = ALObjectRoster; + + struct objectroster_data + { + objectroster_data() + { + Obj::sLive = 0; + Obj::sSerials = 0; + Obj::sDestroyed.clear(); + } + + Roster mRoster; + + Obj* add(U32 id, U32 region = 0) + { + Obj* obj = new Obj(id, region); + REQUIRE(mRoster.add(obj)); + return obj; + } + + // Ids of the objects in the list, sorted: the list's order is not its contract. + std::vector ids() const + { + std::vector out; + for (const LLPointer& obj : mRoster) + { + out.push_back(obj->mId); + } + std::sort(out.begin(), out.end()); + return out; + } + + size_t drainAll() + { + return mRoster.drain([](size_t) { return true; }); + } + + static size_t count(const std::vector& serials, U32 serial) + { + return std::count(serials.begin(), serials.end(), serial); + } + }; + + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: add records positions and holds one reference", "[ALObjectRoster]") + { + Obj* a = add(1); + Obj* b = add(2); + Obj* c = add(3); + + CHECK(mRoster.size() == (size_t)3); + CHECK(a->getObjectListIndex() == 0); + CHECK(b->getObjectListIndex() == 1); + CHECK(c->getObjectListIndex() == 2); + CHECK(mRoster[1] == b); + CHECK(a->getNumRefs() == 1); + CHECK(mRoster.consistent()); + + UNSCOPED_INFO("an object already in a list is refused"); + CHECK_FALSE(mRoster.add(b)); + CHECK(mRoster.size() == (size_t)3); + } + + // Marking only queues: the object stays in the list, where loops still meet it, until a drain reaches it. + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: a marked object stays listed until the queue reaches it", "[ALObjectRoster]") + { + add(1); + Obj* b = add(2); + add(3); + + CHECK(mRoster.queueRemoval(b)); + CHECK(b->isPendingRemoval()); + CHECK(mRoster.queued() == (size_t)1); + CHECK(mRoster.size() == (size_t)3); + CHECK(Obj::sLive == 3); + + UNSCOPED_INFO("marked twice it is queued once"); + CHECK_FALSE(mRoster.queueRemoval(b)); + CHECK(mRoster.queued() == (size_t)1); + + CHECK(drainAll() == (size_t)1); + CHECK(ids() == std::vector{ 1, 3 }); + CHECK(Obj::sLive == 2); + CHECK(mRoster.queued() == (size_t)0); + CHECK(mRoster.consistent()); + } + + // The 2021 timed clean lost count when one id died twice inside a window: its set of ids held the id once while + // two objects carried it, so the second death went uncounted and that dead object stayed in the list. Queued by + // object, each death is its own entry. + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: an id killed and created and killed again leaves twice", "[ALObjectRoster]") + { + Obj* live_before = add(10); + Obj* first = add(7); + const U32 first_serial = first->mSerial; + REQUIRE(mRoster.queueRemoval(first)); + + // the same id again, from the simulator, before any drain + Obj* second = add(7); + const U32 second_serial = second->mSerial; + Obj* live_after = add(11); + REQUIRE(mRoster.queueRemoval(second)); + CHECK(mRoster.queued() == (size_t)2); + + // drained a slice at a time, as frames do + while (mRoster.queued()) + { + mRoster.drain([](size_t removed) { return removed < 1; }); + CHECK(mRoster.consistent()); + } + + CHECK(count(Obj::sDestroyed, first_serial) == (size_t)1); + CHECK(count(Obj::sDestroyed, second_serial) == (size_t)1); + CHECK(Obj::sDestroyed.size() == (size_t)2); + CHECK(ids() == std::vector{ 10, 11 }); + CHECK(live_before->getObjectListIndex() >= 0); + CHECK(live_after->getObjectListIndex() >= 0); + CHECK(mRoster[live_before->getObjectListIndex()] == live_before); + CHECK(mRoster[live_after->getObjectListIndex()] == live_after); + } + + // Removal moves the last object into the hole: every survivor's recorded position must follow, whichever + // positions leave and in whatever order. + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: swap and pop keeps every recorded position", "[ALObjectRoster]") + { + std::vector objs; + for (U32 i = 0; i < 64; ++i) + { + objs.push_back(add(i)); + } + + // the last, the first, a run in the middle, then every third of the rest + std::vector dying = { 63, 0, 30, 31, 32 }; + for (U32 i = 1; i < 63; i += 3) + { + if (std::find(dying.begin(), dying.end(), i) == dying.end()) + { + dying.push_back(i); + } + } + for (U32 i : dying) + { + REQUIRE(mRoster.queueRemoval(objs[i])); + } + + while (mRoster.queued()) + { + mRoster.drain([](size_t removed) { return removed < 3; }); + REQUIRE(mRoster.consistent()); + } + + std::vector expected; + for (U32 i = 0; i < 64; ++i) + { + if (std::find(dying.begin(), dying.end(), i) == dying.end()) + { + expected.push_back(i); + } + } + CHECK(ids() == expected); + CHECK(Obj::sLive == (S32)expected.size()); + for (const LLPointer& obj : mRoster) + { + CHECK_FALSE(obj->isPendingRemoval()); + CHECK(obj->getNumRefs() == 1); + } + } + + // A budget smaller than the queue drains it over several calls, oldest first, and each call's floor holds + // however little time it is given. + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: a budgeted drain empties the queue over several calls", "[ALObjectRoster]") + { + std::vector serials; + for (U32 i = 0; i < 100; ++i) + { + Obj* obj = add(i); + serials.push_back(obj->mSerial); + REQUIRE(mRoster.queueRemoval(obj)); + } + + size_t calls = 0; + while (mRoster.queued()) + { + const size_t floor = Roster::drainFloor(mRoster.queued()); + // no time at all: only the floor + const size_t removed = mRoster.drain([floor](size_t n) { return n < floor; }); + CHECK(removed == floor); + CHECK(removed >= std::min(mRoster.queued() + removed, Roster::MIN_DRAIN)); + ++calls; + REQUIRE(calls <= 100); + } + + CHECK(calls == (size_t)100); + CHECK(mRoster.empty()); + CHECK(Obj::sDestroyed == serials); + } + + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: the drain floor grows with the queue", "[ALObjectRoster]") + { + CHECK(Roster::drainFloor(0) == (size_t)0); + CHECK(Roster::drainFloor(1) == (size_t)1); + CHECK(Roster::drainFloor(255) == Roster::MIN_DRAIN); + CHECK(Roster::drainFloor(1000) == (size_t)3); + CHECK(Roster::drainFloor(25600) == (size_t)100); + CHECK(Roster::drainFloor(256000) == (size_t)1000); + } + + // A region's teardown takes its dead objects now and leaves the rest of the queue, and its order, alone. + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: a region drain leaves none of that region behind", "[ALObjectRoster]") + { + std::vector objs; + for (U32 i = 0; i < 12; ++i) + { + objs.push_back(add(i, i % 3)); + } + // dead: region 1's 1, 4, 7 (10 lives on); region 2's 2, 5; region 0's 0, 3 + for (U32 i : { 0u, 1u, 2u, 3u, 4u, 5u, 7u }) + { + REQUIRE(mRoster.queueRemoval(objs[i])); + } + + CHECK(mRoster.drainIf([](Obj* obj) { return obj->mRegion == 1; }) == (size_t)3); + CHECK(mRoster.consistent()); + for (const LLPointer& obj : mRoster) + { + CHECK_FALSE((obj->mRegion == 1 && obj->isPendingRemoval())); + } + CHECK(mRoster.queued() == (size_t)4); + CHECK(ids() == std::vector{ 0, 2, 3, 5, 6, 8, 9, 10, 11 }); + + // the others keep their order + Obj::sDestroyed.clear(); + const std::vector rest = { objs[0]->mSerial, objs[2]->mSerial, objs[3]->mSerial, objs[5]->mSerial }; + drainAll(); + CHECK(Obj::sDestroyed == rest); + CHECK(ids() == std::vector{ 6, 8, 9, 10, 11 }); + } + + // A destructor run by a drain may kill another object: it joins the queue's back, and the list stays whole. + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: a death during a drain joins the queue", "[ALObjectRoster]") + { + Obj* parent = add(1); + Obj* child = add(2); + add(3); + parent->mOnDestroy = [this, child]() { mRoster.queueRemoval(child); }; + + REQUIRE(mRoster.queueRemoval(parent)); + CHECK(mRoster.drain([](size_t removed) { return removed < 1; }) == (size_t)1); + CHECK(mRoster.consistent()); + CHECK(mRoster.queued() == (size_t)1); + CHECK(child->isPendingRemoval()); + + CHECK(drainAll() == (size_t)1); + CHECK(ids() == std::vector{ 3 }); + } + + // An object queued that was never listed costs the queue's reference and nothing else. + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: an unlisted object leaves without touching the list", "[ALObjectRoster]") + { + add(1); + add(2); + LLPointer stray = new Obj(9); + REQUIRE(mRoster.queueRemoval(stray)); + CHECK(stray->getNumRefs() == 2); + + CHECK(drainAll() == (size_t)1); + CHECK(stray->getNumRefs() == 1); + CHECK(ids() == std::vector{ 1, 2 }); + CHECK(mRoster.consistent()); + } + + TEST_CASE_METHOD(objectroster_data, "ALObjectRoster: clear forgets every position before releasing", "[ALObjectRoster]") + { + LLPointer kept = add(1); + Obj* dead = add(2); + REQUIRE(mRoster.queueRemoval(dead)); + + mRoster.clear(); + CHECK(mRoster.empty()); + CHECK(mRoster.queued() == (size_t)0); + CHECK(kept->getObjectListIndex() == -1); + CHECK(kept->getNumRefs() == 1); + CHECK(Obj::sLive == 1); + } +} diff --git a/indra/newview/tests/alprobereach_test.cpp b/indra/newview/tests/alprobereach_test.cpp new file mode 100644 index 0000000000..5260b9c67b --- /dev/null +++ b/indra/newview/tests/alprobereach_test.cpp @@ -0,0 +1,159 @@ +/** + * @file alprobereach_test.cpp + * @brief Unit tests for which reflection probes the view reaches, and the cube slots dynamic allocation asks for + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include + +#include "../alprobereach.h" + +namespace +{ + const LLVector3 CAMERA(128.f, 128.f, 30.f); + + TEST_CASE("ALProbeReach: a probe is in reach while its sphere comes within draw distance", "[ALProbeReach]") + { + const F32 draw = 128.f; + // a 16 m probe whose centre is 144 m off: its sphere's near side is at the draw distance + CHECK(ALProbeReach::inReach(CAMERA + LLVector3(144.f, 0.f, 0.f), 16.f, CAMERA, draw)); + CHECK_FALSE(ALProbeReach::inReach(CAMERA + LLVector3(145.f, 0.f, 0.f), 16.f, CAMERA, draw)); + // the distance is to the centre in three dimensions, not along an axis + CHECK_FALSE(ALProbeReach::inReach(CAMERA + LLVector3(110.f, 110.f, 0.f), 16.f, CAMERA, draw)); + CHECK_FALSE(ALProbeReach::inReach(CAMERA + LLVector3(0.f, 0.f, 200.f), 16.f, CAMERA, draw)); + // a large placed probe is in reach as long as any of it is + CHECK(ALProbeReach::inReach(CAMERA + LLVector3(300.f, 0.f, 0.f), 180.f, CAMERA, draw)); + // the camera inside a probe + CHECK(ALProbeReach::inReach(CAMERA + LLVector3(4.f, 0.f, 0.f), 16.f, CAMERA, draw)); + UNSCOPED_INFO("a draw distance below zero reaches only the probes the camera is in"); + CHECK(ALProbeReach::inReach(CAMERA + LLVector3(15.f, 0.f, 0.f), 16.f, CAMERA, -10.f)); + CHECK_FALSE(ALProbeReach::inReach(CAMERA + LLVector3(17.f, 0.f, 0.f), 16.f, CAMERA, -10.f)); + } + + // The probes of a region a teleport left are a region or more away: none of them is in reach of a camera at the + // arrival, at any draw distance short of that. + TEST_CASE("ALProbeReach: the terrain probes of a region far behind are out of reach", "[ALProbeReach]") + { + const F32 draw = 256.f; + const LLVector3 far_origin(128.f + 2048.f, 128.f, 0.f); + const F32 spacing = 32.f; + for (U32 i = 0; i < 8; ++i) + { + for (U32 j = 0; j < 8; ++j) + { + const LLVector3 origin = far_origin + LLVector3(i * spacing, j * spacing, 20.f); + CHECK_FALSE(ALProbeReach::inReach(origin, spacing * 0.75f, CAMERA, draw)); + } + } + } + + TEST_CASE("ALProbeReach: the relevant count moves a step at a time", "[ALProbeReach]") + { + CHECK(ALProbeReach::stepRelevant(0, 0) == 0u); + CHECK(ALProbeReach::stepRelevant(1, 0) == 16u); + CHECK(ALProbeReach::stepRelevant(16, 0) == 16u); + CHECK(ALProbeReach::stepRelevant(17, 0) == 32u); + CHECK(ALProbeReach::stepRelevant(250, 0) == 256u); + UNSCOPED_INFO("up as soon as a step is needed"); + CHECK(ALProbeReach::stepRelevant(33, 32) == 48u); + } + + // A probe crossing the edge of draw distance moves the count across a step's boundary and back: the count stays. + TEST_CASE("ALProbeReach: the relevant count does not flap at a step's edge", "[ALProbeReach]") + { + U32 current = ALProbeReach::stepRelevant(33, 0); + REQUIRE(current == 48u); + for (U32 i = 0; i < 10; ++i) + { + current = ALProbeReach::stepRelevant((i % 2) ? 33 : 32, current); + CHECK(current == 48u); + } + UNSCOPED_INFO("down once more than a step fewer"); + CHECK(ALProbeReach::stepRelevant(31, 48) == 48u); + CHECK(ALProbeReach::stepRelevant(16, 48) == 16u); + CHECK(ALProbeReach::stepRelevant(0, 48) == 0u); + } + + // Dynamic allocation sizes the cube array from the probes that matter; off, it takes every slot allowed. + TEST_CASE("ALProbeReach: dynamic allocation counts the probes that matter", "[ALProbeReach]") + { + const U32 slots = 256; + + UNSCOPED_INFO("off"); + CHECK(ALProbeReach::dynamicProbeCount(1, -1, 3, slots) == slots); + CHECK(ALProbeReach::dynamicProbeCount(3, -1, 3, slots) == slots); + + UNSCOPED_INFO("level 0 takes the default probe only"); + CHECK(ALProbeReach::dynamicProbeCount(0, 0, 200, slots) == 1u); + + UNSCOPED_INFO("level 1 takes the relevant probes and the buffer"); + CHECK(ALProbeReach::dynamicProbeCount(1, 0, 48, slots) == 48u); + CHECK(ALProbeReach::dynamicProbeCount(1, 1, 48, slots) == 49u); + CHECK(ALProbeReach::dynamicProbeCount(1, 0, 0, slots) == 1u); + CHECK(ALProbeReach::dynamicProbeCount(1, 0, 4000, slots) == slots); + + UNSCOPED_INFO("level 2 takes at least 128"); + CHECK(ALProbeReach::dynamicProbeCount(2, 0, 48, slots) == 128u); + CHECK(ALProbeReach::dynamicProbeCount(2, 0, 160, slots) == 160u); + + UNSCOPED_INFO("level 3 takes 256"); + CHECK(ALProbeReach::dynamicProbeCount(3, 0, 48, slots) == 256u); + + UNSCOPED_INFO("a buffer above 1 rounds to its multiple, in sixteens"); + CHECK(ALProbeReach::dynamicProbeCount(1, 16, 40, slots) == 48u); + CHECK(ALProbeReach::dynamicProbeCount(1, 16, 56, slots) == 64u); + UNSCOPED_INFO("at other values the count is scaled by 16 / allocation"); + CHECK(ALProbeReach::dynamicProbeCount(1, 2, 16, slots) == 128u); + CHECK(ALProbeReach::dynamicProbeCount(1, 2, 40, slots) == slots); + CHECK(ALProbeReach::dynamicProbeCount(1, 32, 40, slots) == 16u); + CHECK(ALProbeReach::dynamicProbeCount(1, 32, 8, slots) == 1u); + } + + // A probe in reach stays in it a margin past draw distance, and one out of it comes back only within it. + TEST_CASE("ALProbeReach: reach does not flap at the edge of draw distance", "[ALProbeReach]") + { + const F32 draw = 128.f; + const F32 edge = draw + ALProbeReach::REACH_MARGIN; + CHECK(ALProbeReach::inReach(draw + 1.f, draw, true)); + CHECK(ALProbeReach::inReach(edge, draw, true)); + CHECK_FALSE(ALProbeReach::inReach(edge + 0.5f, draw, true)); + CHECK_FALSE(ALProbeReach::inReach(draw + 1.f, draw, false)); + CHECK(ALProbeReach::inReach(draw, draw, false)); + + // a probe wandering either side of the edge holds its state + bool in = true; + for (F32 d : { draw - 2.f, draw + 3.f, draw - 1.f, draw + 8.f, draw + 2.f }) + { + in = ALProbeReach::inReach(d, draw, in); + CHECK(in); + } + in = ALProbeReach::inReach(edge + 4.f, draw, in); + CHECK_FALSE(in); + for (F32 d : { draw + 8.f, draw + 3.f, draw + 1.f }) + { + in = ALProbeReach::inReach(d, draw, in); + CHECK_FALSE(in); + } + CHECK(ALProbeReach::inReach(draw - 1.f, draw, in)); + } +} diff --git a/indra/newview/tests/alregionretirement_test.cpp b/indra/newview/tests/alregionretirement_test.cpp new file mode 100644 index 0000000000..d6b7c60d19 --- /dev/null +++ b/indra/newview/tests/alregionretirement_test.cpp @@ -0,0 +1,113 @@ +/** + * @file alregionretirement_test.cpp + * @brief Unit tests for which regions a settled teleport arrival keeps, and when an arrival has settled + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include + +#include "../alregionretirement.h" + +namespace +{ + constexpr F64 WIDTH = 256.0; + constexpr F64 DRAW = 128.0; + // the agent stands in the middle of the region whose origin is (2560, 2560) + const LLVector3d AGENT(2688.0, 2688.0, 30.0); + + // A region the agent is not in and is not connected to, with its origin at x, y. + bool keeps(F64 x, F64 y, F64 width = WIDTH, F64 draw = DRAW) + { + return ALRegionRetirement::keeps(false, false, LLVector3d(x, y, 0.0), width, AGENT, draw); + } + + TEST_CASE("ALRegionRetirement: the agent's region and its connected neighbours stay however far", "[ALRegionRetirement]") + { + const LLVector3d far_away(100000.0, 100000.0, 0.0); + CHECK(ALRegionRetirement::keeps(true, false, far_away, WIDTH, AGENT, DRAW)); + CHECK(ALRegionRetirement::keeps(false, true, far_away, WIDTH, AGENT, DRAW)); + CHECK_FALSE(ALRegionRetirement::keeps(false, false, far_away, WIDTH, AGENT, DRAW)); + } + + // The reach is draw distance plus one region width, measured from the agent to the nearest point of the + // region's square: 384 m here. + TEST_CASE("ALRegionRetirement: a region stays while its square is within draw distance and a region width", "[ALRegionRetirement]") + { + const F64 reach = DRAW + WIDTH; + // east: the square's west edge at the reach, then a metre past it + CHECK(keeps(AGENT.mdV[VX] + reach, 2560.0)); + CHECK_FALSE(keeps(AGENT.mdV[VX] + reach + 1.0, 2560.0)); + // west: the square's east edge is what counts, not its origin + CHECK(keeps(AGENT.mdV[VX] - reach - WIDTH, 2560.0)); + CHECK_FALSE(keeps(AGENT.mdV[VX] - reach - WIDTH - 1.0, 2560.0)); + // north and south likewise + CHECK(keeps(2560.0, AGENT.mdV[VY] + reach)); + CHECK_FALSE(keeps(2560.0, AGENT.mdV[VY] + reach + 1.0)); + CHECK(keeps(2560.0, AGENT.mdV[VY] - reach - WIDTH)); + CHECK_FALSE(keeps(2560.0, AGENT.mdV[VY] - reach - WIDTH - 1.0)); + // a square the agent stands over without being in it + CHECK(keeps(2560.0, 2560.0)); + } + + // Diagonally the reach is a circle: a corner within the reach on each axis alone can be out of it. + TEST_CASE("ALRegionRetirement: the reach is a distance and not a box", "[ALRegionRetirement]") + { + const F64 reach = DRAW + WIDTH; + const F64 on_circle = reach / std::sqrt(2.0); + CHECK(keeps(AGENT.mdV[VX] + on_circle - 1.0, AGENT.mdV[VY] + on_circle - 1.0)); + CHECK_FALSE(keeps(AGENT.mdV[VX] + on_circle + 1.0, AGENT.mdV[VY] + on_circle + 1.0)); + CHECK_FALSE(keeps(AGENT.mdV[VX] + reach - 1.0, AGENT.mdV[VY] + reach - 1.0)); + } + + TEST_CASE("ALRegionRetirement: a wider region reaches further and height does not count", "[ALRegionRetirement]") + { + const F64 big = 512.0; + CHECK_FALSE(keeps(AGENT.mdV[VX] + DRAW + WIDTH + 100.0, 2560.0)); + CHECK(keeps(AGENT.mdV[VX] + DRAW + WIDTH + 100.0, 2560.0, big)); + CHECK(ALRegionRetirement::keeps(false, false, LLVector3d(AGENT.mdV[VX] + DRAW + WIDTH, 2560.0, 4000.0), WIDTH, + AGENT, DRAW)); + UNSCOPED_INFO("a draw distance below zero reaches one region width"); + CHECK(keeps(AGENT.mdV[VX] + WIDTH, 2560.0, WIDTH, -50.0)); + CHECK_FALSE(keeps(AGENT.mdV[VX] + WIDTH + 1.0, 2560.0, WIDTH, -50.0)); + } + + TEST_CASE("ALRegionRetirement: an arrival settles once it is old enough and the queues are calm", "[ALRegionRetirement]") + { + const F64 min_s = ALRegionRetirement::MIN_SETTLE_SECONDS; + const F64 max_s = ALRegionRetirement::MAX_SETTLE_SECONDS; + const S32 calm_fetch = ALRegionRetirement::CALM_FETCH_REQUESTS; + const S32 calm_create = ALRegionRetirement::CALM_CREATE_QUEUE; + + UNSCOPED_INFO("never before the minimum, however calm"); + CHECK_FALSE(ALRegionRetirement::settled(0.0, 0, 0)); + CHECK_FALSE(ALRegionRetirement::settled(min_s - 0.01, 0, 0)); + + CHECK(ALRegionRetirement::settled(min_s, calm_fetch, calm_create)); + CHECK_FALSE(ALRegionRetirement::settled(min_s, calm_fetch + 1, 0)); + CHECK_FALSE(ALRegionRetirement::settled(min_s, 0, calm_create + 1)); + CHECK_FALSE(ALRegionRetirement::settled(max_s - 0.01, 1000, 1000)); + + UNSCOPED_INFO("at the maximum, however busy"); + CHECK(ALRegionRetirement::settled(max_s, 1000, 1000)); + } +}