From bf1cee1f792823147ed816c186edaf0fbe4cfa6a Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 21 Oct 2026 03:23:49 -0400 Subject: [PATCH 1/4] A volume knows from its parameters whether every LOD builds it alike Since a straight path with no twist and no taper takes one quad per profile edge, a square or triangle profile on it, with no hole or a square or triangle one, builds the same geometry at every detail. The split rule LLVolume::generate applies is now profile_split(), and LLVolume::isLODInvariant(params) asks it whether the split is 0 at every detail, then requires equal twist begin and end, a square or triangle profile, a hole that is not round, and no sculpt or mesh. A hole the clamp pulls in to its wall keeps its split points and so varies by LOD; the check lays the profile out once to see it, when a parameter set's LOD group is made, and each volume reads the same answer off its own profile in generate(). A test builds all four LODs of every sweep shape the predicate accepts and holds them byte-identical: positions, normals, UVs, tangents, indices and faces. Every shape it rejects differs at some LOD. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 143 +++++++++++++++--- indra/llmath/llvolume.h | 7 + indra/llmath/tests/llvolume_bench.cpp | 13 ++ indra/llmath/tests/llvolume_test.cpp | 208 ++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 21 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index a7331aa24b..0eead48ebe 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -2039,6 +2039,7 @@ LLVolume::LLVolume(const LLVolumeParams ¶ms, const F32 detail, const bool ge : mParams(params) { mUnique = is_unique; + mLODInvariant = false; mFaceMask = 0x0; mDetail = detail; mSculptLevel = -2; @@ -2108,6 +2109,120 @@ LLVolume::~LLVolume() mHullIndices = NULL; } +namespace +{ + // Which details split a prim's profile edges. + enum class ProfileSplit + { + // Every detail splits them as it asks. + BY_DETAIL, + // None does. + NONE, + // None does unless the profile's hole is drawn in to its wall, which + // takes the split its detail asks for. + NONE_UNLESS_HOLE_TOUCHES_WALL, + }; + + ProfileSplit profile_split(const LLVolumeParams& params) + { + const LLPathParams& path_params = params.getPathParams(); + if (path_params.getCurveType() != LL_PCODE_PATH_LINE) + { + return ProfileSplit::BY_DETAIL; + } + + // A straight path tapering a square or a triangle whose hole type is + // the default, the profile's own shape. + const U8 profile_curve = params.getProfileParams().getCurveType(); + const bool tapered = path_params.getScale().mV[0] != 1.0f || path_params.getScale().mV[1] != 1.0f; + if (tapered) + { + return profile_curve == LL_PCODE_PROFILE_SQUARE || profile_curve == LL_PCODE_PROFILE_ISOTRI || + profile_curve == LL_PCODE_PROFILE_EQUALTRI || profile_curve == LL_PCODE_PROFILE_RIGHTTRI + ? ProfileSplit::NONE + : ProfileSplit::BY_DETAIL; + } + + // A straight path that neither twists nor tapers sweeps every profile + // edge into a parallelogram, and every face is flat or ruled straight + // along the path: positions and UVs are affine over each quad, and the + // split points and the rings between the ends lie on the triangles the + // ends make. A hole drawn in to its wall is the exception: the wall + // draws in some of its points and not others, its edges no longer run + // straight between its corners, and its split points shape it. + return path_params.getTwistBegin() == path_params.getTwist() ? ProfileSplit::NONE_UNLESS_HOLE_TOUCHES_WALL : ProfileSplit::BY_DETAIL; + } + + // Whether a prim's parameters build it the same at every detail, as far + // as they say without laying its profile out. + enum class LODInvariance + { + NONE, + INVARIANT, + INVARIANT_UNLESS_HOLE_TOUCHES_WALL, + }; + + LODInvariance lod_invariance(const LLVolumeParams& params) + { + // A sculpt or a mesh has a resolution of its own. + if (params.getSculptType() != LL_SCULPT_TYPE_NONE || params.getSculptID().notNull()) + { + return LODInvariance::NONE; + } + + // The profile must take no split at any detail, and the path its two + // ends alone, which a line path does where it does not twist. + const ProfileSplit split = profile_split(params); + const LLPathParams& path_params = params.getPathParams(); + if (split == ProfileSplit::BY_DETAIL || path_params.getTwistBegin() != path_params.getTwist()) + { + return LODInvariance::NONE; + } + + // Unsplit, a square or a triangle has the same points at every + // detail, and so does a hole of straight sides; a round hole has as + // many sides as its detail asks for. + const LLProfileParams& profile_params = params.getProfileParams(); + const U8 profile_curve = profile_params.getCurveType() & LL_PCODE_PROFILE_MASK; + if (profile_curve != LL_PCODE_PROFILE_SQUARE && profile_curve != LL_PCODE_PROFILE_ISOTRI && profile_curve != LL_PCODE_PROFILE_EQUALTRI && + profile_curve != LL_PCODE_PROFILE_RIGHTTRI) + { + return LODInvariance::NONE; + } + if (profile_params.getHollow() == 0.f) + { + return LODInvariance::INVARIANT; + } + if ((profile_params.getCurveType() & LL_PCODE_HOLE_MASK) == LL_PCODE_HOLE_CIRCLE) + { + return LODInvariance::NONE; + } + return split == ProfileSplit::NONE ? LODInvariance::INVARIANT : LODInvariance::INVARIANT_UNLESS_HOLE_TOUCHES_WALL; + } + + // Whether a prim is LOD-invariant, from what its parameters say and its + // profile as LLVolume::generate first lays it out, without split points. + bool keeps_lod_invariance(LODInvariance invariance, const LLProfile& profile) + { + return invariance == LODInvariance::INVARIANT || (invariance == LODInvariance::INVARIANT_UNLESS_HOLE_TOUCHES_WALL && !profile.holeTouchesWall()); + } +} + +// static +bool LLVolume::isLODInvariant(const LLVolumeParams& params) +{ + const LODInvariance invariance = lod_invariance(params); + if (invariance == LODInvariance::NONE) + { + return false; + } + + // The profile as generate() first lays it out. + thread_local LLProfile profile; + profile.setDirty(); + return profile.generate(params.getProfileParams(), true) && keeps_lod_invariance(invariance, profile); +} + bool LLVolume::generate() { LL_PROFILE_ZONE_SCOPED_CATEGORY_VOLUME; @@ -2120,27 +2235,9 @@ bool LLVolume::generate() // stretched due to twisting or scaling on the path. S32 split = (S32) ((mDetail)*0.66f); - const LLPathParams& path_params = mParams.getPathParams(); - const bool tapered = path_params.getScale().mV[0] != 1.0f || path_params.getScale().mV[1] != 1.0f; - if (path_params.getCurveType() == LL_PCODE_PATH_LINE && - tapered && - (mParams.getProfileParams().getCurveType() == LL_PCODE_PROFILE_SQUARE || - mParams.getProfileParams().getCurveType() == LL_PCODE_PROFILE_ISOTRI || - mParams.getProfileParams().getCurveType() == LL_PCODE_PROFILE_EQUALTRI || - mParams.getProfileParams().getCurveType() == LL_PCODE_PROFILE_RIGHTTRI)) - { - split = 0; - } - - // A straight path that neither twists nor tapers sweeps every profile - // edge into a parallelogram, and every face is flat or ruled straight - // along the path: positions and UVs are affine over each quad, and the - // split points and the rings between the ends lie on the triangles the - // ends make. A hole drawn in to its wall is the exception: the wall - // draws in some of its points and not others, its edges no longer run - // straight between its corners, and its split points shape it. - const S32 lighting_split = split; - if (path_params.getCurveType() == LL_PCODE_PATH_LINE && !tapered && path_params.getTwistBegin() == path_params.getTwist()) + const ProfileSplit split_rule = profile_split(mParams); + const S32 lighting_split = split_rule == ProfileSplit::NONE ? 0 : split; + if (split_rule != ProfileSplit::BY_DETAIL) { split = 0; } @@ -2167,6 +2264,10 @@ bool LLVolume::generate() bool regenPath = mPathp->generate(mParams.getPathParams(), path_detail, split); bool regenProf = mProfilep->generate(mParams.getProfileParams(), mPathp->isOpen(),profile_detail, split); + if (regenProf) + { + mLODInvariant = !mUnique && !mGenerateSingleFace && keeps_lod_invariance(lod_invariance(mParams), *mProfilep); + } if (regenProf && split < lighting_split && mProfilep->holeTouchesWall()) { mProfilep->setDirty(); diff --git a/indra/llmath/llvolume.h b/indra/llmath/llvolume.h index dcc6e41a12..9366684537 100644 --- a/indra/llmath/llvolume.h +++ b/indra/llmath/llvolume.h @@ -1081,6 +1081,12 @@ class LLVolume : public LLRefCount bool isFlat(S32 face); bool isUnique() const { return mUnique; } + // Whether generate() builds the same profile, path and faces from these + // parameters at every detail, so that one volume serves every LOD. + static bool isLODInvariant(const LLVolumeParams& params); + // Whether this volume is shared and its parameters are LOD-invariant. + bool isLODInvariant() const { return mLODInvariant; } + S32 getSculptLevel() const { return mSculptLevel; } void setSculptLevel(S32 level) { mSculptLevel = level; } @@ -1148,6 +1154,7 @@ class LLVolume : public LLRefCount protected: bool mUnique; + bool mLODInvariant; F32 mDetail; S32 mSculptLevel; F32 mSurfaceArea; //unscaled surface area diff --git a/indra/llmath/tests/llvolume_bench.cpp b/indra/llmath/tests/llvolume_bench.cpp index 3942ec41a4..346d991744 100644 --- a/indra/llmath/tests/llvolume_bench.cpp +++ b/indra/llmath/tests/llvolume_bench.cpp @@ -588,6 +588,19 @@ namespace } } + TEST_CASE("llvolume_bench: whether a prim of each class is LOD-invariant", "[llvolume_bench][!benchmark]") + { + ll_test::skipUnlessOptimised(); + for (const Prim& prim : primClasses()) + { + const LLVolumeParams params = prim.params; + BENCHMARK(fmt::format("{}, {}", prim.name, LLVolume::isLODInvariant(params) ? "invariant" : "not invariant")) + { + return LLVolume::isLODInvariant(params); + }; + } + } + TEST_CASE("llvolume_bench: a sculpt of each stitching built from its map", "[llvolume_bench][!benchmark]") { ll_test::skipUnlessOptimised(); diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 37afec0356..f770d1b321 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -42,6 +42,7 @@ #include "linden_common.h" #include "llvolume.h" +#include "llvolumemgr.h" #include "llmath.h" #include "llquaternion.h" #include "m3math.h" @@ -3996,4 +3997,211 @@ namespace CHECK(left_out > 0); CHECK(report.failures == 0); } + + + // What first differs between two volumes, or nothing where they are the + // same bytes: the profile's faces and points, the path's length, the + // mesh, and each face's layout, bounds, positions, normals, UVs, + // tangents and indices. + std::string differenceBetween(LLVolume& a, LLVolume& b) + { + auto same = [](const void* x, const void* y, size_t bytes) { return bytes == 0 || std::memcmp(x, y, bytes) == 0; }; + const LLProfile& pa = a.getProfile(); + const LLProfile& pb = b.getProfile(); + if (pa.mFaces.size() != pb.mFaces.size()) + { + return fmt::format("{} profile faces against {}", pa.mFaces.size(), pb.mFaces.size()); + } + for (size_t i = 0; i < pa.mFaces.size(); ++i) + { + const LLProfile::Face& fa = pa.mFaces[i]; + const LLProfile::Face& fb = pb.mFaces[i]; + if (fa.mIndex != fb.mIndex || fa.mCount != fb.mCount || fa.mScaleU != fb.mScaleU || fa.mCap != fb.mCap || fa.mFlat != fb.mFlat || + fa.mFaceID != fb.mFaceID || fa.mFirstCorner != fb.mFirstCorner || fa.mCornerStep != fb.mCornerStep) + { + return fmt::format("profile face {}", i); + } + } + if (pa.mProfile.size() != pb.mProfile.size() || !same(pa.mProfile.data(), pb.mProfile.data(), sizeof(LLVector4a) * pa.mProfile.size())) + { + return "the profile's points"; + } + if (a.getPath().getPathLength() != b.getPath().getPathLength()) + { + return fmt::format("{} path points against {}", a.getPath().getPathLength(), b.getPath().getPathLength()); + } + if (a.getMesh().size() != b.getMesh().size() || !same(a.getMesh().data(), b.getMesh().data(), sizeof(LLVector4a) * a.getMesh().size())) + { + return "the mesh"; + } + if (a.getNumVolumeFaces() != b.getNumVolumeFaces()) + { + return fmt::format("{} faces against {}", a.getNumVolumeFaces(), b.getNumVolumeFaces()); + } + for (S32 i = 0; i < a.getNumVolumeFaces(); ++i) + { + a.genTangents(i); + b.genTangents(i); + const LLVolumeFace& fa = a.getVolumeFace(i); + const LLVolumeFace& fb = b.getVolumeFace(i); + const std::string name = faceName(fa); + if (fa.mID != fb.mID || fa.mTypeMask != fb.mTypeMask || fa.mBeginS != fb.mBeginS || fa.mBeginT != fb.mBeginT || fa.mNumS != fb.mNumS || + fa.mNumT != fb.mNumT || fa.mJoinsS != fb.mJoinsS || fa.mJoinsT != fb.mJoinsT || fa.mReversed != fb.mReversed || + fa.mNumVertices != fb.mNumVertices || fa.mNumIndices != fb.mNumIndices) + { + return fmt::format("{}: its layout", name); + } + if (!same(fa.mExtents, fb.mExtents, 2 * sizeof(LLVector4a)) || !same(fa.mCenter, fb.mCenter, sizeof(LLVector4a)) || + !same(fa.mTexCoordExtents, fb.mTexCoordExtents, sizeof(fa.mTexCoordExtents))) + { + return fmt::format("{}: its bounds", name); + } + const size_t vertices = fa.mNumVertices; + if (!same(fa.mPositions, fb.mPositions, sizeof(LLVector4a) * vertices)) + { + return fmt::format("{}: its positions", name); + } + if (!same(fa.mNormals, fb.mNormals, sizeof(LLVector4a) * vertices)) + { + return fmt::format("{}: its normals", name); + } + if (!same(fa.mTexCoords, fb.mTexCoords, sizeof(LLVector2) * vertices)) + { + return fmt::format("{}: its UVs", name); + } + if ((fa.mTangents == nullptr) != (fb.mTangents == nullptr) || (fa.mTangents && !same(fa.mTangents, fb.mTangents, sizeof(LLVector4a) * vertices))) + { + return fmt::format("{}: its tangents", name); + } + if (!same(fa.mIndices, fb.mIndices, sizeof(U16) * fa.mNumIndices)) + { + return fmt::format("{}: its indices", name); + } + } + return {}; + } + + // A prim is LOD-invariant where its parameters build it the same at + // every detail: a sculpt, a mesh and a flexible prim are not; nor is a + // prim whose path is round, twists, or tapers a profile whose hole type + // is not the default, nor one whose profile or hole is round, nor one + // whose hole is drawn in to its wall. Every prim of the sweep the parameters + // call invariant is the same bytes at every LOD, and every other one + // differs at some LOD. + TEST_CASE("llvolume: a prim LOD-invariant by its parameters is the same bytes at every LOD", "[llvolume]") + { + Shape hollow_box = box(); + hollow_box.name = "hollow box"; + hollow_box.hollow = 0.5f; + Shape tapered = box(); + tapered.name = "tapered box"; + tapered.scale_x = 0.4f; + Shape tapered_hole = tapered; + tapered_hole.name = "tapered box with a square hole"; + tapered_hole.hole = LL_PCODE_HOLE_SQUARE; + tapered_hole.hollow = 0.5f; + Shape twisted = box(); + twisted.name = "twisted box"; + twisted.twist_end = 0.5f; + Shape turned = box(); + turned.name = "box turned a quarter turn"; + turned.twist_begin = turned.twist_end = 0.25f; + Shape circle_hole = box(); + circle_hole.name = "box with a circle hole"; + circle_hole.hole = LL_PCODE_HOLE_CIRCLE; + circle_hole.hollow = 0.5f; + Shape solid_circle_hole = circle_hole; + solid_circle_hole.name = "box with a circle hole of no size"; + solid_circle_hole.hollow = 0.f; + Shape drawn_in = prism(); + drawn_in.name = "prism with a square hole drawn in to its wall"; + drawn_in.hole = LL_PCODE_HOLE_SQUARE; + drawn_in.hollow = 0.85f; + Shape clear = drawn_in; + clear.name = "prism with a square hole clear of its wall"; + clear.hollow = 0.5f; + Report report; + std::vector rejected_but_same; + // A shape at each LOD against its LOD 0: every LOD is the same bytes + // where its parameters call it invariant, and it is listed where they + // do not and no LOD differs. Returns what they call it. + auto check = [&](const Shape& s) + { + const bool invariant = LLVolume::isLODInvariant(s.params()); + LLPointer lowest = build(s, 0); + if (lowest->isLODInvariant() != invariant) + { + report.fail(fmt::format("{}: the volume and its parameters disagree", s.name)); + } + for (S32 lod = 1; lod < LOD_COUNT; ++lod) + { + LLPointer other = build(s, lod); + const std::string difference = differenceBetween(*lowest, *other); + if (!difference.empty()) + { + if (invariant) + { + report.fail(fmt::format("{}: LOD 0 and {} differ in {}", s.name, lod, difference)); + } + return invariant; + } + } + if (!invariant) + { + rejected_but_same.push_back(s.name); + } + return invariant; + }; + + struct Expected + { + Shape shape; + bool invariant; + }; + for (const Expected& expected : { Expected{ box(), true }, Expected{ hollow_box, true }, Expected{ prism(), true }, Expected{ tapered, true }, + Expected{ turned, true }, Expected{ solid_circle_hole, true }, Expected{ clear, true }, + Expected{ tapered_hole, false }, Expected{ twisted, false }, Expected{ circle_hole, false }, + Expected{ drawn_in, false }, Expected{ cylinder(), false }, Expected{ sphere(), false }, + Expected{ tube(), false } }) + { + if (check(expected.shape) != expected.invariant) + { + report.fail(fmt::format("{}: {} invariant", expected.shape.name, expected.invariant ? "not" : "called")); + } + } + const LLUUID asset("6d3b2a1e-4c5f-4e8a-9b7d-0f1e2d3c4b5a"); + LLVolumeParams sculpt = box().params(); + sculpt.setSculptID(asset, LL_SCULPT_TYPE_SPHERE); + CHECK_FALSE(LLVolume::isLODInvariant(sculpt)); + LLVolumeParams mesh = box().params(); + mesh.setSculptID(asset, LL_SCULPT_TYPE_MESH); + CHECK_FALSE(LLVolume::isLODInvariant(mesh)); + LLVolumeParams flexible = box().params(); + flexible.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_FLEXIBLE); + CHECK_FALSE(LLVolume::isLODInvariant(flexible)); + + // A volume says so only where it is shared. + CHECK(build(box(), 0)->isLODInvariant()); + CHECK_FALSE(build(box(), 0, true)->isLODInvariant()); + CHECK_FALSE(build(cylinder(), 0)->isLODInvariant()); + + // The sweep's invariant prims: its straight untwisted squares and + // triangles, plain, cut and sheared, with no hole or one of straight + // sides clear of the wall, and tapered with no hole or one of the + // default type. + S32 accepted = 0; + S32 rejected = 0; + for (const Shape& s : sweepShapes()) + { + ++(check(s) ? accepted : rejected); + } + INFO(fmt::format("{} prims of the sweep invariant and {} not", accepted, rejected)); + INFO(fmt::format("{} not invariant but the same at every LOD{}{}", rejected_but_same.size(), rejected_but_same.empty() ? "" : ", the first ", + rejected_but_same.empty() ? "" : rejected_but_same.front())); + INFO(report.text()); + CHECK(report.failures == 0); + CHECK(rejected_but_same.empty()); + CHECK(accepted == 84); + } + } From ad53e417cb7d004a3c652354b7b8b5993e287eba Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 21 Oct 2026 03:24:42 -0400 Subject: [PATCH 2/4] An LOD-invariant shape is generated once for all its LODs LLVolumeLODGroup::refLOD built a volume for each LOD an object asked for, though a box, a prism or a hollow, cut, tapered or sheared box comes out the same at all four. For a shape whose parameters say so, refLOD now hands out one volume, its LOD 3 one, for every LOD. derefLOD already finds a volume by pointer, so the counts stay consistent; a test refs and unrefs in mixed order and checks the group cleans up. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolumemgr.cpp | 11 ++++++--- indra/llmath/llvolumemgr.h | 2 ++ indra/llmath/tests/llvolume_test.cpp | 37 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/indra/llmath/llvolumemgr.cpp b/indra/llmath/llvolumemgr.cpp index d8f649140f..01b9e3fce5 100644 --- a/indra/llmath/llvolumemgr.cpp +++ b/indra/llmath/llvolumemgr.cpp @@ -248,6 +248,7 @@ std::ostream& operator<<(std::ostream& s, const LLVolumeMgr& volume_mgr) LLVolumeLODGroup::LLVolumeLODGroup(const LLVolumeParams ¶ms) : mVolumeParams(params), + mLODInvariant(LLVolume::isLODInvariant(params)), mRefs(0) { for (S32 i = 0; i < NUM_LODS; i++) @@ -293,13 +294,15 @@ LLVolume* LLVolumeLODGroup::refLOD(const S32 lod) llassert(lod >=0 && lod < NUM_LODS); mAccessCount[lod]++; + const S32 slot = mLODInvariant ? NUM_LODS - 1 : lod; mRefs++; - if (mVolumeLODs[lod].isNull()) + if (mVolumeLODs[slot].isNull()) { - mVolumeLODs[lod] = new LLVolume(mVolumeParams, mDetailScales[lod]); + mVolumeLODs[slot] = new LLVolume(mVolumeParams, mDetailScales[slot]); + llassert(mVolumeLODs[slot]->isLODInvariant() == mLODInvariant); } - mLODRefs[lod]++; - return mVolumeLODs[lod]; + mLODRefs[slot]++; + return mVolumeLODs[slot]; } bool LLVolumeLODGroup::derefLOD(LLVolume *volumep) diff --git a/indra/llmath/llvolumemgr.h b/indra/llmath/llvolumemgr.h index 2e0ce3e88a..a535df110d 100644 --- a/indra/llmath/llvolumemgr.h +++ b/indra/llmath/llvolumemgr.h @@ -67,6 +67,8 @@ class LLVolumeLODGroup protected: LLVolumeParams mVolumeParams; + // Every LOD of LOD-invariant parameters refs one volume, the highest LOD's. + bool mLODInvariant; S32 mRefs; S32 mLODRefs[NUM_LODS]; LLPointer mVolumeLODs[NUM_LODS]; diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index f770d1b321..207c9567a8 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -4204,4 +4204,41 @@ namespace CHECK(accepted == 84); } + // The volume manager refs one volume, the highest LOD's, for every LOD + // of an LOD-invariant prim, and a volume of its own for each LOD of any + // other; each unref returns the ref its LOD took, in any order. + TEST_CASE("llvolume: the volume manager shares one volume between the LODs of an LOD-invariant prim", "[llvolume]") + { + LLVolumeMgr manager; + Shape hollow_box = box(); + hollow_box.hollow = 0.5f; + for (const Shape& s : { box(), hollow_box, prism(), cylinder(), torus() }) + { + const LLVolumeParams params = s.params(); + const bool invariant = LLVolume::isLODInvariant(params); + INFO(s.name); + LLVolume* volumes[LOD_COUNT]; + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + volumes[lod] = manager.refVolume(params, lod); + } + // A second object at LOD 1 shares what the first one has. + CHECK(manager.refVolume(params, 1) == volumes[1]); + const LLVolumeLODGroup* group = manager.getGroup(params); + REQUIRE(group != nullptr); + CHECK(group->getNumRefs() == LOD_COUNT + 1); + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + CHECK(volumes[lod]->isLODInvariant() == invariant); + CHECK(volumes[lod]->getDetail() == (invariant ? LOD_DETAIL[LOD_COUNT - 1] : LOD_DETAIL[lod])); + CHECK((volumes[lod] == volumes[LOD_COUNT - 1]) == (invariant || lod == LOD_COUNT - 1)); + } + for (S32 lod : { 3, 1, 0, 1, 2 }) + { + manager.unrefVolume(volumes[lod]); + } + CHECK(manager.getGroup(params) == nullptr); + } + CHECK(manager.cleanup()); + } } From b5c3678a26be062f4e8e2eb872a0684b55ec7a0e Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 21 Oct 2026 03:24:58 -0400 Subject: [PATCH 3/4] An LOD change leaves an LOD-invariant prim and its spatial group alone When calcLOD moved an object's LOD, updateLOD marked REBUILD_VOLUME before anything asked whether the geometry would change, so a box crossing an LOD threshold refetched its volume, rebuilt its faces and had its spatial group's buffers rebuilt, for geometry that was the same. An LOD-invariant volume now takes the path an unchanged LOD takes: mLOD still moves, for the LOD display, object weights and streaming cost, and nothing is rebuilt. LLPrimitive::setVolume also returns early for an LOD-invariant volume with the same parameters. Without that, a later setVolume at the object's current LOD took the path that hands back the same volume and reported a change. Sculpts, meshes, flexible prims and the unique single-face volume a sculpt unload makes are never LOD-invariant, so their paths are as they were. Co-Authored-By: Claude Opus 5.5 --- indra/llprimitive/llprimitive.cpp | 3 ++- indra/llprimitive/tests/llprimitive_test.cpp | 28 ++++++++++++++++++++ indra/newview/llvovolume.cpp | 4 ++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/indra/llprimitive/llprimitive.cpp b/indra/llprimitive/llprimitive.cpp index cc61a1f19f..6279854641 100644 --- a/indra/llprimitive/llprimitive.cpp +++ b/indra/llprimitive/llprimitive.cpp @@ -796,8 +796,9 @@ bool LLPrimitive::setVolume(const LLVolumeParams &volume_params, const S32 detai { if (mVolumep.notNull()) { + // An LOD-invariant volume serves every detail of its parameters. F32 volume_detail = LLVolumeLODGroup::getVolumeScaleFromDetail(detail); - if (volume_params == mVolumep->getParams() && (volume_detail == mVolumep->getDetail())) + if (volume_params == mVolumep->getParams() && (volume_detail == mVolumep->getDetail() || mVolumep->isLODInvariant())) { return false; } diff --git a/indra/llprimitive/tests/llprimitive_test.cpp b/indra/llprimitive/tests/llprimitive_test.cpp index 9014e31918..f0fcdd7ca4 100644 --- a/indra/llprimitive/tests/llprimitive_test.cpp +++ b/indra/llprimitive/tests/llprimitive_test.cpp @@ -317,6 +317,34 @@ namespace CHECK(light.getLinearColor() == LLColor4(1.f, 0.f, 0.f, 1.f)); } + + TEST_CASE_METHOD(llprimitive, "LLPrimitive: setVolume keeps an LOD-invariant shared volume at every LOD", "[LLPrimitive]") + { + // A box is the same at every LOD and keeps the volume it has. + LLPrimitive box; + LLVolumeParams box_params; + REQUIRE(LLVolume::isLODInvariant(box_params)); + CHECK(box.setVolume(box_params, 0, false)); + LLVolume* box_volume = box.getVolume(); + REQUIRE(box_volume != NULL); + for (S32 lod : { 1, 2, 3, 0 }) + { + CHECK_FALSE(box.setVolume(box_params, lod, false)); + CHECK(box.getVolume() == box_volume); + } + + // A cylinder takes the volume of each LOD it asks for. + LLPrimitive cylinder; + LLVolumeParams cylinder_params; + cylinder_params.setType(LL_PCODE_PROFILE_CIRCLE, LL_PCODE_PATH_LINE); + REQUIRE_FALSE(LLVolume::isLODInvariant(cylinder_params)); + CHECK(cylinder.setVolume(cylinder_params, 0, false)); + for (S32 lod : { 1, 2, 3 }) + { + CHECK(cylinder.setVolume(cylinder_params, lod, false)); + CHECK(cylinder.getVolume()->getDetail() == LLVolumeLODGroup::getVolumeScaleFromDetail(lod)); + } + } } #include "llmessagesystem_stub.cpp" diff --git a/indra/newview/llvovolume.cpp b/indra/newview/llvovolume.cpp index 5ec1f6eaa7..079e9c1fb9 100644 --- a/indra/newview/llvovolume.cpp +++ b/indra/newview/llvovolume.cpp @@ -1716,7 +1716,9 @@ bool LLVOVolume::updateLOD() return false; } - if (lod_changed) + // An LOD-invariant volume is the same at the new LOD, so its faces and + // its spatial group's buffers stay as they are. + if (lod_changed && !getVolume()->isLODInvariant()) { gPipeline.markRebuild(mDrawable, LLDrawable::REBUILD_VOLUME); mLODChanged = true; From 5f2c58fab849bf3fba15117c7be0ad929f4c2ada Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 21 Oct 2026 03:25:17 -0400 Subject: [PATCH 4/4] Volume LOD moves, LOD rebuilds and spatial group rebuilds are plotted per frame Tracy plots, per frame: volumes whose LOD moved ("volume LOD moves"), those of them marked for a volume rebuild ("volume LOD rebuilds"), the faces those rebuilds touched ("volume LOD change faces", sNumLODChanges) and volume spatial groups whose geometry was rebuilt ("volume group rebuilds"). Two captures of the same camera sweep with equal LOD moves compare their rebuilds directly. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llvovolume.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/indra/newview/llvovolume.cpp b/indra/newview/llvovolume.cpp index 079e9c1fb9..3c0232f2bc 100644 --- a/indra/newview/llvovolume.cpp +++ b/indra/newview/llvovolume.cpp @@ -112,6 +112,15 @@ LLPointer LLVOVolume::sObjectMediaNavigateClient = extern bool gCubeSnapshot; +namespace +{ + // A frame's volumes whose LOD moved, those of them marked for a volume + // rebuild, and volume spatial groups whose geometry was rebuilt. + S32 sNumLODMoves = 0; + S32 sNumLODRebuilds = 0; + S32 sNumGroupRebuilds = 0; +} + // Implementation class of LLMediaDataClientObject. See llmediadataclient.h class LLMediaDataClientObjectImpl : public LLMediaDataClientObject { @@ -1718,10 +1727,12 @@ bool LLVOVolume::updateLOD() // An LOD-invariant volume is the same at the new LOD, so its faces and // its spatial group's buffers stay as they are. + sNumLODMoves += lod_changed ? 1 : 0; if (lod_changed && !getVolume()->isLODInvariant()) { gPipeline.markRebuild(mDrawable, LLDrawable::REBUILD_VOLUME); mLODChanged = true; + ++sNumLODRebuilds; } else { @@ -4511,6 +4522,14 @@ U32 LLVOVolume::getHighLODTriangleCount() //static void LLVOVolume::preUpdateGeom() { + // Counted since the previous frame's call. + LL_PROFILE_PLOT("volume LOD moves", (int64_t)sNumLODMoves); + LL_PROFILE_PLOT("volume LOD rebuilds", (int64_t)sNumLODRebuilds); + LL_PROFILE_PLOT("volume LOD change faces", (int64_t)sNumLODChanges); + LL_PROFILE_PLOT("volume group rebuilds", (int64_t)sNumGroupRebuilds); + sNumLODMoves = 0; + sNumLODRebuilds = 0; + sNumGroupRebuilds = 0; sNumLODChanges = 0; } @@ -5937,6 +5956,7 @@ void LLVolumeGeometryManager::rebuildGeom(LLSpatialGroup* group) return; } + ++sNumGroupRebuilds; group->mBuilt = 1.f; LLSpatialBridge* bridge = group->getSpatialPartition()->asBridge();