From 2ffcc7daf64ae6e63685bc045bf05a9147990430 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 09:25:34 -0400 Subject: [PATCH 01/15] The face walk counts a face as seen only when it was drawn and is in view calcPixelArea returned true for every face but media out of the frustum and rigged faces without extents, and the walk stored that as mInFrustum. Every face counted as on screen: the off-screen bias branches never ran, every face took the 8x camera boost, and under pressure visible and unseen textures shrank alike. A face is now seen when its drawable was drawn this frame or the last (isRecentlyVisible) and its own box is in the main camera's frustum, as calcPixelArea last measured it (mBoxInFrustum, which the media test now reads too). An active drawable's face extents are in the drawable's frame, so its box is transformed into agent space for the test. A face whose spatial group no camera has drawn (LLViewerOctreeGroup::getAnyVisible) asks for nothing; geometry is no sign of having been drawn, since a group is built as soon as an object joins it. At bias 1 this only takes the camera boost off unseen faces, so textures first met off screen ask for less; maxima already held stay. Under pressure the off-screen bias and reset finally run, so unseen textures go down first, through today's scaleDown. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llface.cpp | 17 ++++++++++++++++- indra/newview/llface.h | 2 ++ indra/newview/llviewertexturelist.cpp | 14 +++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) 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/llviewertexturelist.cpp b/indra/newview/llviewertexturelist.cpp index a5de0ff6ae..b734a70690 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; } From 3397e5f6ccdd324dccf892a8b51cdeee02725cd4 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 09:25:36 -0400 Subject: [PATCH 02/15] resetTextureStats clears the sliced face walk's carried maxima A texture with more faces than one visit walks carries the largest face stat of the current and the last rotation (mFaceWalkMax, mFaceWalkRotationMax) and adds them on every visit. resetTextureStats, the teleport arrival reset, left them, so the next visit put the pre-teleport sizes back for good. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llviewertexture.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/indra/newview/llviewertexture.cpp b/indra/newview/llviewertexture.cpp index 49124472c1..dbdf734dce 100644 --- a/indra/newview/llviewertexture.cpp +++ b/indra/newview/llviewertexture.cpp @@ -940,6 +940,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 From 7f973b663837d9f5a7da99b1d220cd99a8189bba Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 09:25:54 -0400 Subject: [PATCH 03/15] Dead objects leave the object list a frame's budget at a time, queued by object Every frame with a death scanned all of mObjects and destroyed every dead object at once, so the destructors of a crowd or a region leaving ran in one frame. The time-sliced clean Alchemy had in 2021 (dba7beb746, 82412dbb36) was reverted after crashes (425874a821) with two causes, both in its bookkeeping: - a set of dead UUIDs and a counter agreed only while a UUID died at most once per window; one killed, created again and killed again inside the longer timed window went uncounted, so killObjects(region)'s immediate clean stopped short and left that dead object, whose mRegionp then dangled, in mObjects; - its recovery pass erased by count from the tail, which could drop live objects that drawables, the octree and the selection still pointed at. ALObjectRoster holds the list and its queue of leaving objects together. Each object records its position in the list (mObjectListIndex), so it leaves by a swap with the last and a pop, with no scan. A death queues the object itself, an LLPointer, once, under a per-object flag (mPendingRemoval): an id that dies twice is two objects queued once each, and the queue's length is the count. The dead stay in the list until the queue reaches them; the roster gives out only const iterators, so nothing can write through one and leave the recorded positions behind. dirtyAllObjectInventory and clearDebugText, the two loops that did not skip the dead, now do (a dead object's floating text would come back until it was destroyed), and NUM_OBJECTS counts the live only. cleanDeadObjects destroys from the queue for 0.5 ms a frame, with a floor of one object and of a 256th of the queue, so a queue growing faster than the budget still shrinks; the full synchronous drain it had for killObjects has no caller and goes. killObjects(region) still destroys that region's dead objects at once, since its partitions and the region go next. IGNORE_DEAD, never defined, read the UUID set and goes with it. Co-Authored-By: Claude Opus 5.5 --- indra/newview/CMakeLists.txt | 2 + indra/newview/alobjectroster.h | 203 +++++++++++ indra/newview/llappviewer.cpp | 6 +- indra/newview/llviewerobject.h | 11 + indra/newview/llviewerobjectlist.cpp | 136 +++----- indra/newview/llviewerobjectlist.h | 15 +- indra/newview/tests/alobjectroster_test.cpp | 358 ++++++++++++++++++++ 7 files changed, 637 insertions(+), 94 deletions(-) create mode 100644 indra/newview/alobjectroster.h create mode 100644 indra/newview/tests/alobjectroster_test.cpp diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 4d4cbe60d6..0670706a60 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -1145,6 +1145,7 @@ if(AL_BUILD_VIEWER) allightboxdirectory.h almotionpredictor.h alobjectproperties.h + alobjectroster.h alagentlocationformat.h alcrashreporter.h alfeedback.h @@ -2538,6 +2539,7 @@ if(AL_BUILD_TESTS) tests/algradehistory_test.cpp tests/almotionpredictor_test.cpp tests/alnotecardembedded_test.cpp + tests/alobjectroster_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/llappviewer.cpp b/indra/newview/llappviewer.cpp index 62e64ac220..c898a1b4e8 100644 --- a/indra/newview/llappviewer.cpp +++ b/indra/newview/llappviewer.cpp @@ -5836,8 +5836,10 @@ void LLAppViewer::idle() } // - // 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/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..4e6dea68b2 100644 --- a/indra/newview/llviewerobjectlist.cpp +++ b/indra/newview/llviewerobjectlist.cpp @@ -102,7 +102,6 @@ LLViewerObjectList::LLViewerObjectList() { mCurLazyUpdateIndex = 0; mCurBin = 0; - mNumDeadObjects = 0; mNumOrphans = 0; mNumNewObjects = 0; mWasPaused = false; @@ -122,7 +121,6 @@ void LLViewerObjectList::destroy() resetObjectBeacons(); mActiveObjects.clear(); - mDeadObjects.clear(); mMapObjects.clear(); mUUIDObjectMap.clear(); } @@ -637,16 +635,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) ) @@ -783,9 +771,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 +1097,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 +1385,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 +1400,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 +1436,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,7 +1480,7 @@ 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; @@ -1503,8 +1490,20 @@ void LLViewerObjectList::killObjects(LLViewerRegion *regionp) } } - // 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::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 +1525,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 +1541,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 +1549,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 +1740,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 +1762,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 +2028,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 +2056,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 +2097,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..e1c98d491b 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,8 @@ class LLViewerObjectList bool killObject(LLViewerObject *objectp); void killObjects(LLViewerRegion *regionp); // Kill all objects owned by a particular region. + // 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 +91,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 +147,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 +212,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 +220,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/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); + } +} From 7968ed1feb91d855eccbf330c564856a76360d5a Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 09:34:19 -0400 Subject: [PATCH 04/15] Regions a teleport left behind are retired once the arrival has settled After a teleport the old region stayed until its simulator sent DisableSimulator about a minute later, or until the 100 s circuit timeout, with its objects, drawables and VBOs, avatars and their bakes, terrain and probes. ALRegionRetirement watches for a teleport's arrival. Once it has settled -- 5 s after it at the earliest, then once the texture fetcher holds 32 requests or fewer and the create queue 8 or fewer, or 15 s after it whatever the queues -- every region is retired that is neither the agent's, nor a neighbour connected to it, nor within draw distance plus one region width of the agent, measured to the nearest point of its square in the ground plane. One region at a time: - it is marked retiring: object updates from its simulator are dropped and it skips its idle update, so nothing new is created in it from messages or from its object cache; - its root objects are killed 0.5 ms a frame (at least one): 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 or an animesh with a dead control avatar; the dead leave the object list as cleanDeadObjects reaches them; - once the dead queue has emptied, or 5 s after the last kill, its object cache is saved and it is removed through LLWorld::removeRegion, whose killObjects destroys what is left of it, and its circuit is closed: the path DisableSimulator takes, so what its simulator still sends is dropped by the message system. The log records the objects, frames and seconds it took, and the milliseconds saveObjectCache and the removal took, to measure them in world. A teleport that starts meanwhile removes the region being retired at once, and addRegion recreates a retiring region rather than reuse it, since the objects it lost will not be sent again. The handlers that look a region up by its sender or handle (object updates, cached object updates, KillObject, layer data, parcel overlay and properties, coarse locations, region handshake, GLTF overrides) all handle finding none, for a message read before the circuit closes. AlchemyRetireDepartedRegions, on by default, turns it off; turned off, the region being retired is finished and no other is started. Co-Authored-By: Claude Opus 5.5 --- indra/newview/CMakeLists.txt | 3 + indra/newview/alregionretirement.cpp | 251 ++++++++++++++++++ indra/newview/alregionretirement.h | 112 ++++++++ .../newview/app_settings/settings_alchemy.xml | 11 + indra/newview/llviewerobjectlist.cpp | 12 + indra/newview/llviewerregion.h | 5 + indra/newview/llworld.cpp | 15 +- indra/newview/llworld.h | 3 + .../newview/tests/alregionretirement_test.cpp | 113 ++++++++ 9 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 indra/newview/alregionretirement.cpp create mode 100644 indra/newview/alregionretirement.h create mode 100644 indra/newview/tests/alregionretirement_test.cpp diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 0670706a60..c9e69bbdb7 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 @@ -1146,6 +1147,7 @@ if(AL_BUILD_VIEWER) almotionpredictor.h alobjectproperties.h alobjectroster.h + alregionretirement.h alagentlocationformat.h alcrashreporter.h alfeedback.h @@ -2540,6 +2542,7 @@ if(AL_BUILD_TESTS) tests/almotionpredictor_test.cpp tests/alnotecardembedded_test.cpp tests/alobjectroster_test.cpp + tests/alregionretirement_test.cpp tests/alrecoverykeeper_test.cpp tests/alsceneexplorerpredicate_test.cpp tests/alscopedata_test.cpp diff --git a/indra/newview/alregionretirement.cpp b/indra/newview/alregionretirement.cpp new file mode 100644 index 0000000000..3b65b9c263 --- /dev/null +++ b/indra/newview/alregionretirement.cpp @@ -0,0 +1,251 @@ +/** + * @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 "llviewertexturelist.h" +#include "llworld.h" + +namespace +{ + // What a frame spends killing the retiring region's objects, beyond a floor of one: a kill takes a linkset, or an + // avatar and everything it wears. + constexpr F64 KILL_BUDGET_SECONDS = 0.0005; + constexpr size_t MIN_KILLS = 1; + // 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; + + 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(); + return ALRegionRetirement::keeps(regionp == agent_region, neighbour, regionp->getOriginGlobal(), + regionp->getWidth(), gAgent.getPositionGlobal(), gAgentCamera.mDrawDistance); + } +} + +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; + 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(); + } +} + +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(); + 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() +{ + if (mRegionp == gAgent.getRegion()) + { + LL_WARNS("RegionRetirement") << "The agent entered " << mRegionp->getName() + << " while it was being retired; keeping what is left of it" << LL_ENDL; + mRegionp->setRetiring(false); + mRegionp = nullptr; + mObjects.clear(); + return; + } + + ++mFrames; + if (!mObjects.empty()) + { + const F64 start = LLTimer::getTotalSeconds(); + size_t killed = 0; + while (!mObjects.empty() && (killed < MIN_KILLS || LLTimer::getTotalSeconds() - start < KILL_BUDGET_SECONDS)) + { + 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())) + { + gObjectList.killObject(objectp); + ++killed; + } + } + 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(); + 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..8ecfe3ae93 --- /dev/null +++ b/indra/newview/alregionretirement.h @@ -0,0 +1,112 @@ +/** + * @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. +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; + 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/llviewerobjectlist.cpp b/indra/newview/llviewerobjectlist.cpp index 4e6dea68b2..b5e5f3b96f 100644 --- a/indra/newview/llviewerobjectlist.cpp +++ b/indra/newview/llviewerobjectlist.cpp @@ -462,6 +462,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]; @@ -740,6 +746,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++) 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/llworld.cpp b/indra/newview/llworld.cpp index abe7c1861d..624cfdddac 100644 --- a/indra/newview/llworld.cpp +++ b/indra/newview/llworld.cpp @@ -130,7 +130,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 +148,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 +273,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); @@ -681,6 +688,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 +712,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/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)); + } +} From dcf0b6356b42ef075c56cf996abdbf6d3a736557 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 09:58:01 -0400 Subject: [PATCH 05/15] What a removed region held leaves at once: its textures, and its geometry in the buffer pool A texture only the removed region referenced waited out the 30 s lazy flush. The objects that die with their region -- in killObjects(region), and the roots ALRegionRetirement kills, with their children -- mark the textures they show (LLViewerObjectList::noteReleasedTextures: diffuse, normal and specular maps, and the GLTF material's four), and the lazy flush takes a texture marked within the last minute as soon as it is unreferenced. A texture any other object still shows stays referenced, and one let go for any other reason waits as before. The freed geometry went into the VBO pool's reserve, which stays allocated and counted as used until clean() runs, once every 1024 pool operations. LLVertexBuffer::trimPoolSoon, from removeRegion and once a teleport's arrival has settled (not at the arrival, whose own geometry takes those stores back meanwhile), opens a 6 s window in which updatePoolTrim, once a frame in idle after the dead drawables are cleaned, frees the kept stores the GPU is done with and that have been kept at least a second, 0.5 ms a frame, so a store that LODs and rebuilds take straight back is still reused. Co-Authored-By: Claude Opus 5.5 --- indra/llrender/llvertexbuffer.cpp | 62 ++++++++++++++++ indra/llrender/llvertexbuffer.h | 7 ++ .../llrender/tests/llvertexbuffer_gl_test.cpp | 73 +++++++++++++++++++ indra/newview/alregionretirement.cpp | 5 ++ indra/newview/llappviewer.cpp | 3 + indra/newview/llviewerobjectlist.cpp | 36 +++++++++ indra/newview/llviewerobjectlist.h | 3 + indra/newview/llviewertexture.h | 9 +++ indra/newview/llviewertexturelist.cpp | 3 +- indra/newview/llworld.cpp | 4 + 10 files changed, 204 insertions(+), 1 deletion(-) 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/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/alregionretirement.cpp b/indra/newview/alregionretirement.cpp index 3b65b9c263..5b5fc5cd76 100644 --- a/indra/newview/alregionretirement.cpp +++ b/indra/newview/alregionretirement.cpp @@ -36,6 +36,7 @@ #include "llvoavatar.h" #include "message.h" #include "llviewerregion.h" +#include "llvertexbuffer.h" #include "llviewertexturelist.h" #include "llworld.h" @@ -90,6 +91,9 @@ void ALRegionRetirement::update() (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(); @@ -202,6 +206,7 @@ void ALRegionRetirement::killSome() if (!objectp->isDead() && objectp->getRegion() == mRegionp && !objectp->getParent() && !(avatarp && avatarp->isControlAvatar())) { + gObjectList.noteReleasedTextures(objectp, true); gObjectList.killObject(objectp); ++killed; } diff --git a/indra/newview/llappviewer.cpp b/indra/newview/llappviewer.cpp index c898a1b4e8..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,6 +5834,8 @@ void LLAppViewer::idle() { LLDrawable::cleanupDeadDrawables(); } + // what the dead drawables' geometry left in the buffer pool, after a region went + LLVertexBuffer::updatePoolTrim(); } // diff --git a/indra/newview/llviewerobjectlist.cpp b/indra/newview/llviewerobjectlist.cpp index b5e5f3b96f..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" @@ -1498,6 +1499,7 @@ void LLViewerObjectList::killObjects(LLViewerRegion *regionp) if (objectp->mRegionp == regionp) { + noteReleasedTextures(objectp, false); killObject(objectp); } } @@ -1507,6 +1509,40 @@ void LLViewerObjectList::killObjects(LLViewerRegion *regionp) 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) diff --git a/indra/newview/llviewerobjectlist.h b/indra/newview/llviewerobjectlist.h index e1c98d491b..cefd2c6f34 100644 --- a/indra/newview/llviewerobjectlist.h +++ b/indra/newview/llviewerobjectlist.h @@ -80,6 +80,9 @@ 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; diff --git a/indra/newview/llviewertexture.h b/indra/newview/llviewertexture.h index 2217c9e19a..e7b0f5abfd 100644 --- a/indra/newview/llviewertexture.h +++ b/indra/newview/llviewertexture.h @@ -350,6 +350,14 @@ 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; + } + virtual void processTextureStats() ; bool needsAux() const { return mNeedsAux; } @@ -503,6 +511,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 b734a70690..4e39377c59 100644 --- a/indra/newview/llviewertexturelist.cpp +++ b/indra/newview/llviewertexturelist.cpp @@ -1108,7 +1108,8 @@ void LLViewerTextureList::updateImageDecodePriority(LLViewerFetchedTexture* imag S32 num_refs = imagep->getNumRefs(); if (num_refs <= min_refs && flush_images) { - if (imagep->getLastReferencedTimer()->getElapsedTimeF32() > lazy_flush_timeout) + const F64 unreferenced = imagep->getLastReferencedTimer()->getElapsedTimeF64(); + if (unreferenced > lazy_flush_timeout || imagep->releasedWithRegionWithin(2.0 * lazy_flush_timeout)) { // Remove the unused image from the image list deleteImage(imagep); diff --git a/indra/newview/llworld.cpp b/indra/newview/llworld.cpp index 624cfdddac..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" @@ -287,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(); } From bbf3754dc816709c8d8dad29bb5e165c99819ca5 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 09:58:16 -0400 Subject: [PATCH 06/15] Probes wholly beyond draw distance are irrelevant, and dynamic allocation counts the relevant The region a teleport left kept its probes registered until it was deleted: its 64-probe terrain grid, a probe per 16 m volume node and its placed probes, which are relevant at level 1 and above. They held cube slots while fewer than 256 probes existed, and with RenderReflectionProbeDynamicAllocation on the count was mProbes.size(), relevant or not. A probe affects only the pixels inside its influence volume, so one whose sphere lies wholly beyond draw distance affects nothing drawn (ALProbeReach::inReach). The manager works that out for every probe once a frame, before anything that frame asks whether one is relevant (LLReflectionMap::mInReach), and isRelevant answers false for it, placed or automatic. Every probe of a far region, and of a departed one, which ALRegionRetirement's rule puts beyond draw distance plus a region width, falls under it. Regions culled by the frustum are not: the agent's own region's terrain leaves the frustum looking up or from a skybox, and a probe turned away from would need its twelve passes again on the way back. Dynamic allocation sizes the cube array from the probes relevant last frame, the default probe among them, instead of every probe registered. Since each change of the count reallocates the array and regenerates every probe, the count moves in steps of 16, up at once and down only once it is more than a step lower, so a probe crossing the edge of draw distance does not flap it. The count's formula moved to ALProbeReach::dynamicProbeCount unchanged otherwise. Allocation stays off by default. Co-Authored-By: Claude Opus 5.5 --- indra/newview/CMakeLists.txt | 2 + indra/newview/alprobereach.h | 94 ++++++++++++++++ indra/newview/llreflectionmap.cpp | 5 + indra/newview/llreflectionmap.h | 3 + indra/newview/llreflectionmapmanager.cpp | 63 +++++------ indra/newview/llreflectionmapmanager.h | 2 + indra/newview/tests/alprobereach_test.cpp | 126 ++++++++++++++++++++++ 7 files changed, 257 insertions(+), 38 deletions(-) create mode 100644 indra/newview/alprobereach.h create mode 100644 indra/newview/tests/alprobereach_test.cpp diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index c9e69bbdb7..d060cc1c5b 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -1147,6 +1147,7 @@ if(AL_BUILD_VIEWER) almotionpredictor.h alobjectproperties.h alobjectroster.h + alprobereach.h alregionretirement.h alagentlocationformat.h alcrashreporter.h @@ -2542,6 +2543,7 @@ if(AL_BUILD_TESTS) 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 diff --git a/indra/newview/alprobereach.h b/indra/newview/alprobereach.h new file mode 100644 index 0000000000..e4dabc9f5e --- /dev/null +++ b/indra/newview/alprobereach.h @@ -0,0 +1,94 @@ +/** + * @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); + } + + // The same, from the distance between the camera and the sphere's surface, negative inside it. + static bool inReach(F32 distance_to_sphere, F32 draw_distance) + { + return distance_to_sphere <= std::max(draw_distance, 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) + { + // Round to the nearest increment of 16 + count = ((count + allocation / 2) / allocation) * 16; + } + else + { + count += allocation; + } + return llclamp(count, 1u, probe_count); + } +}; 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..bacd3ab6fe 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,22 @@ void LLReflectionMapManager::update() return; } + // Which probes the view can reach this frame, 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) + { + 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); + relevant += (probe == mDefaultProbe || probe->isRelevant()) ? 1 : 0; + } + mRelevantProbeCount = ALProbeReach::stepRelevant(relevant, mRelevantProbeCount); + } + bool did_update = false; @@ -519,10 +509,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/tests/alprobereach_test.cpp b/indra/newview/tests/alprobereach_test.cpp new file mode 100644 index 0000000000..f4b7c0f313 --- /dev/null +++ b/indra/newview/tests/alprobereach_test.cpp @@ -0,0 +1,126 @@ +/** + * @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); + } +} From 681ac651f88afb18713d06fdf1cd64865bf51557 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 09:58:36 -0400 Subject: [PATCH 07/15] The emergency purge is spread over frames and leaves the lazy flush's timers alone On the frame memory first ran low with system memory short or VRAM 200% over target, updateClass ran updateImageDecodePriority on every texture in the list at once: a hitch the size of the list. And it passed flush_images false, which sent every unreferenced texture down the "still referenced" branch, restarting its lazy flush timer: every unreferenced texture waited another 30 s to go, at the moment memory was shortest. The purge now takes the list's keys and visits them 1 ms a frame (at least 64 textures), skipping any that left the list meanwhile. Keys, not references: a reference held for the purge's length would count an unreferenced texture as referenced in every other visit meanwhile, and restart its wait the same way; each texture is held for its own visit only, as the update loop's copy holds what it visits. Without flush_images an unreferenced texture is neither deleted nor has its timer restarted; with it, nothing changes. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llviewertexture.cpp | 45 +++++++++++++++++++++++++-- indra/newview/llviewertexturelist.cpp | 6 ++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/indra/newview/llviewertexture.cpp b/indra/newview/llviewertexture.cpp index dbdf734dce..ca81ddd97a 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 diff --git a/indra/newview/llviewertexturelist.cpp b/indra/newview/llviewertexturelist.cpp index 4e39377c59..badb50e295 100644 --- a/indra/newview/llviewertexturelist.cpp +++ b/indra/newview/llviewertexturelist.cpp @@ -1106,10 +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) { + // Unreferenced: without flush_images (the emergency purge) it neither goes nor has its wait restarted. const F64 unreferenced = imagep->getLastReferencedTimer()->getElapsedTimeF64(); - if (unreferenced > lazy_flush_timeout || imagep->releasedWithRegionWithin(2.0 * lazy_flush_timeout)) + if (flush_images + && (unreferenced > lazy_flush_timeout || imagep->releasedWithRegionWithin(2.0 * lazy_flush_timeout))) { // Remove the unused image from the image list deleteImage(imagep); From bcce2bce3c9d1267fbcb35ddf0ceb24701c4c9c3 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 10:00:05 -0400 Subject: [PATCH 08/15] Faces the blend pool took while a texture's alpha analysis was out are rebuilt into a mask pass Since Linden #6055 (ac91515c11) a texture of 64 px or more is analysed for an alpha mask on a worker, and until the result comes back clearAlphaAnalysis says it is no mask. A face built in that window -- and a first load always builds in it -- went into the blend pool, and stayed there when the analysis then said mask: alpha-masked foliage and the like sorted and blended as alpha for as long as nothing else rebuilt its group. An LLImageGL now knows the LLGLTexture it was made for (setOwner, by generateGLTexture and the raw-image constructor; cleared by the owner as it goes, since a deferred analysis may outlive it) and tells it on the main thread whenever getIsAlphaMask's answer changes (LLGLTexture::onAlphaMaskChanged): the deferred result, a published threaded upload's, a synchronous one, and the clear before a deferral. LLViewerFetchedTexture, once the answer is mask, dirties and queues the group of every diffuse face still in an alpha pool that can render as a mask, at once rather than at its next visit. A face already in a mask pass, or one that cannot be one, is left alone. Co-Authored-By: Claude Opus 5.5 --- indra/llrender/llgltexture.cpp | 7 ++++ indra/llrender/llgltexture.h | 3 ++ indra/llrender/llimagegl.cpp | 21 ++++++++-- indra/llrender/llimagegl.h | 8 ++++ indra/llrender/tests/llimagegl_test.cpp | 51 +++++++++++++++++++++++++ indra/newview/llviewertexture.cpp | 26 +++++++++++++ indra/newview/llviewertexture.h | 3 ++ 7 files changed, 115 insertions(+), 4 deletions(-) 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..a9673ca9ae 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,30 @@ void LLImageGL::setAlphaAnalysis(const AlphaAnalysis& analysis) return; } - mIsMask = analysis.mIsMask; + setIsMask(analysis.mIsMask); mMaxAlpha = analysis.mMaxAlpha; mMinAlpha = analysis.mMinAlpha; } void LLImageGL::clearAlphaAnalysis() { - mIsMask = false; + setIsMask(false); mMaxAlpha = 255; mMinAlpha = 0; } +void LLImageGL::setIsMask(bool is_mask) +{ + if (mIsMask != is_mask) + { + mIsMask = is_mask; + if (mOwner && on_main_thread()) + { + mOwner->onAlphaMaskChanged(); + } + } +} + void LLImageGL::publishPendingAlpha() { if (mPendingAlpha.mHasPickMask) @@ -2679,7 +2692,7 @@ void LLImageGL::publishPendingAlpha() } if (mPendingAlpha.mHasAnalysis) { - mIsMask = mPendingAlpha.mAnalysis.mIsMask; + setIsMask(mPendingAlpha.mAnalysis.mIsMask); mMaxAlpha = mPendingAlpha.mAnalysis.mMaxAlpha; mMinAlpha = mPendingAlpha.mAnalysis.mMinAlpha; } @@ -3563,7 +3576,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..4777aeba32 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,11 @@ 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 getIsAlphaMask's answer changes + // (LLGLTexture::onAlphaMaskChanged): a face built into a pool by the old answer, as one built while a deferred + // analysis was out is, belongs in another by the new one. 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 +474,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 +493,7 @@ class LLImageGL : public LLThreadSafeRefCount bool mAutoGenMips = false; bool mIsMask; + LLGLTexture* mOwner = nullptr; U8 mMaxAlpha; U8 mMinAlpha; bool mNeedsAlphaAndPickMask; diff --git a/indra/llrender/tests/llimagegl_test.cpp b/indra/llrender/tests/llimagegl_test.cpp index 024cf9b6f2..cab3817644 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,54 @@ 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; + }; + + // An RGBA image whose alpha is 0 in its top half and opaque below (a mask), or 128 everywhere (no mask). + LLPointer makeAlphaRaw(bool mask) + { + constexpr 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()); + } } diff --git a/indra/newview/llviewertexture.cpp b/indra/newview/llviewertexture.cpp index ca81ddd97a..b18dbb9aac 100644 --- a/indra/newview/llviewertexture.cpp +++ b/indra/newview/llviewertexture.cpp @@ -2248,6 +2248,32 @@ bool LLViewerFetchedTexture::processFetchResults(S32& desired_discard, S32 curre return true; } +void LLViewerFetchedTexture::onAlphaMaskChanged() +{ + if (mGLTexturep.isNull() || !mGLTexturep->getIsAlphaMask()) + { + return; + } + + // A face built while a deferred analysis was out found the texture no mask (clearAlphaAnalysis) and went into the + // blend pool; only a rebuild of its group moves it to a mask pass. + for (U32 i = 0; i < mNumFaces[LLRender::DIFFUSE_MAP]; ++i) + { + LLFace* facep = mFaceList[LLRender::DIFFUSE_MAP][i]; + if (!facep || !facep->isInAlphaPool() || !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; diff --git a/indra/newview/llviewertexture.h b/indra/newview/llviewertexture.h index e7b0f5abfd..93ee74c432 100644 --- a/indra/newview/llviewertexture.h +++ b/indra/newview/llviewertexture.h @@ -373,6 +373,9 @@ class LLViewerFetchedTexture : public LLViewerTexture bool updateFetch(); + // Rebuilds the groups of faces the blend pool took while the texture's alpha analysis was out, once it says mask. + void onAlphaMaskChanged() override; + void clearFetchedResults(); //clear all fetched results, for debug use. // Override the computation of discard levels if we know the exact output From 6811bd93f639a5be1c3859b67e7e1cb07d02fef5 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 28 Oct 2026 10:00:07 -0400 Subject: [PATCH 09/15] Media interest decays again: the media texture's size window resets LLViewerMediaTexture::getMaxVirtualSize opened a new window each time its reset counter ran out by calling addTextureStats(0.f), which reset the maximum while addTextureStats still restarted it when the counter was 0. Since that branch was removed it only ever raises the maximum, so the call did nothing and the counter, never re-armed, stayed at 0: a media texture's size was the largest any face had ever asked for, and its plugin kept the interest, and the priority, of the closest look it ever had, off screen or not. The window now resets the maximum and re-arms the counter itself, so the size is what the faces seen in the current window ask for, and media turned away from loses interest. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llviewertexture.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/indra/newview/llviewertexture.cpp b/indra/newview/llviewertexture.cpp index b18dbb9aac..dcefc59640 100644 --- a/indra/newview/llviewertexture.cpp +++ b/indra/newview/llviewertexture.cpp @@ -4135,7 +4135,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 From 82a7a1c4125532e66ee112e324895e3b2a1099fb Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 29 Oct 2026 09:40:57 -0400 Subject: [PATCH 10/15] A region near the camera, or near again mid-retirement, is kept ALRegionRetirement measured a region's reach from the agent alone. The camera can stand a draw distance away from the agent and see as far again, so a region in its view could be retired. A region is now kept when it is near the agent or near the camera. The keep rule was applied only when a region was chosen and when its turn came. If the agent or the camera came near it, or draw distance grew, during the seconds its objects were being killed, the kills went on and the region was removed. killSome now asks the rule before each frame's kills (it answered only for the agent entering it before), and stops the retirement, keeping what is left, once the region is kept. A frame's kills had a floor of one kill: over a run of entries it passes over (children, control avatars, the dead), the floor kept the loop going past its budget until a root came up. The floor is now one entry looked at, and the budget bounds the rest, killed or passed over. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alregionretirement.cpp | 29 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/indra/newview/alregionretirement.cpp b/indra/newview/alregionretirement.cpp index 5b5fc5cd76..cb5668890e 100644 --- a/indra/newview/alregionretirement.cpp +++ b/indra/newview/alregionretirement.cpp @@ -42,10 +42,9 @@ namespace { - // What a frame spends killing the retiring region's objects, beyond a floor of one: a kill takes a linkset, or an - // avatar and everything it wears. + // 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; - constexpr size_t MIN_KILLS = 1; // 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; @@ -60,8 +59,14 @@ namespace std::vector neighbours; agent_region->getNeighboringRegions(neighbours); const bool neighbour = std::find(neighbours.begin(), neighbours.end(), regionp) != neighbours.end(); - return ALRegionRetirement::keeps(regionp == agent_region, neighbour, regionp->getOriginGlobal(), - regionp->getWidth(), gAgent.getPositionGlobal(), gAgentCamera.mDrawDistance); + // 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); } } @@ -181,10 +186,12 @@ void ALRegionRetirement::startNext() void ALRegionRetirement::killSome() { - if (mRegionp == gAgent.getRegion()) + // The agent or the camera came near it, or draw distance grew, while it was being retired. + if (kept(mRegionp)) { - LL_WARNS("RegionRetirement") << "The agent entered " << mRegionp->getName() - << " while it was being retired; keeping what is left of it" << LL_ENDL; + LL_WARNS("RegionRetirement") << mRegionp->getName() + << " is near the view again while being retired; keeping what is left of it" + << LL_ENDL; mRegionp->setRetiring(false); mRegionp = nullptr; mObjects.clear(); @@ -195,9 +202,10 @@ void ALRegionRetirement::killSome() if (!mObjects.empty()) { const F64 start = LLTimer::getTotalSeconds(); - size_t killed = 0; - while (!mObjects.empty() && (killed < MIN_KILLS || LLTimer::getTotalSeconds() - start < KILL_BUDGET_SECONDS)) + 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 @@ -208,7 +216,6 @@ void ALRegionRetirement::killSome() { gObjectList.noteReleasedTextures(objectp, true); gObjectList.killObject(objectp); - ++killed; } } if (mObjects.empty()) From 06a8260cbf9ac650041b8521faeff17d459db961 Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 29 Oct 2026 09:41:00 -0400 Subject: [PATCH 11/15] A texture shared with a living object loses its region mark once that is plain A texture marked as let go with a region kept the mark a minute, so one still shown by an object elsewhere, released for some other reason within that minute, went at once instead of after the lazy flush's wait. A referenced visit now forgets a mark at least 10 s old. Not sooner: the dead objects that set the mark hold the texture until they are destroyed, which ALRegionRetirement waits up to 5 s for, and a referenced visit in that time is theirs. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llviewertexture.h | 9 +++++++++ indra/newview/llviewertexturelist.cpp | 2 ++ 2 files changed, 11 insertions(+) diff --git a/indra/newview/llviewertexture.h b/indra/newview/llviewertexture.h index 93ee74c432..21deb0b4db 100644 --- a/indra/newview/llviewertexture.h +++ b/indra/newview/llviewertexture.h @@ -357,6 +357,15 @@ class LLViewerFetchedTexture : public LLViewerTexture { 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() ; diff --git a/indra/newview/llviewertexturelist.cpp b/indra/newview/llviewertexturelist.cpp index badb50e295..c91d150780 100644 --- a/indra/newview/llviewertexturelist.cpp +++ b/indra/newview/llviewertexturelist.cpp @@ -1122,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()) { From c417d6ba989ece1d2c8ab6df3ac6772f65a0c17e Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 29 Oct 2026 09:41:02 -0400 Subject: [PATCH 12/15] Probe reach has a margin, and is judged after the probes follow their objects A probe in reach stays in it until 16 m past draw distance (ALProbeReach::REACH_MARGIN), and one out of it comes back within draw distance. Without it, a probe at the edge lost its cube slot and its completed state each time it crossed, and needed its twelve passes again. The reach pass read a placed probe's origin and radius before syncToViewerObject moved them to its object's, so a probe on a moving or growing object was judged, and sorted, by last frame's bounds. The sync now happens in the reach pass, once a frame, instead of in the eclipse pass after it. dynamicProbeCount's comment said an allocation above 1 rounds to the nearest 16; the count is in units of allocation, rounded, times 16, which is that only at 16. The comment now says what it does, and tests pin allocations 2 and 32. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alprobereach.h | 14 +++++++--- indra/newview/llreflectionmapmanager.cpp | 15 ++++++----- indra/newview/tests/alprobereach_test.cpp | 33 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/indra/newview/alprobereach.h b/indra/newview/alprobereach.h index e4dabc9f5e..1aa595d7db 100644 --- a/indra/newview/alprobereach.h +++ b/indra/newview/alprobereach.h @@ -38,10 +38,15 @@ struct ALProbeReach return inReach(dist_vec(origin, camera) - radius, draw_distance); } - // The same, from the distance between the camera and the sphere's surface, negative inside it. - static bool inReach(F32 distance_to_sphere, F32 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); + 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 @@ -82,7 +87,8 @@ struct ALProbeReach if (allocation > 1) { - // Round to the nearest increment of 16 + // 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 diff --git a/indra/newview/llreflectionmapmanager.cpp b/indra/newview/llreflectionmapmanager.cpp index bacd3ab6fe..ca52623f26 100644 --- a/indra/newview/llreflectionmapmanager.cpp +++ b/indra/newview/llreflectionmapmanager.cpp @@ -379,17 +379,21 @@ void LLReflectionMapManager::update() return; } - // Which probes the view can reach this frame, before anything this frame asks whether one is relevant. The - // distance measured here is the one the sort below orders by. + // 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 = probe == mDefaultProbe + || ALProbeReach::inReach(probe->mDistance, draw_distance, probe->mInReach); relevant += (probe == mDefaultProbe || probe->isRelevant()) ? 1 : 0; } mRelevantProbeCount = ALProbeReach::stepRelevant(relevant, mRelevantProbeCount); @@ -421,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 @@ -444,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()) { diff --git a/indra/newview/tests/alprobereach_test.cpp b/indra/newview/tests/alprobereach_test.cpp index f4b7c0f313..5260b9c67b 100644 --- a/indra/newview/tests/alprobereach_test.cpp +++ b/indra/newview/tests/alprobereach_test.cpp @@ -122,5 +122,38 @@ namespace 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)); } } From d66e1f48527665c9d937d85de5605d6f57d12d2e Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 29 Oct 2026 09:41:04 -0400 Subject: [PATCH 13/15] A changed mask answer rebuilds faces either way, and the provisional clear is no answer An auto-masked face keeps its alpha pool type; the mask or blend pass is chosen as its group is built (canRenderAsMask), so the pool cannot tell which pass a face took. onAlphaMaskChanged acted only on a change to mask, so faces built into a mask pass stayed there after the texture stopped being one. LLImageGL now tells its owner only when an analysis answers differently from the last answer (mAnsweredMask): the deferred result, a published threaded upload's, a synchronous one, or the texture no longer needing a mask. The no-mask it counts as while a deferred analysis is out is provisional and tells nobody; told, every upload of a mask texture of 64 px or more would rebuild its faces' groups into blend and then back. On an answer, LLViewerFetchedTexture rebuilds the groups of its diffuse faces in an alpha pool: those that can render as a mask when the answer is mask, all of them when it is not. A test now takes the deferred path: the answer arrives through the worker and main queues, the provisional no-mask tells nothing, and only a changed answer does. Co-Authored-By: Claude Opus 5.5 --- indra/llrender/llimagegl.cpp | 11 ++++-- indra/llrender/llimagegl.h | 8 ++-- indra/llrender/tests/llimagegl_test.cpp | 52 ++++++++++++++++++++++--- indra/newview/llviewertexture.cpp | 11 ++++-- indra/newview/llviewertexture.h | 2 +- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/indra/llrender/llimagegl.cpp b/indra/llrender/llimagegl.cpp index a9673ca9ae..d6fe23fbaa 100644 --- a/indra/llrender/llimagegl.cpp +++ b/indra/llrender/llimagegl.cpp @@ -2663,16 +2663,19 @@ void LLImageGL::setAlphaAnalysis(const AlphaAnalysis& analysis) void LLImageGL::clearAlphaAnalysis() { - setIsMask(false); + // Provisional, while an analysis is out: no answer, so the owner is not told, and the next answer is weighed + // against the last one. + mIsMask = false; mMaxAlpha = 255; mMinAlpha = 0; } void LLImageGL::setIsMask(bool is_mask) { - if (mIsMask != is_mask) + mIsMask = is_mask; + if (mAnsweredMask != is_mask) { - mIsMask = is_mask; + mAnsweredMask = is_mask; if (mOwner && on_main_thread()) { mOwner->onAlphaMaskChanged(); @@ -3143,6 +3146,8 @@ void LLImageGL::setNeedsAlphaAndPickMask(bool need_mask) { mAlphaOffset = INVALID_OFFSET ; clearAlphaAnalysis(); + // no mask from now on: an answer + setIsMask(false); // Invalidate pending jobs ++mAlphaAnalysisSerial; diff --git a/indra/llrender/llimagegl.h b/indra/llrender/llimagegl.h index 4777aeba32..bc3eab3fec 100644 --- a/indra/llrender/llimagegl.h +++ b/indra/llrender/llimagegl.h @@ -372,9 +372,10 @@ 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 getIsAlphaMask's answer changes - // (LLGLTexture::onAlphaMaskChanged): a face built into a pool by the old answer, as one built while a deferred - // analysis was out is, belongs in another by the new one. Cleared by the owner as it goes. + // 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. 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 @@ -493,6 +494,7 @@ class LLImageGL : public LLThreadSafeRefCount bool mAutoGenMips = false; bool mIsMask; + bool mAnsweredMask = false; // what the last analysis answered, mIsMask but for the provisional clear LLGLTexture* mOwner = nullptr; U8 mMaxAlpha; U8 mMinAlpha; diff --git a/indra/llrender/tests/llimagegl_test.cpp b/indra/llrender/tests/llimagegl_test.cpp index cab3817644..ac0962ed2c 100644 --- a/indra/llrender/tests/llimagegl_test.cpp +++ b/indra/llrender/tests/llimagegl_test.cpp @@ -1969,16 +1969,15 @@ namespace S32 mChanges = 0; }; - // An RGBA image whose alpha is 0 in its top half and opaque below (a mask), or 128 everywhere (no mask). - LLPointer makeAlphaRaw(bool mask) + // 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) { - constexpr U16 SIDE = 32; - LLPointer raw = new LLImageRaw(SIDE, SIDE, 4); + LLPointer raw = new LLImageRaw(side, side, 4); U8* data = raw->getData(); - for (U32 i = 0; i < (U32)SIDE * SIDE; ++i) + 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; + data[i * 4 + 3] = mask ? (i < (U32)side * side / 2 ? 0 : 255) : 128; } return raw; } @@ -2010,4 +2009,45 @@ namespace 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 only of an answer that differs from the last one. + 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 is no change"); + REQUIRE(img->createGLTexture(0, makeAlphaRaw(true, 64).get())); + CHECK_FALSE(img->getIsAlphaMask()); + settle(); + CHECK(img->getIsAlphaMask()); + CHECK(owner->mChanges == 1); + + REQUIRE(img->createGLTexture(0, makeAlphaRaw(false, 64).get())); + settle(); + CHECK_FALSE(img->getIsAlphaMask()); + CHECK(owner->mChanges == 2); + } } diff --git a/indra/newview/llviewertexture.cpp b/indra/newview/llviewertexture.cpp index dcefc59640..32007f0a1e 100644 --- a/indra/newview/llviewertexture.cpp +++ b/indra/newview/llviewertexture.cpp @@ -2250,17 +2250,20 @@ bool LLViewerFetchedTexture::processFetchResults(S32& desired_discard, S32 curre void LLViewerFetchedTexture::onAlphaMaskChanged() { - if (mGLTexturep.isNull() || !mGLTexturep->getIsAlphaMask()) + if (mGLTexturep.isNull()) { return; } + const bool is_mask = mGLTexturep->getIsAlphaMask(); - // A face built while a deferred analysis was out found the texture no mask (clearAlphaAnalysis) and went into the - // blend pool; only a rebuild of its group moves it to a mask pass. + // 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() || !facep->canRenderAsMask()) + if (!facep || !facep->isInAlphaPool() || (is_mask && !facep->canRenderAsMask())) { continue; } diff --git a/indra/newview/llviewertexture.h b/indra/newview/llviewertexture.h index 21deb0b4db..95562a3f23 100644 --- a/indra/newview/llviewertexture.h +++ b/indra/newview/llviewertexture.h @@ -382,7 +382,7 @@ class LLViewerFetchedTexture : public LLViewerTexture bool updateFetch(); - // Rebuilds the groups of faces the blend pool took while the texture's alpha analysis was out, once it says mask. + // 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. From 39c7aef92af7fe8c27944ddffec5e4f58709f87c Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 29 Oct 2026 10:10:32 -0400 Subject: [PATCH 14/15] A retirement called off asks again for what it killed A region that came near the view again while being retired was kept with what was left of it: the objects already killed stayed gone, since the simulator does not send an object again unasked. ALRegionRetirement now records the local id of every object it kills, a root and its descendants, and on calling the retirement off adds each as a full cache miss (addCacheMissFull), which the world's requestCacheMisses asks the simulator for with RequestMultipleObjects, as a derendered object is brought back. A cached object also comes back from the region's own cache as its idle update resumes: a cached object's drawable hands its cache entry back to the region as it dies. An object asked for again arrives as a new object under its id; the dead one still queued under the same id leaves as before. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alregionretirement.cpp | 27 +++++++++++++++++++++++---- indra/newview/alregionretirement.h | 5 ++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/indra/newview/alregionretirement.cpp b/indra/newview/alregionretirement.cpp index cb5668890e..0db6197eb7 100644 --- a/indra/newview/alregionretirement.cpp +++ b/indra/newview/alregionretirement.cpp @@ -49,6 +49,15 @@ namespace // 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(); @@ -127,6 +136,7 @@ void ALRegionRetirement::forget(LLViewerRegion* regionp) { mRegionp = nullptr; mObjects.clear(); + mKilledIds.clear(); } } @@ -174,6 +184,7 @@ void ALRegionRetirement::startNext() mRegionp = regionp; mRegionp->setRetiring(true); mObjects.clear(); + mKilledIds.clear(); gObjectList.getRegionObjects(mRegionp, mObjects); mObjectCount = mObjects.size(); mStartTime = LLFrameTimer::getTotalSeconds(); @@ -186,15 +197,21 @@ void ALRegionRetirement::startNext() void ALRegionRetirement::killSome() { - // The agent or the camera came near it, or draw distance grew, while it was being retired. + // 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 what is left of it" - << LL_ENDL; + 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; } @@ -214,6 +231,7 @@ void ALRegionRetirement::killSome() if (!objectp->isDead() && objectp->getRegion() == mRegionp && !objectp->getParent() && !(avatarp && avatarp->isControlAvatar())) { + collect_local_ids(objectp, mKilledIds); gObjectList.noteReleasedTextures(objectp, true); gObjectList.killObject(objectp); } @@ -236,6 +254,7 @@ void ALRegionRetirement::finish() LLViewerRegion* regionp = mRegionp; mRegionp = nullptr; mObjects.clear(); + mKilledIds.clear(); if (!regionp || regionp == gAgent.getRegion()) { if (regionp) diff --git a/indra/newview/alregionretirement.h b/indra/newview/alregionretirement.h index 8ecfe3ae93..a847876890 100644 --- a/indra/newview/alregionretirement.h +++ b/indra/newview/alregionretirement.h @@ -38,7 +38,8 @@ 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. +// 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: @@ -105,6 +106,8 @@ class ALRegionRetirement // 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; From 830be4b4f45b4ebaac84bf7a4a8fdd706d833cb5 Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 29 Oct 2026 10:10:34 -0400 Subject: [PATCH 15/15] A mask answer after its provisional no-mask was read rebuilds the faces built by it With the provisional no-mask of a deferred analysis silent, a mask texture analysed again answered mask as before and told nobody, so a face whose group was built while the analysis was out, by the provisional no-mask, stayed in the blend pass. getIsAlphaMask now notes when it answers the provisional no-mask of a texture whose last answer was mask (mProvisionalRead, atomic, since it is read off the main thread too), and an answer of mask after that is a change for the owner. A window nobody read stays silent, so a mask texture's uploads rebuild its faces only when a face was built in one. Co-Authored-By: Claude Opus 5.5 --- indra/llrender/llimagegl.cpp | 13 +++++++++++-- indra/llrender/llimagegl.h | 4 +++- indra/llrender/tests/llimagegl_test.cpp | 15 +++++++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/indra/llrender/llimagegl.cpp b/indra/llrender/llimagegl.cpp index d6fe23fbaa..d95f5d6bee 100644 --- a/indra/llrender/llimagegl.cpp +++ b/indra/llrender/llimagegl.cpp @@ -2664,16 +2664,20 @@ void LLImageGL::setAlphaAnalysis(const AlphaAnalysis& analysis) 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. + // 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 (mAnsweredMask != is_mask) + if (changed) { mAnsweredMask = is_mask; if (mOwner && on_main_thread()) @@ -3120,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; } diff --git a/indra/llrender/llimagegl.h b/indra/llrender/llimagegl.h index bc3eab3fec..1007203b73 100644 --- a/indra/llrender/llimagegl.h +++ b/indra/llrender/llimagegl.h @@ -375,7 +375,8 @@ class LLImageGL : public LLThreadSafeRefCount // 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. Cleared by the owner as it goes. + // 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 @@ -495,6 +496,7 @@ class LLImageGL : public LLThreadSafeRefCount 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; diff --git a/indra/llrender/tests/llimagegl_test.cpp b/indra/llrender/tests/llimagegl_test.cpp index ac0962ed2c..830b0388a1 100644 --- a/indra/llrender/tests/llimagegl_test.cpp +++ b/indra/llrender/tests/llimagegl_test.cpp @@ -2011,7 +2011,8 @@ namespace } // 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 only of an answer that differs from the last one. + // 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 @@ -2038,16 +2039,22 @@ namespace CHECK(img->getIsAlphaMask()); CHECK(owner->mChanges == 1); - UNSCOPED_INFO("the same answer after another deferral is no change"); + UNSCOPED_INFO("the same answer after another deferral, its provisional no-mask unread, is no change"); REQUIRE(img->createGLTexture(0, makeAlphaRaw(true, 64).get())); - CHECK_FALSE(img->getIsAlphaMask()); 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 == 2); + CHECK(owner->mChanges == 3); } }