From 239160f5da255585f7f4bb4361ba6fd8e5aefcc1 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:36:51 -0400 Subject: [PATCH 01/29] A sculpt's path keeps the station a rounding short of its end genNGon's end-station fix left out a station the summed steps bring a rounding short of the end, for every circle path. A sculpt's path is a circle path too, its station count the rows of the map it samples, so a sculpt whose path took one of those side counts, as a map twice as tall as it is wide does at LOD 1 and up, sampled other rows than before and than every other viewer, and its shape changed. A sculpted path keeps the station; a prim's still leaves it out. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 9 ++++---- indra/llmath/llvolume.h | 3 ++- indra/llmath/tests/llvolume_test.cpp | 32 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index e2db948e76..e25d3009b8 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -1293,7 +1293,7 @@ S32 LLPath::getNumNGonPoints(const LLPathParams& params, S32 sides, F32 startOff return ret; } -void LLPath::genNGon(const LLPathParams& params, S32 sides, F32 startOff, F32 end_scale, F32 twist_scale) +void LLPath::genNGon(const LLPathParams& params, S32 sides, F32 startOff, F32 end_scale, F32 twist_scale, bool is_sculpted) { LL_PROFILE_ZONE_SCOPED_CATEGORY_VOLUME; @@ -1401,8 +1401,9 @@ void LLPath::genNGon(const LLPathParams& params, S32 sides, F32 startOff, F32 en // Run through the non-cut dependent points. A point within a hundredth // of a step of the end cut is the end's own, short of it only by the // rounding the steps add up to: it would leave a row with no height - // before the end's. - const F32 end_before = params.getEnd() - step * 0.01f; + // before the end's. A sculpt keeps it: its rows are the rows of its map + // it samples, one a station, as every viewer samples them. + const F32 end_before = is_sculpted ? params.getEnd() : params.getEnd() - step * 0.01f; while (t < end_before) { pt = mPath.append(1); @@ -1604,7 +1605,7 @@ bool LLPath::generate(const LLPathParams& params, F32 detail, S32 split, sides = llmax(sculpt_size, 1); if (0 < sides) - genNGon(params, sides); + genNGon(params, sides, 0.f, 1.f, 1.f, is_sculpted); } break; diff --git a/indra/llmath/llvolume.h b/indra/llmath/llvolume.h index 0454a539e0..2fae980f4d 100644 --- a/indra/llmath/llvolume.h +++ b/indra/llmath/llvolume.h @@ -821,7 +821,8 @@ class LLPath static S32 getNumPoints(const LLPathParams& params, F32 detail); static S32 getNumNGonPoints(const LLPathParams& params, S32 sides, F32 offset=0.0f, F32 end_scale = 1.f, F32 twist_scale = 1.f); - void genNGon(const LLPathParams& params, S32 sides, F32 offset=0.0f, F32 end_scale = 1.f, F32 twist_scale = 1.f); + void genNGon(const LLPathParams& params, S32 sides, F32 offset=0.0f, F32 end_scale = 1.f, F32 twist_scale = 1.f, + bool is_sculpted = false); virtual bool generate(const LLPathParams& params, F32 detail=1.0f, S32 split = 0, bool is_sculpted = false, S32 sculpt_size = 0); diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 938f7ca218..43f256a256 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -4118,6 +4118,38 @@ namespace CHECK(report.failures == 0); } + // A sculpt samples a row of its map at each station of its path, as + // every viewer samples it, so its path keeps the station the summed + // steps bring a rounding short of the end, which a prim's leaves out: + // its last station before the end is the last step the steps take + // short of the end, and a step on from it is not short of the end. + TEST_CASE("llvolume: a sculpt's path keeps a station a rounding short of its end", "[llvolume]") + { + Report report; + S32 kept = 0; + const LLPathParams params = sphere().params().getPathParams(); + for (S32 sides = 2; sides <= 300; ++sides) + { + LLPath path; + path.generate(params, 1.f, 0, true, sides); + const S32 count = (S32)path.mPath.size(); + const F32 step = 1.0f / sides; + const F32 last = path.mPath[count - 2].mTexT; + if (!(last < params.getEnd()) || last + step < params.getEnd()) + { + report.fail( + fmt::format("{} sides: the last station before the end is at t {}, a step of {} short of the end", sides, last, step)); + } + if (last > params.getEnd() - step * 0.01f) + { + ++kept; + } + } + INFO(report.text()); + CHECK(report.failures == 0); + CHECK(kept > 0); + } + // A triangle whose UVs have no area has no dP/du, and one whose // positions have no area has no surface to give one: neither adds to // its corners' tangents, so a real neighbour's tangent stands alone. From ca61afd1c80696d3cba6253c2237404884197938 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:36:54 -0400 Subject: [PATCH 02/29] A flexible prim never joins its first and last rows A flexible prim's volume is first built on two stations, both at the origin, before the rebuild gives it a pose: its first and last rows are one place there, so its sides joined them, and every partial build kept the join. A flexible prim of one section, as a distant one is drawn, never builds in full again, and summed the normals of its two ends. Its path is open, and its sides join no rows. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 6 +++-- indra/llmath/tests/llvolume_test.cpp | 33 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index e25d3009b8..a1e8228799 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -8126,13 +8126,15 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) // one seam, and across its first and last rows: a prim's where they are // one place in every row or column, as its own positions say; a // sculpt's where its stitching samples them from one column or row of - // the map. A flat inner side's seam is one of its corners. + // the map. A flat inner side's seam is one of its corners. A flexible + // prim's path is open, and its first build, which its partial builds + // keep the joins of, lays every row at one place before it has a pose. if (!partial_build) { if (sculpt_stitching == LL_SCULPT_TYPE_NONE) { mJoinsS = !doubled && columns_meet(pos, mNumS, mNumT, 0, mNumS - 1); - mJoinsT = rows_meet(pos, mNumS, 0, mNumT - 1); + mJoinsT = !mFlexible && rows_meet(pos, mNumS, 0, mNumT - 1); } else { diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 43f256a256..91a4ead0ac 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -4317,6 +4317,39 @@ namespace CHECK(report.failures == 0); } + // A flexible prim of one section, as a distant one is drawn, keeps the + // two stations its volume is first built with, both at the origin + // before the rebuild gives them a pose, so every frame is a partial + // build of that first one. Its first and last rows were one place + // there, and are not joined for it: its normals are those a full build + // at its pose makes. + TEST_CASE("llvolume: a flexible prim of one section keeps its first and last rows apart", "[llvolume]") + { + Report report; + for (U8 profile : { LL_PCODE_PROFILE_SQUARE, LL_PCODE_PROFILE_CIRCLE }) + { + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + const std::string name = + fmt::format("flexible {}, one section, LOD {}", profile == LL_PCODE_PROFILE_SQUARE ? "box" : "cylinder", lod); + LLPointer volume = flexiVolume(profile, LL_PCODE_HOLE_SAME, 0.f, lod); + REQUIRE(volume->getPath().getPathLength() == 2); + bendPath(*volume, 1, 1.1f); + volume->regen(); + genAllTangents(*volume); + + LLPointer full = flexiVolume(profile, LL_PCODE_HOLE_SAME, 0.f, lod); + full->resizePath(2); + bendPath(*full, 1, 1.1f); + full->regen(); + genAllTangents(*full); + compareBuilds(*volume, *full, report, name); + } + } + INFO(report.text()); + CHECK(report.failures == 0); + } + // The uncut cube cap is built only on a line path, which flexi never // has; its partial build is the one a regen of an unmoved box makes. TEST_CASE("llvolume: an uncut cube cap's partial build keeps its buffers", "[llvolume]") From b046ff530fce73cd38b1ee87c518e09ccfcfa266 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:36:57 -0400 Subject: [PATCH 03/29] A sphere's pole normals face the way its twist turns its poles A sphere's poles took +x at its first column and -x at its last, where an untwisted half circle puts them. A twist of a half turn at both ends puts each pole at the other end of the axis, which turned both pole normals into the sphere, and those of a hollow sphere's inner side out of it. A pole now takes the axis away from the centre on an outer side and toward it on an inner one. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 34 ++++++++------- indra/llmath/tests/llvolume_test.cpp | 62 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index a1e8228799..41a3f56053 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -8201,26 +8201,28 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) } // On a sphere's side, a column that is one place in every row is a pole - // on the axis the half circle sweeps about, and takes the axis. A cut - // end of a hollow sphere lies on the axis, and has no area. A flat inner - // side is a cone at a pole, and its ends keep the normals of the strips - // they end. + // on the axis the half circle sweeps about, and takes the axis: away + // from the sphere's centre on an outer side, toward it on an inner one. + // Which end of the axis a column's pole is at follows the twist, which + // turns the half circle about its centre. A cut end of a hollow sphere + // lies on the axis, and has no area. A flat inner side is a cone at a + // pole, and its ends keep the normals of the strips they end. if (sculpt_stitching == LL_SCULPT_TYPE_NONE && !(mTypeMask & END_MASK) && !doubled && volume->getPathType() == LL_PCODE_PATH_CIRCLE && ((volume->getProfileType() & LL_PCODE_PROFILE_MASK) == LL_PCODE_PROFILE_CIRCLE_HALF)) { - if (column_is_point(pos, mNumS, mNumT, 0)) - { //all lower S have same normal - for (S32 i = 0; i < mNumT; i++) - { - norm[mNumS*i].set(1,0,0); - } - } - - if (column_is_point(pos, mNumS, mNumT, mNumS - 1)) - { //all upper S have same normal - for (S32 i = 0; i < mNumT; i++) + const bool outer = !(mTypeMask & INNER_MASK); + for (S32 column : { 0, mNumS - 1 }) + { + if (column_is_point(pos, mNumS, mNumT, column)) { - norm[mNumS*i+mNumS-1].set(-1,0,0); + // A pole at the centre, of a sphere with no size, takes the + // side it has untwisted. + const F32 at = pos[column][0]; + const F32 x = at == 0.f ? (column == 0 ? 1.f : -1.f) : ((at > 0.f) == outer ? 1.f : -1.f); + for (S32 i = 0; i < mNumT; i++) + { + norm[mNumS * i + column].set(x, 0.f, 0.f); + } } } } diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 91a4ead0ac..dabf9a70fc 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -4425,6 +4425,68 @@ namespace CHECK(report.failures == 0); } + // A sphere's poles are where its half circle touches the axis it sweeps + // about, and their normals lie along that axis: out of the sphere on its + // outer side, into it on a hollow sphere's inner side. A twist of a half + // turn at both ends puts each pole at the other end of the axis. + TEST_CASE("llvolume: a sphere's pole normals face out of it at any half turn of twist", "[llvolume]") + { + Report report; + S32 poles = 0; + for (F32 twist : { 0.f, 0.5f, -0.5f, 1.f }) + { + for (F32 hollow : { 0.f, 0.5f }) + { + Shape s = sphere(); + s.twist_begin = s.twist_end = twist; + s.hollow = hollow; + s.name = fmt::format("sphere twisted {} hollow {}", twist, hollow); + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + LLPointer volume = build(s, lod); + for (S32 f = 0; f < volume->getNumVolumeFaces(); ++f) + { + const LLVolumeFace& face = volume->getVolumeFace(f); + if (!(face.mTypeMask & LLVolumeFace::SIDE_MASK) || face.mNumS * face.mNumT != face.mNumVertices) + { + continue; + } + const F32 inward = (face.mTypeMask & LLVolumeFace::INNER_MASK) ? -1.f : 1.f; + for (S32 column : { 0, face.mNumS - 1 }) + { + bool pole = true; + for (S32 t = 1; t < face.mNumT && pole; ++t) + { + pole = face.mPositions[column + face.mNumS * t].equals3(face.mPositions[column], 1e-6f); + } + if (!pole) + { + continue; + } + ++poles; + for (S32 t = 0; t < face.mNumT; ++t) + { + const S32 v = column + face.mNumS * t; + const F32 out = inward * face.mNormals[v].dot3(face.mPositions[v]).getF32(); + if (!(out > 0.f)) + { + report.fail(fmt::format("{}: {}'s pole at column {} has normal ({}, {}, {}) at ({}, {}, {})", + lodName(s, lod), faceName(face), column, face.mNormals[v][0], + face.mNormals[v][1], face.mNormals[v][2], face.mPositions[v][0], + face.mPositions[v][1], face.mPositions[v][2])); + break; + } + } + } + } + } + } + } + INFO(report.text()); + CHECK(poles > 0); + CHECK(report.failures == 0); + } + // The sphere placeholder gets a sphere sculpt's normal stitching, so it // must run as a sphere sculpt's map does, around along the profile and // pole to pole along the path. The other way round, the poles' average From 7a878721161eade65ed0fc5ea33f5bc5b193a66c Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:36:59 -0400 Subject: [PATCH 04/29] A generated normal is marked as its own whatever w its edges carry A grid normal is the vertex's own where normalize3_or_mark leaves its w at 0, and it kept w scaled with the sum. The sum's w is the cross products' rounding of their edges' w, which is nonzero under fused multiply-add wherever the positions' w differ: the sphere placeholder wrote only x, y and z into mesh rows its resize leaves as it found them, and at LOD 2 and 3 its last rows took a neighbour's normal or the whole face +Z. A normalized vector now has a w of 0, and both placeholders write theirs. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/alsimdkernels.h | 6 +++--- indra/llmath/alsimdkernels.inl | 2 +- indra/llmath/llvolume.cpp | 3 +++ indra/llmath/tests/alsimdkernels_test.cpp | 9 ++++++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/indra/llmath/alsimdkernels.h b/indra/llmath/alsimdkernels.h index fd60b5ddda..2a80e53436 100644 --- a/indra/llmath/alsimdkernels.h +++ b/indra/llmath/alsimdkernels.h @@ -226,9 +226,9 @@ void indexed_tangents(const LLVector4a* positions, const LLVector4a* normals, co bool grid_tangents_central(const LLVector4a* positions, const LLVector4a* normals, size_t columns, size_t rows, bool joins, F32 same, F32 sign, F32 w, LLVector4a* tangents); -// Every vector at unit length in x, y and z, w scaled with them, where its -// squared length in x, y and z is above FLT_MIN; elsewhere (0, 0, 0, 1). -// Returns whether any was set so. +// Every vector at unit length in x, y and z, with a w of 0, where its +// squared length in x, y and z is above FLT_MIN; elsewhere (0, 0, 0, 1), +// whatever w it had. Returns whether any was set so. bool normalize3_or_mark(LLVector4a* v, size_t n); } // namespace alsimd diff --git a/indra/llmath/alsimdkernels.inl b/indra/llmath/alsimdkernels.inl index c483644941..4ced899b51 100644 --- a/indra/llmath/alsimdkernels.inl +++ b/indra/llmath/alsimdkernels.inl @@ -1641,7 +1641,7 @@ bool normalize3_or_mark_impl(LLVector4a* v, size_t n) const reg scale = W::rsqrt(W::max(length2, W::set1(FLT_MIN))); a = { W::select(none, W::zero(), W::mul(a.x, scale)), W::select(none, W::zero(), W::mul(a.y, scale)), W::select(none, W::zero(), W::mul(a.z, scale)) }; - w = W::select(none, W::set1(1.f), W::mul(w, scale)); + w = W::select(none, W::set1(1.f), W::zero()); const mask real = W::cmplt(W::add(W::ramp(), W::set1(F32(first))), W::set1(F32(n))); marked = W::or_(marked, W::and_(real, none)); W::store_columns(a.x, a.y, a.z, w, [&](int i) { return first + i < n ? v[first + i].getF32ptr() : spill; }); diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 41a3f56053..fb3c769556 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -3265,6 +3265,7 @@ void LLVolume::sculptGenerateEmptyPlaceholder() p[0] = 0; p[1] = 0; p[2] = 0; + p[3] = 0; llassert(pt.isFinite3()); } @@ -3300,6 +3301,8 @@ void LLVolume::sculptGenerateSpherePlaceholder() p[0] = (F32)(sin(F_PI * v) * cos(2.0 * F_PI * u) * RADIUS); p[1] = (F32)(-sin(F_PI * v) * sin(2.0 * F_PI * u) * RADIUS); p[2] = (F32)(cos(F_PI * v) * RADIUS); + // mMesh's resize leaves what it adds as it found it. + p[3] = 0; llassert(pt.isFinite3()); } diff --git a/indra/llmath/tests/alsimdkernels_test.cpp b/indra/llmath/tests/alsimdkernels_test.cpp index 9d2ab8b9ba..fea2a0c95e 100644 --- a/indra/llmath/tests/alsimdkernels_test.cpp +++ b/indra/llmath/tests/alsimdkernels_test.cpp @@ -1408,8 +1408,10 @@ namespace // ------------------------------------------------------------------ // Normalizing - // Vectors at unit length, or marked (0, 0, 0, 1) where they have none, - // against normalize3 to within its rounding at every width and count. + // Vectors at unit length with a w of 0, or marked (0, 0, 0, 1) where + // they have none, against normalize3 to within its rounding at every + // width and count, whatever w they came with: sums of cross products + // carry the rounding of their edges' w. TEST_CASE("alsimdkernels: normalize3_or_mark against normalize3 at every width", "[alsimdkernels]") { for (size_t n : COUNTS) @@ -1425,7 +1427,7 @@ namespace } for (size_t i = 0; i < n; ++i) { - src[i].getF32ptr()[3] = 0.f; + src[i].getF32ptr()[3] = i % 3 == 0 ? 0.f : 0.25f * (F32)(i % 5 + 1); } bool any_expected = false; std::vector expected(src.begin(), src.begin() + n); @@ -1434,6 +1436,7 @@ namespace if (e.dot3(e).getF32() > FLT_MIN) { e.normalize3(); + e.getF32ptr()[3] = 0.f; } else { From a81c9d49ccad3b9076ae4db40bcc13aaf927967e Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:37:02 -0400 Subject: [PATCH 05/29] A sculpt's doubled inner side keeps all of its quads A sculpt whose parameters carry hollow with a square or triangle hole has a flat inner side with its corners doubled, which the pinch search indexed as an undoubled grid: the corners' empty quads went in and the last real ones were left out. It keeps every quad, as a doubled side of a prim does. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index fb3c769556..f3b05dca4e 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -8055,13 +8055,15 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) end_index = doubled ? grid_indices(mIndices, limit, mNumS, mNumT, lefts, quads, kept) : grid_indices(mIndices, limit, mNumS, mNumT, lefts, quads, kept); } - else if (sculpt_stitching != LL_SCULPT_TYPE_NONE && !volume->isUnique() && quads > 0 && rows > 0) + else if (sculpt_stitching != LL_SCULPT_TYPE_NONE && !volume->isUnique() && !doubled && quads > 0 && rows > 0) { // A sculpt's triangles two of whose corners are one point have // no area, and are left out: the points' codes find them and the // points confirm them. A flexible sculpt's points move after its // full build, and it keeps them. A face keeps at least one - // triangle. + // triangle. The flat inner side of a sculpt whose parameters + // carry a square or triangle hole has its corners doubled, and + // keeps all of its quads. ALScratchArena::Scope scratch; U32* codes = scratch.allocate(num_vertices + 8); alsimd::unit_cube_codes(pos, num_vertices, codes); From 0572ca5a20ada263085afb7b69b5795ef04bc79b Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:37:04 -0400 Subject: [PATCH 06/29] The grid tangent kernel counts a line's first lane in its own line grid_quads counted whole lines as floor(lane / width), a division by a width the loop never changes, which -freciprocal-math lets Clang make a multiply by a reciprocal taken once before the loop. At a line's first lane that lands an ulp under the line for widths such as 41, 47 and 55, and the line's first quad was taken for the padding lane past the end of the line before it, adding nothing to its corners. The lines are counted from the middle of the lane, which no rounding of a reciprocal moves across a line. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/alsimdkernels.inl | 10 +++-- indra/llmath/tests/llvolume_test.cpp | 63 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/indra/llmath/alsimdkernels.inl b/indra/llmath/alsimdkernels.inl index 4ced899b51..4dd955b3bf 100644 --- a/indra/llmath/alsimdkernels.inl +++ b/indra/llmath/alsimdkernels.inl @@ -1295,8 +1295,11 @@ struct TangentLanes // left, top right, top left, and its corners' shares go to its four // vertices' sums. A lane holds a quad where it is before the last line // and not at the end of its own: where its place, its lane less whole -// lines, is short of the last. winding is -1 where the grid is walked -// down its columns. +// lines, is short of the last. The whole lines are counted from the +// middle of the lane, half a lane clear of a line's ends, so that a +// division the compiler makes a multiply by a rounded reciprocal still +// counts a line's first lane in its own line. winding is -1 where the +// grid is walked down its columns. template AL_SIMD_INLINE void grid_quads(const TangentLanes& lanes, size_t q, size_t across, size_t quads, F32 winding, typename K::reg& total) { @@ -1307,7 +1310,8 @@ AL_SIMD_INLINE void grid_quads(const TangentLanes& lanes, size_t q, size_t acros const size_t tl_at = q + across; const size_t tr_at = q + across + 1; const reg lane = K::add(K::ramp(), K::set1(F32(q))); - const reg lane_place = K::fnmadd(K::floor(K::div(lane, across_f)), across_f, lane); + const reg lines = K::floor(K::div(K::add(lane, K::set1(0.5f)), across_f)); + const reg lane_place = K::fnmadd(lines, across_f, lane); const reg real = K::select(K::and_(K::cmplt(lane_place, K::set1(F32(across - 1))), K::cmplt(lane, K::set1(F32(quads)))), K::set1(1.f), K::zero()); const Lanes3 bl = load_lanes3(lanes.position, bl_at); diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index dabf9a70fc..f1ca6b21cd 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -3997,6 +3997,69 @@ namespace CHECK(report.failures == 0); } + // The grid form takes every quad the index buffer would, at widths + // whose lines a division by a rounded reciprocal of the width would + // count a lane short at their first quad: a rippled grid, its UVs + // rippled too, so that each quad's tangent differs from its + // neighbours' and a quad left out moves its corners' sums. The two + // add the same shares in different orders, and agree to within that. + TEST_CASE("llvolume: a grid's tangents take every quad at widths a reciprocal rounds", "[llvolume]") + { + Report report; + for (size_t columns : { 41, 47, 55, 61, 97, 107 }) + { + const size_t rows = 6; + const size_t count = columns * rows; + std::vector positions(count), normals(count); + std::vector uvs(count); + for (size_t t = 0; t < rows; ++t) + { + for (size_t s = 0; s < columns; ++s) + { + const F32 x = 0.1f * (F32)s; + const F32 y = 0.1f * (F32)t; + const F32 phase = 0.37f * (F32)s + 0.21f * (F32)t; + const size_t v = s + columns * t; + positions[v].set(x, y, 0.05f * sinf(phase), 0.f); + // The surface's normal: (-dz/dx, -dz/dy, 1), normalized. + normals[v].set(-0.05f * 3.7f * cosf(phase), -0.05f * 2.1f * cosf(phase), 1.f, 0.f); + normals[v].normalize3fast(); + uvs[v].set((F32)s / (F32)(columns - 1) + 0.03f * sinf(0.5f * (F32)t + (F32)s), + (F32)t / (F32)(rows - 1) + 0.02f * cosf(0.7f * (F32)s)); + } + } + std::vector indices; + for (size_t t = 0; t + 1 < rows; ++t) + { + for (size_t s = 0; s + 1 < columns; ++s) + { + const U16 bl = (U16)(s + columns * t); + const U16 br = (U16)(bl + 1); + const U16 tl = (U16)(bl + columns); + const U16 tr = (U16)(tl + 1); + indices.insert(indices.end(), { bl, tr, tl, bl, br, tr }); + } + } + + std::vector grid(count), indexed(count); + alsimd::grid_tangents(positions.data(), normals.data(), uvs.data(), columns, rows, grid.data()); + alsimd::indexed_tangents(positions.data(), normals.data(), uvs.data(), count, indices.data(), indices.size(), indexed.data()); + for (size_t v = 0; v < count; ++v) + { + const F64 apart = degreesApart(v3(grid[v]), v3(indexed[v])); + if (!(apart <= 1e-3) || grid[v][3] != indexed[v][3]) + { + report.fail(fmt::format("{} columns: vertex {} (column {}, row {}) is {} degrees from the indexed tangent, " + "w {} against {}", + columns, v, v % columns, v / columns, apart, grid[v][3], indexed[v][3])); + break; + } + } + } + INFO(report.text()); + CHECK(report.failures == 0); + } + // A card drawn both ways, the one triangle twice sharing two vertices, // its third corner mirrored in U a hair off true, and turned off the // axes: at the shared vertices the two triangles' tangents all but From 2a599a405c895318b96459457f9ea171e03e12fe Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:37:07 -0400 Subject: [PATCH 07/29] A mesh face's normals or texture coordinates short of its positions are taken as none A face's vertex count is its positions', and the decode read that many normals and texture coordinates from arrays it checked only for being empty: a shorter one was read past its end. An array short of the count is taken as none, its zeroes in its place. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 5 ++-- indra/llmath/tests/llvolume_test.cpp | 43 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index f3b05dca4e..b4288c7e26 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -2740,7 +2740,8 @@ bool LLVolume::decodeVolumeFaces(const LLSD& mdl) { LL_PROFILE_ZONE_NAMED_CATEGORY_VOLUME("unpackVolumeFaces - normals"); - if (!norm.empty()) + // An array short of the positions' count is taken as none. + if (norm.size() >= (size_t)num_verts * 3 * sizeof(U16)) { // from sixteen bits to -1..1 alsimd::dequantize_u16x3(norm.data(), num_verts, LLVector4a(2.f / 65535.f), LLVector4a(-1.f), face.mNormals); @@ -2787,7 +2788,7 @@ bool LLVolume::decodeVolumeFaces(const LLSD& mdl) { LL_PROFILE_ZONE_NAMED_CATEGORY_VOLUME("unpackVolumeFaces - texcoords"); - if (!tc.empty()) + if (tc.size() >= (size_t)num_verts * 2 * sizeof(U16)) { LLVector4a tc_scale; tc_scale.setMul(tc_range, u16_step); diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index f1ca6b21cd..e328ec47d3 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -4118,6 +4118,49 @@ namespace } } + // A mesh face's vertex count is its positions'. A normal or texture + // coordinate array that falls short of it is taken as none, its zeroes + // in their place, rather than read past its end. + TEST_CASE("llvolume: a mesh face whose normals or texture coordinates fall short of its positions decodes without them", "[llvolume]") + { + auto binary = [](std::initializer_list values) + { + LLSD::Binary bytes; + for (U16 value : values) + { + bytes.push_back((U8)(value & 0xff)); + bytes.push_back((U8)(value >> 8)); + } + return LLSD(bytes); + }; + LLSD face; + face["Position"] = binary({ 0, 0, 0, 65535, 0, 0, 0, 65535, 0 }); + face["Normal"] = binary({ 32768, 32768, 65535 }); + face["TexCoord0"] = binary({ 65535, 65535 }); + face["TriangleList"] = binary({ 0, 1, 2 }); + face["PositionDomain"]["Min"] = LLVector3(-0.5f, -0.5f, -0.5f).getValue(); + face["PositionDomain"]["Max"] = LLVector3(0.5f, 0.5f, 0.5f).getValue(); + face["TexCoord0Domain"]["Min"] = LLVector2(0.f, 0.f).getValue(); + face["TexCoord0Domain"]["Max"] = LLVector2(1.f, 1.f).getValue(); + LLSD mdl = LLSD::emptyArray(); + mdl.append(face); + + LLVolumeParams params; + params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); + params.setSculptID(LLUUID("00000000-0000-0000-0000-00000000000a"), LL_SCULPT_TYPE_MESH); + LLPointer volume = new LLVolume(params, LLVolumeLODGroup::getVolumeScaleFromDetail(3)); + REQUIRE(ll_test::VolumeProbe::decode(*volume, mdl)); + REQUIRE(volume->getNumVolumeFaces() == 1); + const LLVolumeFace& decoded = volume->getVolumeFace(0); + REQUIRE(decoded.mNumVertices == 3); + for (S32 v = 0; v < decoded.mNumVertices; ++v) + { + CAPTURE(v); + CHECK(decoded.mNormals[v].equals3(LLVector4a::getZero())); + CHECK(decoded.mTexCoords[v] == LLVector2(0.f, 0.f)); + } + } + // A circle path's stations step around it and end at its end cut. The // steps add up with rounding, and a station they bring a rounding short // of the end would leave a row with no height before the end's: no two From 6bbbfb73e0bec97e703561f6c402896138347209 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:37:09 -0400 Subject: [PATCH 08/29] Media zoom looks along a face's planar normal, or along the camera getApproximateFaceNormal summed a face's vertex normals. A face that wraps around holds its seam twice, so its normals summed to the seam's, and media focus flew the camera round to the seam, behind a cylinder or sphere from where its user stood. A face that faces one way, as planar texgen takes it, gives its planar normal; any other gives none, and the focus looks along the camera as it always did before 65f595463c. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llvovolume.cpp | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/indra/newview/llvovolume.cpp b/indra/newview/llvovolume.cpp index 3c0232f2bc..28121b4a70 100644 --- a/indra/newview/llvovolume.cpp +++ b/indra/newview/llvovolume.cpp @@ -2643,22 +2643,20 @@ bool LLVOVolume::hasMedia() const LLVector3 LLVOVolume::getApproximateFaceNormal(U8 face_id) { LLVolume* volume = getVolume(); - LLVector4a result; - result.clear(); - LLVector3 ret; + // A face that faces one way, as planar texgen takes it, faces along its + // planar normal. One that wraps around faces no one way, and the zero + // returned leaves the caller to look along the camera. if (volume && face_id < volume->getNumVolumeFaces()) { - const LLVolumeFace& face = volume->getVolumeFace(face_id); - for (S32 i = 0; i < (S32)face.mNumVertices; ++i) + const LLVector4a planar = volume->getVolumeFace(face_id).getPlanarNormal(); + if (planar[3] > 0.f) { - result.add(face.mNormals[i]); + ret.set(planar.getF32ptr()); + ret = volumeDirectionToAgent(ret); + ret.normVec(); } - - ret.set(result.getF32ptr()); - ret = volumeDirectionToAgent(ret); - ret.normVec(); } return ret; From 1928e7a6a75680cea625deec3e42441d4489b19a Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:37:47 -0400 Subject: [PATCH 09/29] The uploader's simplifier prepares a model once for all its simplifications Every simplifyModel and simplifyFace call took the whole model as the simplifier weighs it again: its attributes, texture densities, position remap and extent. Precise and Sloppy modes made one call a face, and Auto up to a dozen a level of detail, sloppy retries included. An ALMeshSimplifier now holds what one model prepares, for every call made of it. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/almeshsimplifier.cpp | 72 +++++++++++--------- indra/llmath/almeshsimplifier.h | 25 +++++-- indra/llmath/tests/almeshsimplifier_test.cpp | 32 ++++----- indra/llprimitive/tests/llmodel_test.cpp | 2 +- indra/newview/llmodelpreview.cpp | 5 +- 5 files changed, 81 insertions(+), 55 deletions(-) diff --git a/indra/llmath/almeshsimplifier.cpp b/indra/llmath/almeshsimplifier.cpp index a540fcd03d..5290043340 100644 --- a/indra/llmath/almeshsimplifier.cpp +++ b/indra/llmath/almeshsimplifier.cpp @@ -37,8 +37,32 @@ #include #include +// base's faces as meshoptimizer takes them, their vertices and indices +// numbered face after face. +struct ALMeshSimplifier::Mesh +{ + std::vector first; // each face's first vertex, then the vertex count + std::vector first_index; // each face's first index, then the index count + std::vector indices; // each face's triangles, those of its indices that name its vertices + std::vector positions; // in the authored shape + std::vector attributes; // ATTRIBUTE_COUNT a vertex + std::vector shared; // at a position another face has a vertex at too + std::vector uv_seam; // at a position its face has a vertex with another texture coordinate at + F32 extent = 0.f; // the largest extent of the positions + + size_t vertexCount() const { return first.back(); } + size_t indexCount() const { return first_index.back(); } + + S32 faceOf(U32 vertex) const + { + return (S32)(std::upper_bound(first.begin(), first.end(), vertex) - first.begin()) - 1; + } +}; + namespace { + using Mesh = ALMeshSimplifier::Mesh; + // What the simplifier weighs at a vertex besides its position: its // normal, as a direction in the authored shape, and its texture // coordinate over its face's density. @@ -58,28 +82,6 @@ namespace constexpr F32 ATTRIBUTE_WEIGHTS[ATTRIBUTE_COUNT] = { NORMAL_WEIGHT, NORMAL_WEIGHT, NORMAL_WEIGHT, UV_WEIGHT, UV_WEIGHT }; - // base's faces as meshoptimizer takes them, their vertices and indices - // numbered face after face. - struct Mesh - { - std::vector first; // each face's first vertex, then the vertex count - std::vector first_index; // each face's first index, then the index count - std::vector indices; // each face's triangles, those of its indices that name its vertices - std::vector positions; // in the authored shape - std::vector attributes; // ATTRIBUTE_COUNT a vertex - std::vector shared; // at a position another face has a vertex at too - std::vector uv_seam; // at a position its face has a vertex with another texture coordinate at - F32 extent = 0.f; // the largest extent of the positions - - size_t vertexCount() const { return first.back(); } - size_t indexCount() const { return first_index.back(); } - - S32 faceOf(U32 vertex) const - { - return (S32)(std::upper_bound(first.begin(), first.end(), vertex) - first.begin()) - 1; - } - }; - LLVector4a unscale(const LLVector3& normalized_scale) { // A loader keeps a size of nothing as 1, and so does this. @@ -521,11 +523,20 @@ bool ALMeshSimplifier::compact(const LLVolume& base, S32 face, const U32* indice return true; } -// static -F32 ALMeshSimplifier::simplifyModel(const LLVolume& base, LLVolume& target, const Target& goal, Seams seams) +ALMeshSimplifier::ALMeshSimplifier(const LLVolume& base) + : mBase(base) { - Mesh mesh; - prepare(base, mesh); + auto mesh = std::make_unique(); + prepare(base, *mesh); + mMesh = std::move(mesh); +} + +ALMeshSimplifier::~ALMeshSimplifier() = default; + +F32 ALMeshSimplifier::simplifyModel(LLVolume& target, const Target& goal, Seams seams) const +{ + const LLVolume& base = mBase; + const Mesh& mesh = *mMesh; const size_t index_count = mesh.indices.size(); if (index_count < 3) { @@ -581,7 +592,7 @@ F32 ALMeshSimplifier::simplifyModel(const LLVolume& base, LLVolume& target, cons S32 kept = 0; for (S32 g = 0; g < faces; ++g) { - if (simplifyFace(base, g, target.getVolumeFace(g), goal, seams) > 0.f) + if (simplifyFace(g, target.getVolumeFace(g), goal, seams) > 0.f) { face_count += target.getVolumeFace(g).mNumIndices; ++kept; @@ -600,11 +611,10 @@ F32 ALMeshSimplifier::simplifyModel(const LLVolume& base, LLVolume& target, cons return (F32)index_count / (F32)count; } -// static -F32 ALMeshSimplifier::simplifyFace(const LLVolume& base, S32 face, LLVolumeFace& target, const Target& goal, Seams seams) +F32 ALMeshSimplifier::simplifyFace(S32 face, LLVolumeFace& target, const Target& goal, Seams seams) const { - Mesh mesh; - prepare(base, mesh); + const LLVolume& base = mBase; + const Mesh& mesh = *mMesh; const U32 first = mesh.first[face]; const U32 vertex_count = mesh.first[face + 1] - first; const size_t index_count = mesh.first_index[face + 1] - mesh.first_index[face]; diff --git a/indra/llmath/almeshsimplifier.h b/indra/llmath/almeshsimplifier.h index 7928473f57..1abd8f1156 100644 --- a/indra/llmath/almeshsimplifier.h +++ b/indra/llmath/almeshsimplifier.h @@ -28,6 +28,8 @@ #include "stdtypes.h" +#include + class LLVolume; class LLVolumeFace; @@ -39,7 +41,8 @@ class LLVolumeFace; // coordinates drift as well as the positions. A simplified face takes its // vertices from the base face unchanged, each attribute as it was: its // position too, so whatever is keyed by position, as a model's skin weights -// are, still finds it. +// are, still finds it. A simplifier takes its model's faces as it weighs +// them once, for every simplification it makes of the model. class ALMeshSimplifier { public: @@ -73,6 +76,13 @@ class ALMeshSimplifier bool distant = false; }; + // base's faces as the simplifier takes them. + struct Mesh; + + // A simplifier of base, which must outlive it unchanged. + explicit ALMeshSimplifier(const LLVolume& base); + ~ALMeshSimplifier(); + // Simplifies base's faces together, as one mesh, into target's, which // has as many faces. A triangle belongs to the face its first vertex // came from. A part detached from the rest is dropped only with all of @@ -81,14 +91,15 @@ class ALMeshSimplifier // they were. A face left with no triangle is one of three indices to a // vertex at the origin. Where a face would take more vertices than 16-bit // indices reach, each face is simplified on its own instead. - static F32 simplifyModel(const LLVolume& base, LLVolume& target, const Target& goal, Seams seams); + F32 simplifyModel(LLVolume& target, const Target& goal, Seams seams) const; // Simplifies base's face on its own into target. A face that shares a // position with any other face of base keeps its border where it is, so // that no gap opens between the two, and keeps all of its parts; only - // one that shares none may lose its small detached parts. Returns as simplifyModel does; where no triangle - // is left, target is the face of three indices to a vertex at the origin. - static F32 simplifyFace(const LLVolume& base, S32 face, LLVolumeFace& target, const Target& goal, Seams seams); + // one that shares none may lose its small detached parts. Returns as + // simplifyModel does; where no triangle is left, target is the face of + // three indices to a vertex at the origin. + F32 simplifyFace(S32 face, LLVolumeFace& target, const Target& goal, Seams seams) const; // The face of base's face's layout that the given triangles make: the // vertices they name and no others, each with every attribute it has in @@ -97,6 +108,10 @@ class ALMeshSimplifier // from any face. Returns false, leaving target as it was, where the // triangles name more vertices than 16-bit indices reach. static bool compact(const LLVolume& base, S32 face, const U32* indices, size_t index_count, LLVolumeFace& target); + +private: + const LLVolume& mBase; + std::unique_ptr mMesh; }; #endif // AL_MESHSIMPLIFIER_H diff --git a/indra/llmath/tests/almeshsimplifier_test.cpp b/indra/llmath/tests/almeshsimplifier_test.cpp index 25185acde7..99ca4bd122 100644 --- a/indra/llmath/tests/almeshsimplifier_test.cpp +++ b/indra/llmath/tests/almeshsimplifier_test.cpp @@ -521,7 +521,7 @@ namespace { INFO(fmt::format("{}, a {}th of {} triangles, {}", name, decimator, base_triangles, nameOf(seams))); LLPointer target = targetFor(*base); - const F32 ratio = ALMeshSimplifier::simplifyModel(*base, *target, toCount(decimator), seams); + const F32 ratio = ALMeshSimplifier(*base).simplifyModel(*target, toCount(decimator), seams); REQUIRE(ratio > 0.f); REQUIRE(wellFormed(target->getVolumeFace(0))); CHECK(fromBase(target->getVolumeFace(0), *base)); @@ -551,14 +551,14 @@ namespace const F32 decimator = 9.f; LLPointer held = targetFor(*base); - const F32 held_ratio = ALMeshSimplifier::simplifyModel(*base, *held, toCount(decimator), Seams::ALL); + const F32 held_ratio = ALMeshSimplifier(*base).simplifyModel(*held, toCount(decimator), Seams::ALL); INFO(fmt::format("every seam held: a ratio of {}", held_ratio)); CHECK(held_ratio * ALLOWED_DRIFT < decimator); for (Seams seams : { Seams::UV, Seams::NONE }) { LLPointer free = targetFor(*base); - const F32 ratio = ALMeshSimplifier::simplifyModel(*base, *free, toCount(decimator), seams); + const F32 ratio = ALMeshSimplifier(*base).simplifyModel(*free, toCount(decimator), seams); INFO(fmt::format("{}: a ratio of {}", nameOf(seams), ratio)); REQUIRE(ratio > 0.f); REQUIRE(wellFormed(free->getVolumeFace(0))); @@ -582,7 +582,7 @@ namespace ALMeshSimplifier::Target goal; goal.error = error; LLPointer target = targetFor(*base); - const F32 ratio = ALMeshSimplifier::simplifyModel(*base, *target, goal, Seams::ALL); + const F32 ratio = ALMeshSimplifier(*base).simplifyModel(*target, goal, Seams::ALL); const F32 measured = deviation(*base, *target); const S32 triangles = triangleCount(*target); INFO(fmt::format("an error of {}: {} of {} triangles, a ratio of {}, {} measured", error, triangles, triangleCount(*base), @@ -686,7 +686,7 @@ namespace LLPointer target = targetFor(*base); for (S32 f = 0; f < 2; ++f) { - CHECK(ALMeshSimplifier::simplifyFace(*base, f, target->getVolumeFace(f), toCount(decimator), seams) > 0.f); + CHECK(ALMeshSimplifier(*base).simplifyFace(f, target->getVolumeFace(f), toCount(decimator), seams) > 0.f); REQUIRE(wellFormed(target->getVolumeFace(f))); CHECK(fromBase(target->getVolumeFace(f), *base)); } @@ -710,7 +710,7 @@ namespace INFO(fmt::format("{} halves, a {}th, {}", flat_unmapped ? "flat shaded, unmapped" : "smooth", decimator, nameOf(seams))); LLPointer target = targetFor(*base); - REQUIRE(ALMeshSimplifier::simplifyModel(*base, *target, toCount(decimator), seams) > 0.f); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*target, toCount(decimator), seams) > 0.f); for (S32 f = 0; f < 2; ++f) { const LLVolumeFace& face = target->getVolumeFace(f); @@ -735,14 +735,14 @@ namespace LLPointer alone = volumeOf({ plane(32) }); LLPointer alone_target = targetFor(*alone); - const F32 alone_ratio = ALMeshSimplifier::simplifyFace(*alone, 0, alone_target->getVolumeFace(0), toCount(decimator), Seams::ALL); + const F32 alone_ratio = ALMeshSimplifier(*alone).simplifyFace(0, alone_target->getVolumeFace(0), toCount(decimator), Seams::ALL); INFO(fmt::format("on its own: a ratio of {}", alone_ratio)); CHECK(alone_ratio * ALLOWED_DRIFT >= decimator); // The same square beside another, which it meets along x = 0.5. LLPointer pair = volumeOf({ plane(32), plane(32, 0.5f) }); LLPointer pair_target = targetFor(*pair); - const F32 pair_ratio = ALMeshSimplifier::simplifyFace(*pair, 0, pair_target->getVolumeFace(0), toCount(decimator), Seams::ALL); + const F32 pair_ratio = ALMeshSimplifier(*pair).simplifyFace(0, pair_target->getVolumeFace(0), toCount(decimator), Seams::ALL); INFO(fmt::format("beside another: a ratio of {}", pair_ratio)); CHECK(pair_ratio * ALLOWED_DRIFT < decimator); CHECK(borderOf(pair_target->getVolumeFace(0)).size() == 4 * 32); @@ -763,7 +763,7 @@ namespace { INFO(fmt::format("a {}th, {}", decimator, nameOf(seams))); LLPointer target = targetFor(*base); - REQUIRE(ALMeshSimplifier::simplifyModel(*base, *target, toCount(decimator), seams) > 0.f); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*target, toCount(decimator), seams) > 0.f); const LLVolumeFace& face = target->getVolumeFace(0); REQUIRE(wellFormed(face)); size_t across = 0; @@ -827,11 +827,11 @@ namespace for (Seams seams : { Seams::ALL, Seams::UV }) { LLPointer held = targetFor(*base); - REQUIRE(ALMeshSimplifier::simplifyModel(*base, *held, toCount(decimator), seams) > 0.f); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*held, toCount(decimator), seams) > 0.f); CHECK(spanning(held->getVolumeFace(0)) == 0); } LLPointer free = targetFor(*base); - REQUIRE(ALMeshSimplifier::simplifyModel(*base, *free, toCount(decimator), Seams::NONE) > 0.f); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*free, toCount(decimator), Seams::NONE) > 0.f); CHECK(spanning(free->getVolumeFace(0)) > 0); } } @@ -857,14 +857,14 @@ namespace { INFO(fmt::format("a {}th", decimator)); LLPointer held = targetFor(*base); - REQUIRE(ALMeshSimplifier::simplifyModel(*base, *held, toCount(decimator), Seams::ALL) > 0.f); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*held, toCount(decimator), Seams::ALL) > 0.f); CHECK(mixed(held->getVolumeFace(0)) == 0); } // Free to cross them, at a level of detail the held one cannot // reach, it does. LLPointer free = targetFor(*base); - REQUIRE(ALMeshSimplifier::simplifyModel(*base, *free, toCount(100.f), Seams::UV) > 0.f); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*free, toCount(100.f), Seams::UV) > 0.f); CHECK(mixed(free->getVolumeFace(0)) > 0); } @@ -900,7 +900,7 @@ namespace LLPointer target = targetFor(*base); ALMeshSimplifier::Target goal = toCount(9.f); goal.distant = true; - REQUIRE(ALMeshSimplifier::simplifyModel(*base, *target, goal, Seams::ALL) > 0.f); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*target, goal, Seams::ALL) > 0.f); const LLVolumeFace& face = target->getVolumeFace(0); REQUIRE(wellFormed(face)); S32 bolt_vertices = 0; @@ -915,7 +915,7 @@ namespace // A level of detail seen from near keeps them, short of its target. LLPointer near_target = targetFor(*base); - REQUIRE(ALMeshSimplifier::simplifyModel(*base, *near_target, toCount(9.f), Seams::ALL) > 0.f); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*near_target, toCount(9.f), Seams::ALL) > 0.f); CHECK(triangleCount(*near_target) > target_triangles); } @@ -1197,7 +1197,7 @@ namespace { Tally& tally = tallies[{ (S32)seams, decimator }]; LLPointer target = targetFor(base); - const F32 ratio = ALMeshSimplifier::simplifyModel(base, *target, toCount(decimator), seams); + const F32 ratio = ALMeshSimplifier(base).simplifyModel(*target, toCount(decimator), seams); if (ratio < 0.f) { ++tally.failed; diff --git a/indra/llprimitive/tests/llmodel_test.cpp b/indra/llprimitive/tests/llmodel_test.cpp index 0a112d9d99..f7522f405d 100644 --- a/indra/llprimitive/tests/llmodel_test.cpp +++ b/indra/llprimitive/tests/llmodel_test.cpp @@ -125,7 +125,7 @@ namespace lod->setNumVolumeFaces(1); ALMeshSimplifier::Target goal; goal.decimator = 9.f; - REQUIRE(ALMeshSimplifier::simplifyModel(*model, *lod, goal, seams) > 0.f); + REQUIRE(ALMeshSimplifier(*model).simplifyModel(*lod, goal, seams) > 0.f); lod->mSkinWeights = model->mSkinWeights; CHECK(weightsFound(*lod)); } diff --git a/indra/newview/llmodelpreview.cpp b/indra/newview/llmodelpreview.cpp index bd67480d1b..3c1faeaf0e 100644 --- a/indra/newview/llmodelpreview.cpp +++ b/indra/newview/llmodelpreview.cpp @@ -1539,15 +1539,16 @@ void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 d goal.decimator = indices_decimator; goal.error = lod_error_threshold; goal.distant = lod <= LLModel::LOD_LOW; + const ALMeshSimplifier simplifier(*base); auto simplify_model = [&](F32 decimator, ALMeshSimplifier::Seams seams) { ALMeshSimplifier::Target at = goal; at.decimator = decimator; - return ALMeshSimplifier::simplifyModel(*base, *target_model, at, seams); + return simplifier.simplifyModel(*target_model, at, seams); }; auto simplify_face = [&](S32 face_idx, ALMeshSimplifier::Seams seams) { - return ALMeshSimplifier::simplifyFace(*base, face_idx, target_model->getVolumeFace(face_idx), goal, seams); + return simplifier.simplifyFace(face_idx, target_model->getVolumeFace(face_idx), goal, seams); }; // Ideally this should run not per model, From 96631b9b5da96a1def3ea68037808bc29c77ebff Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:38:12 -0400 Subject: [PATCH 10/29] A distant level of detail to an error keeps a model of small parts With no triangle target, a distant level of detail made to an error prunes every detached part within it. A model of nothing but such parts came out with no triangle, which the check that no part past PRUNE_LIMIT was dropped passed, and the uploader took the whole model for the level. A pruned simplification left with no triangle now goes the way of one that dropped too large a part: the parts within PRUNE_LIMIT are dropped first, and where that leaves nothing, the model is simplified without dropping any. An error within PRUNE_LIMIT drops no larger part, and skips the search for one. The search's union of a triangle's corners linked the third to a root the second had replaced, splitting the second's part from the rest. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/almeshsimplifier.cpp | 18 ++++++--- indra/llmath/tests/almeshsimplifier_test.cpp | 42 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/indra/llmath/almeshsimplifier.cpp b/indra/llmath/almeshsimplifier.cpp index 5290043340..702759bb2e 100644 --- a/indra/llmath/almeshsimplifier.cpp +++ b/indra/llmath/almeshsimplifier.cpp @@ -281,13 +281,16 @@ namespace }; for (size_t i = 0; i < index_count; i += 3) { - const U32 a = root(remap[indices[i]]); + // a stays the root of the corners joined so far. + U32 a = root(remap[indices[i]]); for (S32 k = 1; k < 3; ++k) { const U32 b = root(remap[indices[i + k]]); if (a != b) { - parent[llmax(a, b)] = llmin(a, b); + const U32 joined = llmin(a, b); + parent[llmax(a, b)] = joined; + a = joined; } } } @@ -339,8 +342,9 @@ namespace // indices numbered from first. Where prune is set, it may drop detached // parts as its error grows past their size, none larger than // PRUNE_LIMIT; where it would have dropped a larger one to reach its - // target, it drops each part within PRUNE_LIMIT first and simplifies - // what is left without dropping any more. + // target, or every part, it drops each part within PRUNE_LIMIT first + // and simplifies what is left without dropping any more. An error + // within PRUNE_LIMIT drops no part larger than it. size_t simplifyMesh(const Mesh& mesh, U32 first, U32 count, const U32* indices, size_t index_count, size_t target_count, F32 error, U32 options, const std::vector& locks, bool prune, U32* destination, F32& result_error) { @@ -361,8 +365,10 @@ namespace } const size_t simplified = simplify(indices, index_count, options | meshopt_SimplifyPrune); - const F32 extent = meshopt_simplifyScale(positions, count, sizeof(LLVector4a)); - if (largestDropped(vertices, count, indices, index_count, destination, simplified, extent) <= PRUNE_LIMIT) + if (simplified >= 3 && + (error <= PRUNE_LIMIT || + largestDropped(vertices, count, indices, index_count, destination, simplified, + meshopt_simplifyScale(positions, count, sizeof(LLVector4a))) <= PRUNE_LIMIT)) { return simplified; } diff --git a/indra/llmath/tests/almeshsimplifier_test.cpp b/indra/llmath/tests/almeshsimplifier_test.cpp index 99ca4bd122..fec3ace57d 100644 --- a/indra/llmath/tests/almeshsimplifier_test.cpp +++ b/indra/llmath/tests/almeshsimplifier_test.cpp @@ -919,6 +919,48 @@ namespace CHECK(triangleCount(*near_target) > target_triangles); } + // A distant level of detail made to an error, with no triangle target, + // of a model that is nothing but detached parts within that error: the + // parts are all it has, and it keeps them rather than drop every one, + // as a simplification to nothing would leave the uploader to take the + // whole model for it. The modes tried hold the cubes' seams, so only + // dropping a part could take one away; a mode free to cross them may + // collapse a cube smaller than the error to nothing. + TEST_CASE("almeshsimplifier: a distant level of detail to an error keeps a model of small parts", "[almeshsimplifier]") + { + ArenaInstalled installed; + Geometry model; + for (S32 bead = 0; bead < 16; ++bead) + { + const Geometry part = cube(1); + const F32 phi = F_TWO_PI * bead / 16; + const LLVector3 at(0.5f * cosf(phi), 0.5f * sinf(phi), 0.f); + const U16 first = (U16)model.positions.size(); + for (size_t v = 0; v < part.positions.size(); ++v) + { + model.add(at + part.positions[v] * 0.02f, part.normals[v], part.uvs[v]); + } + for (U16 index : part.indices) + { + model.indices.push_back((U16)(first + index)); + } + } + LLPointer base = volumeOf({ model }); + + ALMeshSimplifier::Target goal; + goal.decimator = 0.f; + goal.error = 0.03f; + goal.distant = true; + for (Seams seams : { Seams::ALL, Seams::UV }) + { + INFO(nameOf(seams)); + LLPointer target = targetFor(*base); + CHECK(ALMeshSimplifier(*base).simplifyModel(*target, goal, seams) > 0.f); + REQUIRE(wellFormed(target->getVolumeFace(0))); + CHECK(triangleCount(*target) >= 16); + } + } + // ------------------------------------------------------------------ // Compaction From 0d6a6b94926b9872cfdb6bda5eaa9c2f4e601143 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:38:15 -0400 Subject: [PATCH 11/29] A face simplified on its own is held only where it meets another A face that met another took meshoptimizer's LockBorder, which holds every open border, not only those shared with another face: a skirt meeting a bodice kept every vertex of its hem. The vertices at a position another face shares are locked instead, which keeps the seam between the two closed, and the rest of the border is free. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/almeshsimplifier.cpp | 25 +++++++++++++------- indra/llmath/almeshsimplifier.h | 10 ++++---- indra/llmath/tests/almeshsimplifier_test.cpp | 19 +++++++++++---- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/indra/llmath/almeshsimplifier.cpp b/indra/llmath/almeshsimplifier.cpp index 702759bb2e..8ef4a302cb 100644 --- a/indra/llmath/almeshsimplifier.cpp +++ b/indra/llmath/almeshsimplifier.cpp @@ -654,22 +654,29 @@ F32 ALMeshSimplifier::simplifyFace(S32 face, LLVolumeFace& target, const Target& } else { - // A face that meets another keeps its border, which the other's - // simplification keeps as well, and all of its parts, as a part of - // one face may be joined to another face's. + // A face that meets another keeps its vertices where it meets it, + // which the other's simplification keeps as well, and all of its + // parts, as a part of one face may be joined to another face's. Its + // borders that meet no face are free. const bool meets = std::any_of(mesh.shared.begin() + first, mesh.shared.begin() + first + vertex_count, [](U8 shared) { return shared != 0; }); - U32 options = seamOptions(seams, goal); - if (meets) - { - options |= meshopt_SimplifyLockBorder; - } std::vector locks; if (seams != Seams::ALL) { protect(mesh, seams, first, vertex_count, locks); } - count = simplifyMesh(mesh, first, vertex_count, indices.data(), index_count, target_count, error, options, locks, + if (meets) + { + locks.resize(vertex_count, 0); + for (U32 v = 0; v < vertex_count; ++v) + { + if (mesh.shared[first + v]) + { + locks[v] |= (U8)meshopt_SimplifyVertex_Lock; + } + } + } + count = simplifyMesh(mesh, first, vertex_count, indices.data(), index_count, target_count, error, seamOptions(seams, goal), locks, goal.distant && !meets, simplified.data(), result_error); } if (count < 3) diff --git a/indra/llmath/almeshsimplifier.h b/indra/llmath/almeshsimplifier.h index 1abd8f1156..01bb56f4d5 100644 --- a/indra/llmath/almeshsimplifier.h +++ b/indra/llmath/almeshsimplifier.h @@ -94,11 +94,11 @@ class ALMeshSimplifier F32 simplifyModel(LLVolume& target, const Target& goal, Seams seams) const; // Simplifies base's face on its own into target. A face that shares a - // position with any other face of base keeps its border where it is, so - // that no gap opens between the two, and keeps all of its parts; only - // one that shares none may lose its small detached parts. Returns as - // simplifyModel does; where no triangle is left, target is the face of - // three indices to a vertex at the origin. + // position with any other face of base keeps its vertices at those + // positions where they are, so that no gap opens between the two, and + // keeps all of its parts; only one that shares none may lose its small + // detached parts. Returns as simplifyModel does; where no triangle is + // left, target is the face of three indices to a vertex at the origin. F32 simplifyFace(S32 face, LLVolumeFace& target, const Target& goal, Seams seams) const; // The face of base's face's layout that the given triangles make: the diff --git a/indra/llmath/tests/almeshsimplifier_test.cpp b/indra/llmath/tests/almeshsimplifier_test.cpp index fec3ace57d..263699d177 100644 --- a/indra/llmath/tests/almeshsimplifier_test.cpp +++ b/indra/llmath/tests/almeshsimplifier_test.cpp @@ -726,8 +726,9 @@ namespace } // A face that meets no other is free at its border, where one that - // meets another is held: the held square keeps the 128 edges of its - // border, more than a hundredth of its triangles can make. + // meets another is held where it meets it: the held square keeps the + // 32 edges along the side it shares, and is free along its other + // three, which it simplifies as the square on its own does. TEST_CASE("almeshsimplifier: a face is held at its border only where it meets another", "[almeshsimplifier]") { ArenaInstalled installed; @@ -744,8 +745,18 @@ namespace LLPointer pair_target = targetFor(*pair); const F32 pair_ratio = ALMeshSimplifier(*pair).simplifyFace(0, pair_target->getVolumeFace(0), toCount(decimator), Seams::ALL); INFO(fmt::format("beside another: a ratio of {}", pair_ratio)); - CHECK(pair_ratio * ALLOWED_DRIFT < decimator); - CHECK(borderOf(pair_target->getVolumeFace(0)).size() == 4 * 32); + CHECK(pair_ratio > 0.f); + const std::set shared = sharedPositions(*pair); + REQUIRE(shared.size() == 33); + const std::set border = borderOf(pair_target->getVolumeFace(0)); + S32 held = 0; + for (const EdgeKey& edge : border) + { + held += shared.count(edge.first) && shared.count(edge.second); + } + CHECK(held == 32); + // Fewer triangles than a border of 128 edges, all held, would need. + CHECK(pair_target->getVolumeFace(0).mNumIndices / 3 < 4 * 32); } // ------------------------------------------------------------------ From 3ab0f9fde0210813d695d158e7d6a328a1852cd1 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:38:17 -0400 Subject: [PATCH 12/29] Auto mode falls back to the precise mode that came closest Auto tried ALL, UV and NONE in turn, and each overwrote the ratio of the last. Where none reached the target and sloppy did worse, it fell back to ALL, the mode furthest from the target, and where NONE failed after ALL had worked, it took the whole model. It keeps the closest mode and its ratio. A first sloppy pass that failed also asked a correction to a negative decimator, which asks for a single triangle; the smaller decimators tried after it are the correction. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llmodelpreview.cpp | 37 +++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/indra/newview/llmodelpreview.cpp b/indra/newview/llmodelpreview.cpp index 3c1faeaf0e..031e7c23f7 100644 --- a/indra/newview/llmodelpreview.cpp +++ b/indra/newview/llmodelpreview.cpp @@ -1600,18 +1600,25 @@ void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 d if (model_meshopt_mode == MESH_OPTIMIZER_AUTO) { - // Remove progressively more data if we can't reach the target. + // Remove progressively more data if we can't reach the target, + // keeping the closest a mode came: target_model holds the + // last mode's. F32 allowed_ratio_drift = 1.8f; - F32 precise_ratio = simplify_model(indices_decimator, ALMeshSimplifier::Seams::ALL); - - if (precise_ratio < 0 || (precise_ratio * allowed_ratio_drift < indices_decimator)) - { - precise_ratio = simplify_model(indices_decimator, ALMeshSimplifier::Seams::UV); - } - - if (precise_ratio < 0 || (precise_ratio * allowed_ratio_drift < indices_decimator)) + using Seams = ALMeshSimplifier::Seams; + F32 precise_ratio = -1.f; + Seams precise_seams = Seams::ALL; + for (Seams seams : { Seams::ALL, Seams::UV, Seams::NONE }) { - precise_ratio = simplify_model(indices_decimator, ALMeshSimplifier::Seams::NONE); + const F32 ratio = simplify_model(indices_decimator, seams); + if (ratio > precise_ratio) + { + precise_ratio = ratio; + precise_seams = seams; + } + if (ratio >= 0 && ratio * allowed_ratio_drift >= indices_decimator) + { + break; + } } if (precise_ratio < 0 || (precise_ratio * allowed_ratio_drift < indices_decimator)) @@ -1636,10 +1643,10 @@ void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 d } // Sloppy has a tendecy to error into lower side, so a request for 100 - // triangles turns into ~70, so check for significant difference from target decimation + // triangles turns into ~70, so check for significant difference from target decimation. + // A sloppy pass that failed has no ratio to correct by; smaller decimators are tried below. F32 sloppy_ratio_drift = 1.4f; - if (lod_mode == LIMIT_TRIANGLES - && (sloppy_ratio > indices_decimator * sloppy_ratio_drift || sloppy_ratio < 0)) + if (lod_mode == LIMIT_TRIANGLES && sloppy_ratio > indices_decimator * sloppy_ratio_drift) { // Apply a correction to compensate. @@ -1689,8 +1696,8 @@ void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 d } else { - // Fallback to normal method - precise_ratio = simplify_model(indices_decimator, ALMeshSimplifier::Seams::ALL); + // Fallback to normal method, in the mode that came closest + precise_ratio = simplify_model(indices_decimator, precise_seams); } LL_DEBUGS("Upload") << "Model " << target_model->getName() From 649c86a3d41242e44762d7ad38074c9d9de8ce39 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:57:56 -0400 Subject: [PATCH 13/29] A circle path leaves out a step within a hundredth of a step of its begin cut A begin cut just short of a step snaps the path's first step to within a hundredth of a step of the cut, as about one cut the wire carries in a hundred does, and so does the rounding of the step's size where the cut is on a step: a torus twisted 1.4 turns has 41 sides at LOD 3, and its first step rounds back to a cut at 0, a second station at the first's place. Either leaves the first row no height, whose degenerate triangles a shared volume drew. The step is the cut's own, as the end's last step is, and the stations after it keep their places. A sculpt keeps it, as it keeps the end's: its rows are the rows of its map. So does a circle2 path, a spiral whose stations alternate sides, where leaving one out would turn every station after it the other way; getNumNGonPoints, which counts only circle2 paths, is unchanged, and so are the circle paths' counts, which a formula of their sides makes. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 12 +++++++ indra/llmath/tests/llvolume_test.cpp | 52 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index b4288c7e26..c4b55a3860 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -1398,6 +1398,18 @@ void LLPath::genNGon(const LLPathParams& params, S32 sides, F32 startOff, F32 en // affect most sample points. t = ((S32)(t * sides)) / (F32)sides; + // A step the snap brings within a hundredth of a step of the begin cut, + // as it does a cut just short of a step, or the first step where the + // rounding of the step's size takes it back to the cut, is the begin's + // own: it would leave a row with no height after the begin's. A sculpt + // keeps it, as it keeps the end's, and so does a circle2 path, a spiral + // whose stations alternate sides: leaving one out would turn every + // station after it the other way. + if (!is_sculpted && (params.getCurveType() & 0xf0) == LL_PCODE_PATH_CIRCLE && t - params.getBegin() < step * 0.01f) + { + t += step; + } + // Run through the non-cut dependent points. A point within a hundredth // of a step of the end cut is the end's own, short of it only by the // rounding the steps add up to: it would leave a row with no height diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index e328ec47d3..82d8fef568 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -4193,6 +4193,58 @@ namespace CHECK(report.failures == 0); } + // A begin cut just short of a step, as the cuts the wire carries can + // be, snaps the path's first step to within a hundredth of a step of + // the cut, and so can the rounding of the step's size where the cut is + // on a step: a twist of 1.4 turns at LOD 3 gives a torus 41 sides, + // whose first step rounds back to a cut at 0. That step is the cut's + // own, as the end's last step is, and the first row has height. + TEST_CASE("llvolume: a circle path's first station after its begin cut is apart from it", "[llvolume]") + { + Report report; + S32 cuts = 0; + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + const F32 detail = LOD_DETAIL[lod]; + for (F32 twist : { 0.f, 0.7f }) + { + const S32 sides = llfloor(MIN_DETAIL_FACES * detail + 2.f * twist * 3.5f * (detail - 0.5f)); + const F32 step = 1.f / sides; + std::vector begins = { 0.f }; + for (S32 k = 1; k < sides; ++k) + { + const S32 quanta = (S32)((F32)k / (F32)sides / CUT_QUANTA); + for (S32 j = 0; j < 3; ++j) + { + begins.push_back((F32)(quanta - j) * CUT_QUANTA); + } + } + for (F32 begin : begins) + { + Shape s = torus(); + s.twist_begin = -twist; + s.twist_end = twist; + s.path_begin = begin; + LLPath path; + path.generate(s.params().getPathParams(), detail); + ++cuts; + for (S32 i = 0; i + 1 < (S32)path.mPath.size(); ++i) + { + if (!(path.mPath[i + 1].mTexT - path.mPath[i].mTexT >= step * 0.01f)) + { + report.fail(fmt::format("{} sides at LOD {}, cut at {}: stations {} and {} are at t {} and {}", sides, lod, + begin, i, i + 1, path.mPath[i].mTexT, path.mPath[i + 1].mTexT)); + break; + } + } + } + } + } + INFO(fmt::format("{} cuts", cuts)); + INFO(report.text()); + CHECK(report.failures == 0); + } + // The streaming cost counts a circle2 path's points with // getNumNGonPoints, which walks the stations as genNGon places them. An // end cut on a step, which the summed steps reach a rounding short of, From 7ab82e2b4ca3714e03f3853cae0e2abc45fa989d Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:57:59 -0400 Subject: [PATCH 14/29] A quad whose diagonal has no length adds nothing to its corners' normals A quad adds each triangle's cross product at its corner off the shared diagonal, so that a row or column two of whose corners are one place gives an edge of no length and a product of exactly zero. A quad whose diagonal's ends are one place, as a sculpt can fold one, has no area on either side, but the edges at its corners off the diagonal are each other's reverse, and under fused multiply-add their product is the rounding of one of its terms. A vertex with only such quads around it took that rounding, normalized, as a normal of its own, in an arbitrary direction, rather than a neighbour's. Such a quad now adds nothing. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 18 +++++++-- indra/llmath/tests/llvolume_test.cpp | 59 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index c4b55a3860..84fdc3293d 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -6231,10 +6231,19 @@ namespace // two of a triangle's corners in one place, an edge there has no length // and the product is exactly zero, fused multiply-add or not. A quad // between a copied row or column adds nothing, so the edge there stays - // hard. + // hard. A quad whose diagonal has no length, as a sculpt can fold one, + // has no area on either side of it and adds nothing either: the edges + // at its corners off the diagonal are each other's reverse, whose + // product a fused multiply-add leaves as the rounding of one of its + // terms. inline void add_quad_to_normals(LLVector4a* norm, S32 bl, S32 br, S32 tl, S32 tr, const LLVector4a& bottom, const LLVector4a& right, - const LLVector4a& top, const LLVector4a& left) + const LLVector4a& top, const LLVector4a& left, const LLVector4a& diagonal) { + if (diagonal.equals3(LLVector4a::getZero(), SAME_PLACE)) + { + return; + } + LLVector4a lower, upper; lower.setCross3(bottom, right); upper.setCross3(top, left); @@ -8128,14 +8137,15 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) { const S32 bl = row + s; const S32 tl = bl + mNumS; - LLVector4a bottom, right, top; + LLVector4a bottom, right, top, diagonal; bottom.setSub(pos[bl + 1], pos[bl]); right.setSub(pos[tl + 1], pos[bl + 1]); top.setSub(pos[tl + 1], pos[tl]); + diagonal.setSub(pos[tl + 1], pos[bl]); drop_rounding(bottom); drop_rounding(right); drop_rounding(top); - add_quad_to_normals(norm, bl, bl + 1, tl, tl + 1, bottom, right, top, left); + add_quad_to_normals(norm, bl, bl + 1, tl, tl + 1, bottom, right, top, left, diagonal); left = right; } } diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 82d8fef568..e6338977be 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -2977,6 +2977,65 @@ namespace } } + // A plane whose mesh vertex (10, 10) has a quad of no area at each of + // its corners, each folded so that its diagonal's ends are one place: + // vertices (9, 9), (10, 10) and (11, 11) are one point, and so are + // (9, 10) and (10, 11), and (10, 9) and (11, 10). Vertex (c, r) reads + // pixel (2c, 2r). + SculptMap pinwheelMap() + { + auto surface = [](S32 x, S32 y) + { + const F32 u = (F32)x / (PINCH_MAP - 1); + const F32 v = (F32)y / (PINCH_MAP - 1); + return LLVector3(u - 0.5f, v - 0.5f, 0.2f * sinf(F_TWO_PI * u) * cosf(F_PI * v)); + }; + return sculptMap(PINCH_MAP, PINCH_MAP, [&](U16 x, U16 y) + { + if (x % 2 == 0 && y % 2 == 0) + { + const S32 dc = x / 2 - 10; + const S32 dr = y / 2 - 10; + if (dc == dr && dc >= -1 && dc <= 1) + { + return surface(20, 20); + } + if ((dc == -1 && dr == 0) || (dc == 0 && dr == 1)) + { + return surface(18, 20); + } + if ((dc == 0 && dr == -1) || (dc == 1 && dr == 0)) + { + return surface(20, 18); + } + } + return surface(x, y); + }); + } + + // A vertex whose quads all have no area has no normal of its own, and + // takes the nearest one down or up its column. Under fused multiply-add + // the corners off a folded quad's diagonal would take the rounding of + // an edge crossed with its own reverse for one. + TEST_CASE("llvolume: a vertex among quads folded along their diagonals takes a neighbour's normal", "[llvolume]") + { + LLPointer volume = buildSculpt(LL_SCULPT_TYPE_PLANE, pinwheelMap(), 3); + REQUIRE(volume->getNumVolumeFaces() == 1); + const LLVolumeFace& face = volume->getVolumeFace(0); + REQUIRE(isGrid(face)); + auto at = [&](S32 c, S32 r) { return gridIndex(face, c, r); }; + REQUIRE(coincide(face.mPositions[at(9, 9)], face.mPositions[at(10, 10)])); + REQUIRE(coincide(face.mPositions[at(11, 11)], face.mPositions[at(10, 10)])); + REQUIRE(coincide(face.mPositions[at(9, 10)], face.mPositions[at(10, 11)])); + REQUIRE(coincide(face.mPositions[at(10, 9)], face.mPositions[at(11, 10)])); + REQUIRE(!coincide(face.mPositions[at(10, 10)], face.mPositions[at(10, 9)])); + + const LLVector4a& normal = face.mNormals[at(10, 10)]; + const LLVector4a& below = face.mNormals[at(10, 9)]; + INFO(fmt::format("({}, {}, {}) against ({}, {}, {}) below it", normal[0], normal[1], normal[2], below[0], below[1], below[2])); + CHECK(std::memcmp(normal.getF32ptr(), below.getF32ptr(), 3 * sizeof(F32)) == 0); + } + // A map whose every pixel is one colour has no area, and makes the // sphere above LOD 0, where the area is tested. SculptMap oneColourMap() From f7df4659669e00c9278f4ff23064b23cfa307fae Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 19:58:01 -0400 Subject: [PATCH 15/29] A GLTF primitive is no longer ordered for the vertex cache as it loads Only the uploader loads GLTF assets, and the order a primitive takes there reaches no one: the uploader orders a face too large for 16-bit indices again as it splits it, and every viewer orders a mesh face for the vertex cache as it decodes it. The ordering cost a remap of eight attributes for every primitive of every upload. Co-Authored-By: Claude Opus 5.5 --- indra/newview/gltf/primitive.cpp | 56 -------------------------------- 1 file changed, 56 deletions(-) diff --git a/indra/newview/gltf/primitive.cpp b/indra/newview/gltf/primitive.cpp index a30942efed..d0e8c8810f 100644 --- a/indra/newview/gltf/primitive.cpp +++ b/indra/newview/gltf/primitive.cpp @@ -29,7 +29,6 @@ #include "asset.h" #include "buffer_util.h" #include "../llviewershadermgr.h" -#include "alscratcharena.h" #include @@ -284,59 +283,6 @@ struct MikktMesh }; -// An attribute's values in the order remap gives them, and only those it -// keeps. -template -static void remap_attribute(std::vector& attribute, const U32* remap, size_t kept) -{ - if (!attribute.empty()) - { - meshopt_remapVertexBuffer(attribute.data(), attribute.data(), attribute.size(), sizeof(T), remap); - attribute.resize(kept); - } -} - -// A triangle list's triangles in an order a vertex cache reuses, then its -// vertices in the order those triangles first take them, every attribute -// moved alike; a vertex no triangle takes is dropped. The remap and -// meshoptimizer's scratch come from the thread's arena. A primitive whose -// attributes are not one value a vertex, or whose indices are not whole -// triangles, is left as it is. -static void order_for_vertex_cache(Primitive& prim) -{ - const size_t index_count = prim.mIndexArray.size(); - const size_t vertex_count = prim.mPositions.size(); - if (prim.mGLMode != LLRender::TRIANGLES || index_count < 3 || index_count % 3 != 0 || vertex_count == 0) - { - return; - } - for (size_t size : { prim.mNormals.size(), prim.mTangents.size(), prim.mTexCoords0.size(), prim.mTexCoords1.size(), prim.mColors.size(), - prim.mWeights.size(), prim.mJoints.size() }) - { - if (size != 0 && size != vertex_count) - { - return; - } - } - - U32* indices = prim.mIndexArray.data(); - meshopt_optimizeVertexCache(indices, indices, index_count, vertex_count); - - ALScratchArena::Scope scratch; - U32* remap = scratch.allocate(vertex_count); - const size_t kept = meshopt_optimizeVertexFetchRemap(remap, indices, index_count, vertex_count); - meshopt_remapIndexBuffer(indices, indices, index_count, remap); - - remap_attribute(prim.mPositions, remap, kept); - remap_attribute(prim.mNormals, remap, kept); - remap_attribute(prim.mTangents, remap, kept); - remap_attribute(prim.mTexCoords0, remap, kept); - remap_attribute(prim.mTexCoords1, remap, kept); - remap_attribute(prim.mColors, remap, kept); - remap_attribute(prim.mWeights, remap, kept); - remap_attribute(prim.mJoints, remap, kept); -} - static void vertical_flip(std::vector& texcoords) { for (auto& tc : texcoords) @@ -522,8 +468,6 @@ bool Primitive::prep(Asset& asset) } } - order_for_vertex_cache(*this); - if (!mNormals.empty()) { mask |= LLVertexBuffer::MAP_NORMAL; From 0dd7c2646c98d4a68ff0e707c93999148ef7dbc6 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 20:30:19 -0400 Subject: [PATCH 16/29] The registry's baseline moves to meshoptimizer 1.3#2 meshoptimizer 1.3#2 makes its compatible tangents linear around a vertex. 1.3#1 compared every pair of corners at a vertex twice, to pair edges and to split exactly opposite tangents, so a hub of 20,000 triangles, which the 65,535-vertex face cap allows, took 145 to 350 ms a LOD on a mesh thread; the same hub now takes 2 to 4 ms. Tangents are bit for bit 1.3#1's: over the mesh cache, 40,455 faces hash the same out of meshoptimizer and out of cacheOptimize, and llmath_tests gives the same results on either. The baseline moves to 9350d75, which also takes openjpeg 2.5.4#31; that drops only the tile-part walker, which nothing on this branch calls. Co-Authored-By: Claude Opus 5.5 --- indra/vcpkg-configuration.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/indra/vcpkg-configuration.json b/indra/vcpkg-configuration.json index eff7739f30..f61335b6e7 100644 --- a/indra/vcpkg-configuration.json +++ b/indra/vcpkg-configuration.json @@ -2,7 +2,7 @@ "registries": [ { "kind": "git", - "baseline": "a6132ca341959dc989c88cd2aac992399a6d12bb", + "baseline": "9350d755f4c03f83f211ce99f94c5072e611ada0", "reference": "main", "repository": "https://github.com/AlchemyViewer/alchemy-registry", "packages": [ From c25037ab1d678d9443ca4920a5c67a9009eababa Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 21:04:17 -0400 Subject: [PATCH 17/29] The registry's baseline moves to meshoptimizer 1.3#3 meshoptimizer 1.3#3 bounds a mesh's compatible tangents to linear time. 1.3#2 still spent the square of a group's size where its tangents turn all the way round a vertex, as at a pole whose UVs pinch to a point: a fan of 20,000 triangles pinched so took 1.2 s a LOD on a mesh thread, and a mesh can be built to make every hub pay it. A group whose split would cost more than 64 tests a corner plus 1,024 is now left whole, every corner taking the group's tangent; that fan takes 3.4 ms. The mesh cache comes nowhere near the budget: its 40,455 faces hash the same out of meshoptimizer and out of cacheOptimize as on 1.3#1, and llmath_tests gives the same results. The baseline moves to f007fb7, which changes no other port. Co-Authored-By: Claude Opus 5.5 --- indra/vcpkg-configuration.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/indra/vcpkg-configuration.json b/indra/vcpkg-configuration.json index f61335b6e7..1ec921be9a 100644 --- a/indra/vcpkg-configuration.json +++ b/indra/vcpkg-configuration.json @@ -2,7 +2,7 @@ "registries": [ { "kind": "git", - "baseline": "9350d755f4c03f83f211ce99f94c5072e611ada0", + "baseline": "f007fb751094d5689410eb97adeb3aae5e150642", "reference": "main", "repository": "https://github.com/AlchemyViewer/alchemy-registry", "packages": [ From ba180c9d02658b257b6f8edd3f63fe9cd9cbd22b Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:22 -0400 Subject: [PATCH 18/29] The SIMD kernels are made in each file that uses them The kernels in alsimdkernels.inl had external linkage, so every file that included it and used a kernel made the same symbol, and the linker kept one. The kernel test is built strict on GCC and Clang, and its copies came first in llmath_tests' link: the library's entry points, and every prim and sculpt case in llvolume_test, ran the strict build in place of the one the viewer ships, which no fast-math defect could fail. Each tier's simd_tests binary folded its tier's copies with the tree tier's the same way. The kernels now sit in an unnamed namespace, each file's its own. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/alsimdkernels.inl | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/indra/llmath/alsimdkernels.inl b/indra/llmath/alsimdkernels.inl index 4dd955b3bf..dd54d2d380 100644 --- a/indra/llmath/alsimdkernels.inl +++ b/indra/llmath/alsimdkernels.inl @@ -392,6 +392,13 @@ using ALSimdLanes = ALSimd8; using ALSimdLanes = ALSimd4; #endif +// Each file that includes this one makes the kernels it uses for itself, +// under its own floating-point options and instruction set: with external +// linkage the linker would keep one copy of each, and the strict kernel +// test's copies would run in place of the library's, or one tier's in a +// binary built for another. +namespace +{ namespace kernels { @@ -1947,6 +1954,7 @@ void planar_tangents_impl(const LLVector4a* normals, size_t n, const PlanarArgs& #endif } // namespace kernels +} // namespace } // namespace alsimd #endif // AL_SIMDKERNELS_INL From 4257783bc127ffdbbb83f6b15133c5c56bde89fa Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:24 -0400 Subject: [PATCH 19/29] clang-cl builds the planar kernels and their test strict alsimdplanar.cpp and the kernel test are built without contraction, reassociation or reciprocals on GCC and Clang, and MSVC holds the planar kernels to the same with a float_control pragma. clang-cl took neither: the pragma is guarded off under Clang, and the options were given to GCC and Clang alone, so a touch could land a unit in the last place from its vertex's coordinate. clang-cl now takes the options too, through /clang:. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/CMakeLists.txt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/indra/llmath/CMakeLists.txt b/indra/llmath/CMakeLists.txt index e0787a35ed..7c1a792c57 100644 --- a/indra/llmath/CMakeLists.txt +++ b/indra/llmath/CMakeLists.txt @@ -109,13 +109,16 @@ target_precompile_headers(llmath REUSE_FROM llprecompiled) # compiler may round one sum differently at each place it inlines it, and a # touch would then land a unit in the last place from the coordinate its # vertex is drawn with. MSVC is held to the same by the float_control pragma -# around the kernels in alsimdkernels.inl. -if(NOT COMPILER_IS_MSVC AND NOT COMPILER_IS_CLANG_CL) +# around the kernels in alsimdkernels.inl, which clang-cl does not take, and +# clang-cl by the same options as Clang. +set(AL_STRICT_FP_OPTIONS "-ffp-contract=off;-fno-associative-math;-fno-reciprocal-math") +if(COMPILER_IS_CLANG_CL) + list(TRANSFORM AL_STRICT_FP_OPTIONS PREPEND "/clang:") +endif() +if(NOT COMPILER_IS_MSVC) set_source_files_properties( alsimdplanar.cpp - PROPERTIES - COMPILE_OPTIONS "-ffp-contract=off;-fno-associative-math;-fno-reciprocal-math" - SKIP_PRECOMPILE_HEADERS TRUE + PROPERTIES COMPILE_OPTIONS "${AL_STRICT_FP_OPTIONS}" SKIP_PRECOMPILE_HEADERS TRUE ) endif() @@ -172,10 +175,10 @@ if(AL_BUILD_TESTS) # intrinsics, and the file passes there as it is. Accuracy against a double # reference, and the library's own entry built as the viewer has it, are # checked to a tolerance. - if(NOT COMPILER_IS_MSVC AND NOT COMPILER_IS_CLANG_CL) + if(NOT COMPILER_IS_MSVC) set_source_files_properties( tests/alsimdkernels_test.cpp - PROPERTIES COMPILE_OPTIONS "-ffp-contract=off;-fno-associative-math;-fno-reciprocal-math" + PROPERTIES COMPILE_OPTIONS "${AL_STRICT_FP_OPTIONS}" ) endif() From 4a118a46eb13e4bf6ed71f33d1e3a2a5b24908d2 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:27 -0400 Subject: [PATCH 20/29] Hair, skirt and eyelashes keep the order their triangles were made in The avatar meshes' triangles are ordered for the vertex cache as they load. Hair, skirt and eyelashes are drawn blended, the hair and skirt in a first pass that writes no depth with culling off, so their triangles blend over one another in the order they come: ordering them for the cache changed how their layers composite. They keep the order of their files. Co-Authored-By: Claude Opus 5.5 --- indra/llappearance/llavatarappearance.cpp | 8 ++++++-- indra/llappearance/llpolymesh.cpp | 8 ++++---- indra/llappearance/llpolymesh.h | 9 ++++++--- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/indra/llappearance/llavatarappearance.cpp b/indra/llappearance/llavatarappearance.cpp index 6e8c5685d0..5f8b6d258f 100644 --- a/indra/llappearance/llavatarappearance.cpp +++ b/indra/llappearance/llavatarappearance.cpp @@ -1128,12 +1128,16 @@ bool LLAvatarAppearance::loadMeshNodes() LLPolyMesh *poly_mesh = NULL; + // Hair, skirt and eyelashes are drawn blended, and keep the order + // their triangles were made in. + const bool blended = mesh_id == MESH_ID_HAIR || mesh_id == MESH_ID_SKIRT || mesh_id == MESH_ID_EYELASH; + if (!info->mReferenceMeshName.empty()) { polymesh_map_t::const_iterator polymesh_iter = mPolyMeshes.find(info->mReferenceMeshName); if (polymesh_iter != mPolyMeshes.end()) { - poly_mesh = LLPolyMesh::getMesh(info->mMeshFileName, polymesh_iter->second); + poly_mesh = LLPolyMesh::getMesh(info->mMeshFileName, polymesh_iter->second, blended); poly_mesh->setAvatar(this); } else @@ -1145,7 +1149,7 @@ bool LLAvatarAppearance::loadMeshNodes() } else { - poly_mesh = LLPolyMesh::getMesh(info->mMeshFileName); + poly_mesh = LLPolyMesh::getMesh(info->mMeshFileName, NULL, blended); poly_mesh->setAvatar(this); } diff --git a/indra/llappearance/llpolymesh.cpp b/indra/llappearance/llpolymesh.cpp index 0fe7648e7b..479e5a82d6 100644 --- a/indra/llappearance/llpolymesh.cpp +++ b/indra/llappearance/llpolymesh.cpp @@ -270,7 +270,7 @@ bool LLPolyMeshSharedData::allocateJointNames( U32 numJointNames ) //-------------------------------------------------------------------- // LLPolyMeshSharedData::loadMesh() //-------------------------------------------------------------------- -bool LLPolyMeshSharedData::loadMesh( const std::string& fileName ) +bool LLPolyMeshSharedData::loadMesh( const std::string& fileName, bool keep_order ) { //------------------------------------------------------------------------- // Open the file @@ -565,7 +565,7 @@ bool LLPolyMeshSharedData::loadMesh( const std::string& fileName ) // weights address vertices, which keep their order, and an // LOD's faces are its own. const U32 vertex_count = (U32)mNumVertices; - bool in_range = numFaces > 0; + bool in_range = numFaces > 0 && !keep_order; for (i = 0; i < (U32)numFaces * 3 && in_range; i++) { in_range = (U32)mFaces[i / 3][i % 3] < vertex_count; @@ -835,7 +835,7 @@ LLPolyMesh::~LLPolyMesh() //----------------------------------------------------------------------------- // LLPolyMesh::getMesh() //----------------------------------------------------------------------------- -LLPolyMesh *LLPolyMesh::getMesh(const std::string &name, LLPolyMesh* reference_mesh) +LLPolyMesh *LLPolyMesh::getMesh(const std::string &name, LLPolyMesh* reference_mesh, bool keep_order) { //------------------------------------------------------------------------- // search for an existing mesh by this name @@ -859,7 +859,7 @@ LLPolyMesh *LLPolyMesh::getMesh(const std::string &name, LLPolyMesh* reference_m { mesh_data->setupLOD(reference_mesh->getSharedData()); } - if ( ! mesh_data->loadMesh( full_path ) ) + if ( ! mesh_data->loadMesh( full_path, keep_order ) ) { delete mesh_data; return NULL; diff --git a/indra/llappearance/llpolymesh.h b/indra/llappearance/llpolymesh.h index 6de68e9b37..c55a58bd44 100644 --- a/indra/llappearance/llpolymesh.h +++ b/indra/llappearance/llpolymesh.h @@ -128,8 +128,10 @@ class LLPolyMeshSharedData // Retrieve the number of KB of memory used by this instance U32 getNumKB(); - // Load mesh data from file - bool loadMesh( const std::string& fileName ); + // Load mesh data from file. Its triangles are ordered for the vertex + // cache unless keep_order is set, for a mesh drawn blended, whose + // triangles blend over one another in the order they were made in. + bool loadMesh( const std::string& fileName, bool keep_order = false ); public: void genIndices(S32 offset); @@ -170,7 +172,8 @@ class LLPolyMesh // Requests a mesh by name. // If the mesh already exists in the global mesh table, it is returned, // otherwise it is loaded from file, added to the table, and returned. - static LLPolyMesh *getMesh( const std::string &name, LLPolyMesh* reference_mesh = NULL); + // keep_order is loadMesh's. + static LLPolyMesh *getMesh( const std::string &name, LLPolyMesh* reference_mesh = NULL, bool keep_order = false); // Frees all loaded meshes. // This should only be called once you know there are no outstanding From efd275c864b4be03f6df439dd40ec1b18dbe6be1 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:29 -0400 Subject: [PATCH 21/29] A rigged face's weights that run out leave the rest no influence A rigged mesh face reads its weights vertex by vertex until the array runs out, and warned when it ran out early, leaving the vertices past it with what their allocation held: a weld key and skinning weights of heap garbage. They take the weight a vertex with no influence gets, all of it on the first joint. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 8 +++++ indra/llmath/tests/llvolume_test.cpp | 49 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 84fdc3293d..b40b7a6c40 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -2887,6 +2887,14 @@ bool LLVolume::decodeVolumeFaces(const LLSD& mdl) LL_WARNS() << "Vertex weight count does not match vertex count!" << LL_ENDL; } + // A vertex the weights run out before takes the weight of a + // vertex with no influence, rather than what the allocation + // held. + for (; cur_vertex < num_verts; ++cur_vertex) + { + face.mWeights[cur_vertex].set(0.999f, 0.f, 0.f, 0.f); + } + } // modifier flags? diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index e6338977be..d6a8674278 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -4220,6 +4220,55 @@ namespace } } + // A rigged face's weights are read vertex by vertex until they run out; + // a vertex they run out before takes the weight of one with no + // influence, all of it on the first joint, rather than whatever its + // allocation held. + TEST_CASE("llvolume: a mesh face whose weights run out before its vertices weighs the rest by no influence", "[llvolume]") + { + auto binary = [](std::initializer_list values) + { + LLSD::Binary bytes; + for (U16 value : values) + { + bytes.push_back((U8)(value & 0xff)); + bytes.push_back((U8)(value >> 8)); + } + return LLSD(bytes); + }; + LLSD face; + face["Position"] = binary({ 0, 0, 0, 65535, 0, 0, 0, 65535, 0 }); + face["TriangleList"] = binary({ 0, 1, 2 }); + face["PositionDomain"]["Min"] = LLVector3(-0.5f, -0.5f, -0.5f).getValue(); + face["PositionDomain"]["Max"] = LLVector3(0.5f, 0.5f, 0.5f).getValue(); + face["TexCoord0Domain"]["Min"] = LLVector2(0.f, 0.f).getValue(); + face["TexCoord0Domain"]["Max"] = LLVector2(1.f, 1.f).getValue(); + // The first vertex's influences, joint 3 at half, and no more. + face["Weights"] = LLSD(LLSD::Binary{ 3, 0xff, 0x7f, 0xff }); + LLSD mdl = LLSD::emptyArray(); + mdl.append(face); + + LLVolumeParams params; + params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); + params.setSculptID(LLUUID("00000000-0000-0000-0000-00000000000b"), LL_SCULPT_TYPE_MESH); + LLPointer volume = new LLVolume(params, LLVolumeLODGroup::getVolumeScaleFromDetail(3)); + REQUIRE(ll_test::VolumeProbe::decode(*volume, mdl)); + REQUIRE(volume->getNumVolumeFaces() == 1); + const LLVolumeFace& decoded = volume->getVolumeFace(0); + REQUIRE(decoded.mNumVertices == 3); + REQUIRE(decoded.mWeights); + CHECK((S32)decoded.mWeights[0][0] == 3); + for (S32 v = 1; v < decoded.mNumVertices; ++v) + { + CAPTURE(v); + const LLVector4a& weight = decoded.mWeights[v]; + CHECK(weight[0] == 0.999f); + CHECK(weight[1] == 0.f); + CHECK(weight[2] == 0.f); + CHECK(weight[3] == 0.f); + } + } + // A circle path's stations step around it and end at its end cut. The // steps add up with rounding, and a station they bring a rounding short // of the end would leave a row with no height before the end's: no two From dd8b83d3076cef51fb9038a64991748d6cab7edc Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:32 -0400 Subject: [PATCH 22/29] cacheOptimize's out of memory fails its face, not the viewer cacheOptimize caught a bad allocation by showing the out-of-memory alert and raising a fatal error, on the mesh threads, where neither may run; a mesh face with an index list of a few hundred megabytes, small compressed, could raise it. The allocations that throw, the scratch and meshoptimizer's, are all made before the face's buffers are replaced, so it now warns and returns false, and the LOD fails to load as any other bad one does. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index b40b7a6c40..d29000215d 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -5719,8 +5719,13 @@ bool LLVolumeFace::cacheOptimize(bool gen_tangents) } catch (std::bad_alloc&) { - LLError::LLUserWarningMsg::showOutOfMemory(); - LL_ERRS("LLVolume") << "Bad memory allocation in cacheOptimize, " << vertex_count << " vertices, " << index_count << " indices" << LL_ENDL; + // Only the scratch and meshoptimizer's allocations throw, all made + // before the face's buffers are replaced, so the face keeps its + // buffers. This runs on the mesh threads, where a fatal error may + // not be raised. + LL_WARNS("LLVolume") << "Bad memory allocation in cacheOptimize, " << vertex_count << " vertices, " << index_count << " indices" + << LL_ENDL; + return false; } return true; From 2643be2d844e776d2dc07275b884dbcd0c8137dc Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:35 -0400 Subject: [PATCH 23/29] A face with no area keeps no planar normal A flexible prim is first built before it has a pose, on two stations at one place, and its sides have no area: every vertex takes +Z, and the planar normal made from them then was one plane facing +Z, kept through every partial build. A flexible prim of one section never builds in full again, so every planar coordinate made after it, on a texture change, a touch or a normal map's frame, was projected along z. A face with no area now keeps nothing, and its planar normal is made again at the next call; a face of one normal still projects along it. The area leaves out a triangle two of whose corners are one place, whose cross product a fused multiply-add leaves as rounding. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 71 ++++++++++++++++++---------- indra/llmath/llvolume.h | 6 ++- indra/llmath/tests/llvolume_test.cpp | 46 ++++++++++++++++++ 3 files changed, 95 insertions(+), 28 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index d29000215d..fa00760c6d 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -6005,8 +6005,40 @@ const LLVolumeOctree* LLVolumeFace::getOctree() const LLVector4a LLVolumeFace::makePlanarNormal() const { LLVector4a planar(0.f, 0.f, 0.f, -1.f); + bool keep = true; if (mPositions && mNormals && mNumVertices > 0) { + // A triangle two of whose corners are one place has no area, which + // a fused multiply-add would leave as the rounding of its cross + // product. + auto same_place = [](const LLVector4a& p, const LLVector4a& q) + { + return std::memcmp(p.getF32ptr(), q.getF32ptr(), 3 * sizeof(F32)) == 0; + }; + LLVector4a sum; + sum.clear(); + for (S32 i = 0; i + 2 < mNumIndices; i += 3) + { + const LLVector4a& a = mPositions[mIndices[i]]; + const LLVector4a& b = mPositions[mIndices[i + 1]]; + const LLVector4a& c = mPositions[mIndices[i + 2]]; + if (same_place(a, b) || same_place(b, c) || same_place(a, c)) + { + continue; + } + LLVector4a ab, ac, area; + ab.setSub(b, a); + ac.setSub(c, a); + area.setCross3(ab, ac); + sum.add(area); + } + const F32 length2 = sum.dot3(sum).getF32(); + + // A face with no area keeps nothing, and is made again at the next + // call: a flexible prim's sides have none before its first pose, and + // the one normal they have then is no direction of theirs. + keep = length2 > 0.f && std::isfinite(length2); + auto same_normal = [&](S32 v) { return std::memcmp(mNormals[v].getF32ptr(), mNormals[0].getF32ptr(), 3 * sizeof(F32)) == 0; }; bool one_normal = true; for (S32 v = 1; v < mNumVertices && one_normal; ++v) @@ -6019,38 +6051,25 @@ LLVector4a LLVolumeFace::makePlanarNormal() const planar = mNormals[0]; planar.getF32ptr()[3] = 1.f; } - else + else if (keep) { - LLVector4a sum; - sum.clear(); - for (S32 i = 0; i + 2 < mNumIndices; i += 3) - { - const LLVector4a& a = mPositions[mIndices[i]]; - LLVector4a ab, ac, area; - ab.setSub(mPositions[mIndices[i + 1]], a); - ac.setSub(mPositions[mIndices[i + 2]], a); - area.setCross3(ab, ac); - sum.add(area); - } - - const F32 length2 = sum.dot3(sum).getF32(); - if (length2 > 0.f && std::isfinite(length2)) + sum.mul(1.f / sqrtf(length2)); + constexpr F32 cone = PLANAR_CONE_COS + PLANAR_CONE_TIE; + bool within = true; + for (S32 v = 0; v < mNumVertices && within; ++v) { - sum.mul(1.f / sqrtf(length2)); - constexpr F32 cone = PLANAR_CONE_COS + PLANAR_CONE_TIE; - bool within = true; - for (S32 v = 0; v < mNumVertices && within; ++v) - { - const F32 along = mNormals[v].dot3(sum).getF32(); - within = along > 0.f && along * along >= cone * cone * mNormals[v].dot3(mNormals[v]).getF32(); - } - planar = sum; - planar.getF32ptr()[3] = within ? 1.f : -1.f; + const F32 along = mNormals[v].dot3(sum).getF32(); + within = along > 0.f && along * along >= cone * cone * mNormals[v].dot3(mNormals[v]).getF32(); } + planar = sum; + planar.getF32ptr()[3] = within ? 1.f : -1.f; } } - std::memcpy(mPlanarNormal, planar.getF32ptr(), sizeof(mPlanarNormal)); + if (keep) + { + std::memcpy(mPlanarNormal, planar.getF32ptr(), sizeof(mPlanarNormal)); + } return planar; } diff --git a/indra/llmath/llvolume.h b/indra/llmath/llvolume.h index 2fae980f4d..cac8974b07 100644 --- a/indra/llmath/llvolume.h +++ b/indra/llmath/llvolume.h @@ -975,8 +975,10 @@ class LLVolumeFace // weighted by their areas, normalized; in w 1 where the face is // projected as one plane and -1 where it is not. Made on the first call // and kept until a build other than a flexible prim's partial one: its - // frames keep their first build's planar texture coordinates. Main - // thread only. + // frames keep their first build's planar texture coordinates. A face + // with no area keeps nothing, and its planar normal is made again at + // each call: a flexible prim's sides have none before its first pose. + // Main thread only. LLVector4a getPlanarNormal() const { LLVector4a planar; diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index d6a8674278..876fc78d63 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -4616,6 +4616,52 @@ namespace CHECK(report.failures == 0); } + // A flexible prim's sides have no area before its first pose, and keep + // no planar normal from it: a one-section flexible prim's frames, every + // one a partial build of that first one, would otherwise project every + // side along z. Once posed, each side's planar normal is the one a full + // build at the pose makes. Its caps have area from the first, and keep + // the planar normal they first have, as every face of a flexible prim + // does. + TEST_CASE("llvolume: a flexible prim keeps no planar normal from before its first pose", "[llvolume]") + { + Report report; + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + LLPointer volume = flexiVolume(LL_PCODE_PROFILE_SQUARE, LL_PCODE_HOLE_SAME, 0.f, lod); + for (S32 f = 0; f < volume->getNumVolumeFaces(); ++f) + { + volume->getVolumeFace(f).getPlanarNormal(); + } + bendPath(*volume, 1, 1.1f); + volume->regen(); + + LLPointer full = flexiVolume(LL_PCODE_PROFILE_SQUARE, LL_PCODE_HOLE_SAME, 0.f, lod); + full->resizePath(2); + bendPath(*full, 1, 1.1f); + full->regen(); + REQUIRE(volume->getNumVolumeFaces() == full->getNumVolumeFaces()); + for (S32 f = 0; f < volume->getNumVolumeFaces(); ++f) + { + if (!(volume->getVolumeFace(f).mTypeMask & LLVolumeFace::SIDE_MASK)) + { + continue; + } + const LLVector4a got = volume->getVolumeFace(f).getPlanarNormal(); + const LLVector4a want = full->getVolumeFace(f).getPlanarNormal(); + if (std::memcmp(got.getF32ptr(), want.getF32ptr(), 4 * sizeof(F32)) != 0) + { + report.fail(fmt::format("flexible box, one section, LOD {}: {} has planar normal ({}, {}, {}, {}), " + "a full build ({}, {}, {}, {})", + lod, faceName(volume->getVolumeFace(f)), got[0], got[1], got[2], got[3], want[0], want[1], + want[2], want[3])); + } + } + } + INFO(report.text()); + CHECK(report.failures == 0); + } + // The uncut cube cap is built only on a line path, which flexi never // has; its partial build is the one a regen of an unmoved box makes. TEST_CASE("llvolume: an uncut cube cap's partial build keeps its buffers", "[llvolume]") From 56d8932301d8c108eed7f4f0161307b4c490ce64 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:39 -0400 Subject: [PATCH 24/29] The sphere placeholder is stitched as a sphere, whatever its sculpt's stitching A sculpt whose map is missing or has no area shows a sphere laid out as a sphere sculpt's map is sampled, but createSide joined its seams and poles as its own stitching does: a torus sculpt's placeholder summed its north pole with its south and left both poles' normals level, and a plane's left its seam open. The placeholder now takes the sphere's stitching. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 5 ++++ indra/llmath/llvolume.h | 6 +++++ indra/llmath/tests/llvolume_test.cpp | 36 ++++++++++++++++------------ 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index fa00760c6d..3fc7085929 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -3559,6 +3559,7 @@ void LLVolume::sculpt(U16 sculpt_width, U16 sculpt_height, S8 sculpt_components, } mSculptLevel = sculpt_level; + mSculptSpherePlaceholder = data_is_empty && visible_placeholder; // Delete any existing faces so that they get regenerated mVolumeFaces.clear(); @@ -7886,6 +7887,10 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) bool sculpt_invert = sculpt_type & LL_SCULPT_FLAG_INVERT; bool sculpt_mirror = sculpt_type & LL_SCULPT_FLAG_MIRROR; bool sculpt_reverse_horizontal = (sculpt_invert ? !sculpt_mirror : sculpt_mirror); // XOR + if (volume->isSculptSpherePlaceholder()) + { + sculpt_stitching = LL_SCULPT_TYPE_SPHERE; + } S32 num_vertices, num_indices; diff --git a/indra/llmath/llvolume.h b/indra/llmath/llvolume.h index cac8974b07..87aa12de9b 100644 --- a/indra/llmath/llvolume.h +++ b/indra/llmath/llvolume.h @@ -1169,6 +1169,11 @@ class LLVolume : public LLRefCount S32 getSculptLevel() const { return mSculptLevel; } void setSculptLevel(S32 level) { mSculptLevel = level; } + // Whether the sculpt shows the sphere it shows while its map is missing + // or has no area, which is laid out and stitched as a sphere sculpt is, + // whatever its own stitching. + bool isSculptSpherePlaceholder() const { return mSculptSpherePlaceholder; } + static void getLoDTriangleCounts(const LLVolumeParams& params, S32* counts); @@ -1239,6 +1244,7 @@ class LLVolume : public LLRefCount bool mLODInvariant; F32 mDetail; S32 mSculptLevel; + bool mSculptSpherePlaceholder = false; F32 mSurfaceArea; //unscaled surface area bool mIsMeshAssetLoaded; bool mIsMeshAssetUnavaliable; diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 876fc78d63..63ecaba9f2 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -3044,29 +3044,34 @@ namespace } // The sphere a sculpt shows while its map is missing or has no area, - // and the empty one it shows otherwise. + // whatever its stitching, and the empty one it shows otherwise. void inspectPlaceholders(const Inspect& how, bool spheres_only, Report& report) { for (S32 lod = 0; lod < LOD_COUNT; ++lod) { - for (bool visible : { true, false }) + for (U8 stitching : STITCHINGS) { - if (spheres_only && !visible) + for (bool visible : { true, false }) { - continue; + if (spheres_only && !visible) + { + continue; + } + LLPointer volume = buildSculpt(stitching, SculptMap(), lod, visible); + const std::string name = + fmt::format("{} placeholder of a {} sculpt at LOD {}", visible ? "sphere" : "empty", stitchingName(stitching), lod); + if (volume->getSculptLevel() != -1 || volume->getNumVolumeFaces() != 1) + { + report.fail( + fmt::format("{}: sculpt level {} and {} faces", name, volume->getSculptLevel(), volume->getNumVolumeFaces())); + continue; + } + inspect(*volume, name, how, report); } - LLPointer volume = buildSculpt(LL_SCULPT_TYPE_SPHERE, SculptMap(), lod, visible); - const std::string name = fmt::format("{} placeholder at LOD {}", visible ? "sphere" : "empty", lod); - if (volume->getSculptLevel() != -1 || volume->getNumVolumeFaces() != 1) + if (!spheres_only || lod > 0) { - report.fail(fmt::format("{}: sculpt level {} and {} faces", name, volume->getSculptLevel(), volume->getNumVolumeFaces())); - continue; + inspectSculpt(stitching, oneColourMap(), lod, "one colour", how, report); } - inspect(*volume, name, how, report); - } - if (!spheres_only || lod > 0) - { - inspectSculpt(LL_SCULPT_TYPE_SPHERE, oneColourMap(), lod, "one colour", how, report); } } } @@ -4803,7 +4808,8 @@ namespace // must run as a sphere sculpt's map does, around along the profile and // pole to pole along the path. The other way round, the poles' average // would land on one meridian, and the S wrap would sum the north pole - // with the south. + // with the south. It gets the sphere's stitching whatever its own: a + // torus's would sum its two poles, and a plane's leave its seam open. TEST_CASE("llvolume: the sphere placeholder's normals follow its surface", "[llvolume]") { Inspect how; From cc343cd59eb4f369a00fd3cf73176bee04879e34 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:41 -0400 Subject: [PATCH 25/29] Normals a grid's column and row cannot give come from one search A vertex with no normal of its own takes one from its column, or its row, or failing both from the nearest ring around it, which it searched ring by ring on its own. A shared sculpt whose map collapses most of its grid sent nearly every vertex to the rings, about a million checks a face at LOD 3. Those vertices now take theirs from one search outward from every normal of its own at once, a ring at a time. Among the nearest in a ring, the one it gives may be another than the ring search gave. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 88 ++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 3fc7085929..cee1206c8c 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -6296,10 +6296,11 @@ namespace // the middle of a sculpt's pinch, has no normal of its own. It takes the // nearest one up or down its column; where its whole column has none, // the nearest along its row; where its row has none either, as where a - // copied column crosses a copied row, the nearest around it. It takes - // only a normal a vertex has of its own, so that none is passed on twice. - // A grid with no normal at all faces +Z. Such a vertex is marked with a - // w of 1 until it has one; a normal of its own has a w of 0. + // copied column crosses a copied row, one of the nearest around it, in + // rings of the grid. It takes only a normal a vertex has of its own, so + // that none is passed on twice. A grid with no normal at all faces +Z. + // Such a vertex is marked with a w of 1 until it has one, and of 2 while + // it waits for the search around; a normal of its own has a w of 0. void normalize_grid_normals(LLVector4a* norm, S32 num_s, S32 num_t) { const S32 count = num_s * num_t; @@ -6318,6 +6319,7 @@ namespace return; } + bool around = false; for (S32 t = 0; t < num_t; ++t) { for (S32 s = 0; s < num_s; ++s) @@ -6350,38 +6352,64 @@ namespace } } } - // Each square looks only at its ring, since every point inside - // it was looked at by the square before. - for (S32 d = 1; d < llmax(num_s, num_t) && !donor; ++d) + if (donor) { - for (S32 row = llmax(t - d, 0); row <= llmin(t + d, num_t - 1) && !donor; ++row) + n = *donor; + n.getF32ptr()[3] = 1.f; + } + else + { + n.getF32ptr()[3] = 2.f; + around = true; + } + } + } + + if (around) + { + // Every vertex's nearest normal of its own in rings, found + // outward from all of them at once a ring at a time: a step to + // any of the eight vertices around one is one ring further. + ALScratchArena::Scope scratch; + U32* source = scratch.allocate(count); + U32* queue = scratch.allocate(count); + size_t head = 0; + size_t tail = 0; + for (S32 v = 0; v < count; ++v) + { + source[v] = U32_MAX; + if (own(norm[v])) + { + source[v] = (U32)v; + queue[tail++] = (U32)v; + } + } + while (head < tail) + { + const U32 v = queue[head++]; + const S32 s = (S32)v % num_s; + const S32 t = (S32)v / num_s; + for (S32 row = llmax(t - 1, 0); row <= llmin(t + 1, num_t - 1); ++row) + { + for (S32 column = llmax(s - 1, 0); column <= llmin(s + 1, num_s - 1); ++column) { - if (row == t - d || row == t + d) - { - for (S32 column = llmax(s - d, 0); column <= llmin(s + d, num_s - 1); ++column) - { - if (own(norm[column + num_s * row])) - { - donor = &norm[column + num_s * row]; - break; - } - } - } - else + const S32 u = column + num_s * row; + if (source[u] == U32_MAX) { - for (S32 column : { s - d, s + d }) - { - if (column >= 0 && column < num_s && own(norm[column + num_s * row])) - { - donor = &norm[column + num_s * row]; - break; - } - } + source[u] = source[v]; + queue[tail++] = (U32)u; } } } - n = donor ? *donor : LLVector4a(0.f, 0.f, 1.f); - n.getF32ptr()[3] = 1.f; + } + + for (S32 v = 0; v < count; ++v) + { + if (norm[v][3] == 2.f) + { + norm[v] = norm[source[v]]; + norm[v].getF32ptr()[3] = 1.f; + } } } From 7c013e56dc6f46c2aff492769600b3c7f9bce0da Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:45 -0400 Subject: [PATCH 26/29] A face simplified on its own is held only where a face's border runs A face simplified on its own locked every vertex at a position another face shares. Faces that lie over one another, as a garment's inside is a copy of its outside with a material of its own, share every position, so Precise mode locked them whole and left them at full detail. A vertex is now locked where a shared position lies on a face's open border, which keeps the seam between two faces that meet closed, and a face lying over another is free inside its borders. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/almeshsimplifier.cpp | 50 ++++++++++++++++++-- indra/llmath/almeshsimplifier.h | 12 +++-- indra/llmath/tests/almeshsimplifier_test.cpp | 31 ++++++++++++ 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/indra/llmath/almeshsimplifier.cpp b/indra/llmath/almeshsimplifier.cpp index 8ef4a302cb..cf7c37549d 100644 --- a/indra/llmath/almeshsimplifier.cpp +++ b/indra/llmath/almeshsimplifier.cpp @@ -47,6 +47,7 @@ struct ALMeshSimplifier::Mesh std::vector positions; // in the authored shape std::vector attributes; // ATTRIBUTE_COUNT a vertex std::vector shared; // at a position another face has a vertex at too + std::vector held; // shared, and at a position on the open border of a face std::vector uv_seam; // at a position its face has a vertex with another texture coordinate at F32 extent = 0.f; // the largest extent of the positions @@ -220,11 +221,52 @@ namespace } } } + // Each position on the open border of a face, where an edge of its + // runs that no other of its triangles has, its vertices taken at + // their positions. Where faces meet, one's border lies along the + // other; faces that overlap, as a garment's inside is a copy of its + // outside, share positions across their insides too. + std::vector border(vertex_count, 0); + std::vector edges; + for (S32 f = 0; f < faces; ++f) + { + edges.clear(); + for (U32 i = mesh.first_index[f]; i < mesh.first_index[f + 1]; i += 3) + { + for (U32 k = 0; k < 3; ++k) + { + const U32 a = remap[mesh.indices[i + k]]; + const U32 b = remap[mesh.indices[i + (k + 1) % 3]]; + if (a != b) + { + edges.push_back(((U64)llmin(a, b) << 32) | llmax(a, b)); + } + } + } + std::sort(edges.begin(), edges.end()); + for (size_t e = 0; e < edges.size();) + { + size_t run = e + 1; + while (run < edges.size() && edges[run] == edges[e]) + { + ++run; + } + if (run - e == 1) + { + border[(U32)(edges[e] >> 32)] = 1; + border[(U32)edges[e]] = 1; + } + e = run; + } + } + mesh.shared.resize(vertex_count); + mesh.held.resize(vertex_count); mesh.uv_seam.resize(vertex_count); for (U32 v = 0; v < vertex_count; ++v) { mesh.shared[v] = spans[remap[v]]; + mesh.held[v] = spans[remap[v]] && border[remap[v]]; mesh.uv_seam[v] = seam[remap[v]]; } @@ -656,8 +698,10 @@ F32 ALMeshSimplifier::simplifyFace(S32 face, LLVolumeFace& target, const Target& { // A face that meets another keeps its vertices where it meets it, // which the other's simplification keeps as well, and all of its - // parts, as a part of one face may be joined to another face's. Its - // borders that meet no face are free. + // parts, as a part of one face may be joined to another face's. A + // face meets another along a border, one's or the other's; its + // borders that meet no face are free, and so is its inside where + // another face lies over it. const bool meets = std::any_of(mesh.shared.begin() + first, mesh.shared.begin() + first + vertex_count, [](U8 shared) { return shared != 0; }); std::vector locks; @@ -670,7 +714,7 @@ F32 ALMeshSimplifier::simplifyFace(S32 face, LLVolumeFace& target, const Target& locks.resize(vertex_count, 0); for (U32 v = 0; v < vertex_count; ++v) { - if (mesh.shared[first + v]) + if (mesh.held[first + v]) { locks[v] |= (U8)meshopt_SimplifyVertex_Lock; } diff --git a/indra/llmath/almeshsimplifier.h b/indra/llmath/almeshsimplifier.h index 01bb56f4d5..59fe36181b 100644 --- a/indra/llmath/almeshsimplifier.h +++ b/indra/llmath/almeshsimplifier.h @@ -94,11 +94,13 @@ class ALMeshSimplifier F32 simplifyModel(LLVolume& target, const Target& goal, Seams seams) const; // Simplifies base's face on its own into target. A face that shares a - // position with any other face of base keeps its vertices at those - // positions where they are, so that no gap opens between the two, and - // keeps all of its parts; only one that shares none may lose its small - // detached parts. Returns as simplifyModel does; where no triangle is - // left, target is the face of three indices to a vertex at the origin. + // position with any other face of base keeps its vertices where they + // are at those of the positions that lie on either face's open border, + // so that no gap opens between the two, and keeps all of its parts; + // only one that shares none may lose its small detached parts. Where two + // faces overlap, each is free inside its borders. Returns as + // simplifyModel does; where no triangle is left, target is the face of + // three indices to a vertex at the origin. F32 simplifyFace(S32 face, LLVolumeFace& target, const Target& goal, Seams seams) const; // The face of base's face's layout that the given triangles make: the diff --git a/indra/llmath/tests/almeshsimplifier_test.cpp b/indra/llmath/tests/almeshsimplifier_test.cpp index 263699d177..0692b285be 100644 --- a/indra/llmath/tests/almeshsimplifier_test.cpp +++ b/indra/llmath/tests/almeshsimplifier_test.cpp @@ -759,6 +759,37 @@ namespace CHECK(pair_target->getVolumeFace(0).mNumIndices / 3 < 4 * 32); } + // Two faces at the same positions, as a garment's inside is a copy of + // its outside turned over, with a material of its own: each is held + // where its open border runs, which the other's is too, and simplifies + // inside it as a face on its own does. + TEST_CASE("almeshsimplifier: faces that lie over one another simplify face by face", "[almeshsimplifier]") + { + ArenaInstalled installed; + const F32 decimator = 9.f; + const Geometry front = plane(32); + Geometry back = front; + for (size_t i = 0; i < back.indices.size(); i += 3) + { + std::swap(back.indices[i + 1], back.indices[i + 2]); + } + for (LLVector3& normal : back.normals) + { + normal = -normal; + } + LLPointer base = volumeOf({ front, back }); + LLPointer target = targetFor(*base); + for (S32 f = 0; f < 2; ++f) + { + INFO(fmt::format("face {}", f)); + const F32 ratio = ALMeshSimplifier(*base).simplifyFace(f, target->getVolumeFace(f), toCount(decimator), Seams::ALL); + INFO(fmt::format("a ratio of {}", ratio)); + CHECK(ratio * ALLOWED_DRIFT >= decimator); + REQUIRE(wellFormed(target->getVolumeFace(f))); + CHECK(borderOf(target->getVolumeFace(f)).size() == 4 * 32); + } + } + // ------------------------------------------------------------------ // Seams From f44158931c062a8e899c7c9e0fd6512f31188cf7 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:48 -0400 Subject: [PATCH 27/29] A texture coordinate that is not finite weighs nothing in the simplifier One texture coordinate that is not a number made its face's texture density not a number, which counted as none: the whole face's coordinates weighed nothing in the simplifier's error, and a plane that bent texture across it held up at 26 triangles fell to 6. Such a coordinate is left out of the face's area and least coordinates, and weighs nothing itself. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/almeshsimplifier.cpp | 33 ++++++++++++++------ indra/llmath/tests/almeshsimplifier_test.cpp | 31 ++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/indra/llmath/almeshsimplifier.cpp b/indra/llmath/almeshsimplifier.cpp index cf7c37549d..8a6b5451a3 100644 --- a/indra/llmath/almeshsimplifier.cpp +++ b/indra/llmath/almeshsimplifier.cpp @@ -159,6 +159,11 @@ namespace if (face.mTexCoords && vertices) { + // A coordinate that is not finite counts for nothing, and is + // weighed as none: in the simplifier's error it would make + // every collapse near it free. + auto finite = [](const LLVector2& uv) { return std::isfinite(uv.mV[0]) && std::isfinite(uv.mV[1]); }; + F64 area = 0.0; size_t triangles = 0; for (U32 i = mesh.first_index[f]; i < mesh.first_index[f + 1]; i += 3) @@ -166,25 +171,35 @@ namespace const LLVector2& a = face.mTexCoords[mesh.indices[i] - first]; const LLVector2& b = face.mTexCoords[mesh.indices[i + 1] - first]; const LLVector2& c = face.mTexCoords[mesh.indices[i + 2] - first]; - area += fabs(0.5 * ((F64)(b.mV[0] - a.mV[0]) * (c.mV[1] - a.mV[1]) - (F64)(c.mV[0] - a.mV[0]) * (b.mV[1] - a.mV[1]))); - ++triangles; + if (finite(a) && finite(b) && finite(c)) + { + area += fabs(0.5 * ((F64)(b.mV[0] - a.mV[0]) * (c.mV[1] - a.mV[1]) - + (F64)(c.mV[0] - a.mV[0]) * (b.mV[1] - a.mV[1]))); + ++triangles; + } } const F64 mean = triangles ? area / (F64)triangles : 0.0; const F32 density = mean > 0.0 && std::isfinite(mean) ? (F32)(1.0 / sqrt(mean)) : 0.f; // Measured from the face's least coordinates, so that their // size stays near the density's whatever the texture's offset. - LLVector2 least = face.mTexCoords[0]; - for (U32 v = 1; v < vertices; ++v) + LLVector2 least(F32_MAX, F32_MAX); + for (U32 v = 0; v < vertices; ++v) { - least.mV[0] = llmin(least.mV[0], face.mTexCoords[v].mV[0]); - least.mV[1] = llmin(least.mV[1], face.mTexCoords[v].mV[1]); + if (finite(face.mTexCoords[v])) + { + least.mV[0] = llmin(least.mV[0], face.mTexCoords[v].mV[0]); + least.mV[1] = llmin(least.mV[1], face.mTexCoords[v].mV[1]); + } } for (U32 v = 0; v < vertices; ++v) { - F32* attribute = &mesh.attributes[(first + v) * ATTRIBUTE_COUNT]; - attribute[3] = (face.mTexCoords[v].mV[0] - least.mV[0]) * density; - attribute[4] = (face.mTexCoords[v].mV[1] - least.mV[1]) * density; + if (density > 0.f && finite(face.mTexCoords[v])) + { + F32* attribute = &mesh.attributes[(first + v) * ATTRIBUTE_COUNT]; + attribute[3] = (face.mTexCoords[v].mV[0] - least.mV[0]) * density; + attribute[4] = (face.mTexCoords[v].mV[1] - least.mV[1]) * density; + } } } } diff --git a/indra/llmath/tests/almeshsimplifier_test.cpp b/indra/llmath/tests/almeshsimplifier_test.cpp index 0692b285be..1594fed00e 100644 --- a/indra/llmath/tests/almeshsimplifier_test.cpp +++ b/indra/llmath/tests/almeshsimplifier_test.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -594,6 +595,36 @@ namespace } } + // A texture coordinate that is not finite, as a loader may pass on, + // counts for nothing, and the rest of its face's coordinates weigh as + // they did: one would otherwise make the face's texture density not a + // number, and the face's coordinates weigh nothing at all. A flat plane + // whose coordinates bend across it is held up by them alone. + TEST_CASE("almeshsimplifier: a texture coordinate that is not finite leaves its face's others their weight", "[almeshsimplifier]") + { + ArenaInstalled installed; + Geometry model = plane(16); + for (LLVector2& uv : model.uvs) + { + uv.mV[0] += 0.2f * sinf(F_TWO_PI * 2.f * uv.mV[1]); + } + ALMeshSimplifier::Target goal; + goal.error = 0.005f; + auto simplified = [&](const Geometry& geometry) + { + LLPointer base = volumeOf({ geometry }); + LLPointer target = targetFor(*base); + REQUIRE(ALMeshSimplifier(*base).simplifyModel(*target, goal, Seams::ALL) > 0.f); + return triangleCount(*target); + }; + const S32 whole = simplified(model); + model.uvs[0] = LLVector2(std::numeric_limits::quiet_NaN(), 0.f); + const S32 with_nan = simplified(model); + INFO(fmt::format("{} triangles from finite coordinates, {} with one not", whole, with_nan)); + REQUIRE(whole > 8); + CHECK(with_nan * 2 >= whole); + } + // ------------------------------------------------------------------ // Borders between faces From 25c09c57e83a71f9f95c7b68f4bceb1188a46d6f Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 22:03:50 -0400 Subject: [PATCH 28/29] Auto mode keeps a sloppy correction that brings it nearer its target A sloppy pass that removed too much is run again at a smaller decimator, which brings its ratio down, toward the target; the check that went back to the first pass when the correction did not work went back whenever the ratio came down, which a correction that worked always does. It goes back where the correction failed or came out further from the target. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llmodelpreview.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/indra/newview/llmodelpreview.cpp b/indra/newview/llmodelpreview.cpp index 031e7c23f7..4155835374 100644 --- a/indra/newview/llmodelpreview.cpp +++ b/indra/newview/llmodelpreview.cpp @@ -1657,7 +1657,10 @@ void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 d sloppy_ratio = simplify_model(sloppy_decimator, ALMeshSimplifier::Seams::SLOPPY); } - if (last_working_decimator > 0 && sloppy_ratio < last_working_ratio) + // A correction that failed, or came out further from the + // target than the first pass, goes back to the first. + if (last_working_decimator > 0 && + (sloppy_ratio < 0 || fabs(sloppy_ratio - indices_decimator) > fabs(last_working_ratio - indices_decimator))) { // Compensation didn't work, return back to previous decimator sloppy_ratio = simplify_model(indices_decimator, ALMeshSimplifier::Seams::SLOPPY); From 73f507170ae249c092326aa49a083a82e979aba6 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 24 Oct 2026 23:03:27 -0400 Subject: [PATCH 29/29] The registry's baseline moves to meshoptimizer 1.3#4 meshoptimizer 1.3#4 fixes four faults a review found in 1.3#3's compatible tangents. A sphere's pole whose UVs pinch to one point left its tangent group whole from 78 triangles, giving its pole corners an arbitrary direction; such a group is now split as MikkTSpace splits it, to rounding, still in linear time. One corner whose tangent is not a number no longer makes its group's tangents so. A triangle that names one vertex twice at a position that is not a number no longer pairs a corner's two edges with each other. And the vertex weld, which hashed UVs only as u ^ v, no longer takes 4.6 s for 65,535 vertices at one position with u == v: it hashes every bit, and sorts when its table probes too long. Over the mesh cache, 45,820 faces hash the same out of meshoptimizer and out of cacheOptimize as on 1.3#1, and llmath_tests gives the same results. The baseline moves to 1898932, which changes no other port. Co-Authored-By: Claude Opus 5.5 --- indra/vcpkg-configuration.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/indra/vcpkg-configuration.json b/indra/vcpkg-configuration.json index 1ec921be9a..741a2ec9ea 100644 --- a/indra/vcpkg-configuration.json +++ b/indra/vcpkg-configuration.json @@ -2,7 +2,7 @@ "registries": [ { "kind": "git", - "baseline": "f007fb751094d5689410eb97adeb3aae5e150642", + "baseline": "18989321bd0cb4b6163827833989621fce1e9209", "reference": "main", "repository": "https://github.com/AlchemyViewer/alchemy-registry", "packages": [