From dc0f4b478c40017a1d149828131f6459863ea526 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:33:26 -0400 Subject: [PATCH 01/15] A decoded face with a vertex under too many triangles keeps its order meshoptimizer's vertex cache order scores again every live triangle of each vertex in its cache whenever it takes a triangle, so one vertex of n triangles costs it about n squared. The decode caps bound a face's indices, not how many of them name one vertex: a face of three vertices and 699,050 copies of one triangle, a few kilobytes deflated, fits under them and kept a mesh decode thread for minutes, two of them all of the pool's LOD decoding, and a quit waiting on the pool. cacheOptimize now counts each welded vertex's triangles first, and a face whose sum of their squares passes 2^26 keeps the order it has; an ordinary face of 65,535 vertices sums to under 2.4 million. A failed allocation once the face had given up its buffers raised a fatal error, on the mesh threads where none may be raised, and a failed weights or tangents allocation was written through. The face now frees what it had, keeps no buffers, and cacheOptimize returns false. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 62 ++++++++++++++++++++++++---- indra/llmath/tests/llvolume_test.cpp | 45 ++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index fd38969364..078efb75b2 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -5326,6 +5326,36 @@ namespace unit.normalize3(); v.setSelectWithMask(length2.greaterThan(LLVector4a(FLT_MIN)), unit, LLVector4a::getZero()); } + + // meshoptimizer's vertex cache order scores again every triangle a vertex + // in its cache has left whenever it takes a triangle, so a vertex of n + // triangles costs it about n squared: one vertex under the most + // triangles a decoded face may have would take it minutes. Past this + // sum, over the vertices, of each one's triangles squared, some tens of + // milliseconds of scoring, a face keeps the order it has. An ordinary face of 65,535 + // vertices, six triangles each, sums to under 2.4 million. + constexpr U64 CACHE_ORDER_BUDGET = 1ull << 26; + + bool cache_order_affordable(const U32* indices, size_t index_count, size_t vertex_count) + { + ALScratchArena::Scope scratch; + U32* triangles = scratch.allocate(vertex_count); + std::fill_n(triangles, vertex_count, 0u); + for (size_t i = 0; i < index_count; ++i) + { + ++triangles[indices[i]]; + } + U64 cost = 0; + for (size_t v = 0; v < vertex_count; ++v) + { + cost += (U64)triangles[v] * triangles[v]; + if (cost > CACHE_ORDER_BUDGET) + { + return false; + } + } + return true; + } } // namespace // A decoded mesh LOD face made ready to draw: MikkTSpace's tangents, as @@ -5531,7 +5561,14 @@ bool LLVolumeFace::cacheOptimize(bool gen_tangents) U32* ordered = scratch.allocate(index_count); { LL_PROFILE_ZONE_NAMED_CATEGORY_VOLUME("cacheOptimize - cache order"); - meshopt_optimizeVertexCache(ordered, welded_indices, index_count, welded_count); + if (cache_order_affordable(welded_indices, index_count, welded_count)) + { + meshopt_optimizeVertexCache(ordered, welded_indices, index_count, welded_count); + } + else + { + std::copy_n(welded_indices, index_count, ordered); + } } // Each vertex's final place, in the order the reordered triangles @@ -5557,19 +5594,28 @@ bool LLVolumeFace::cacheOptimize(bool gen_tangents) } resizeVertices((S32)kept); - if (!mPositions) - { - LLError::LLUserWarningMsg::showOutOfMemory(); - LL_ERRS("LLVolume") << "Failed to allocate memory for resizeVertices(" << kept << ")" << LL_ENDL; - } - if (old_weights) + if (mPositions && old_weights) { allocateWeights((S32)kept); } - if (tangents || old_tangents) + if (mPositions && (tangents || old_tangents)) { allocateTangents((S32)kept); } + if (!mPositions || (old_weights && !mWeights) || ((tangents || old_tangents) && !mTangents)) + { + // The face has given up its buffers, and keeps none. This runs on + // the mesh threads, where a fatal error may not be raised. + ll_aligned_free<64>(old_positions); + ll_aligned_free_16(old_weights); + ll_aligned_free_16(old_tangents); + ll_aligned_free_16(mWeights); + mWeights = nullptr; + resizeVertices(0); + resizeIndices(0); + LL_WARNS("LLVolume") << "Failed to allocate memory for " << kept << " vertices in cacheOptimize" << LL_ENDL; + return false; + } for (size_t u = 0; u < welded_count; ++u) { diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 0b81b79957..ef263b7761 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -67,6 +67,7 @@ #include #include #include +#include #include #include #include @@ -3477,6 +3478,50 @@ namespace } } + // A fan of 60,000 triangles round one vertex, in shuffled order: a vertex + // cache order would score that vertex's triangles again for each it + // takes, some 1.8 billion times, so the face keeps the order it has. + TEST_CASE("llvolume: cacheOptimize keeps the order of a face with a vertex under too many triangles", "[llvolume]") + { + constexpr U32 RIM = 60000; + CorpusMesh mesh; + mesh.name = "fan"; + mesh.positions.emplace_back(0.f, 0.f, 0.f); + for (U32 i = 0; i < RIM; ++i) + { + const F32 angle = F_TWO_PI * (F32)i / (F32)RIM; + mesh.positions.emplace_back(0.5f * cosf(angle), 0.5f * sinf(angle), 0.f); + } + for (const LLVector3& p : mesh.positions) + { + mesh.normals.emplace_back(0.f, 0.f, 1.f); + mesh.uvs.emplace_back(p.mV[0] + 0.5f, p.mV[1] + 0.5f); + } + std::vector order(RIM); + std::iota(order.begin(), order.end(), 0u); + std::shuffle(order.begin(), order.end(), std::mt19937(7)); + for (U32 t : order) + { + mesh.indices.push_back(0); + mesh.indices.push_back((U16)(1 + t)); + mesh.indices.push_back((U16)(1 + (t + 1) % RIM)); + } + + const LLVolumeFace before = mesh.face(); + LLVolumeFace after = mesh.face(); + REQUIRE(after.cacheOptimize(true)); + REQUIRE(after.mNumIndices == before.mNumIndices); + S32 moved = 0; + for (S32 i = 0; i < before.mNumIndices; ++i) + { + if (bitsOf(after.mPositions[after.mIndices[i]]) != bitsOf(before.mPositions[before.mIndices[i]])) + { + ++moved; + } + } + CHECK(moved == 0); + } + TEST_CASE("llvolume: cacheOptimize splits the vertices a mirrored UV seam joins", "[llvolume]") { const CorpusMesh mesh = mirroredStrip(); From 1c6a22fe154cc056bc5a8875f4f04a0b2421763c Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:33:46 -0400 Subject: [PATCH 02/15] A sculpt of no stitching keeps every triangle of its map A sculpt block of type 0 with a map is built from the map, as the viewer has always built it: LLVOVolume takes any sculpt block for a sculpt. createSide took its stitching of none for a parametric prim's and left out the rows and columns its prim parameters would take to one point, a sphere's poles or a line tapered to nothing, though the map's samples put them anywhere: on sphere parameters every column-0 upper triangle and last-column lower triangle went, two seams of holes. The collapse now applies only where the volume has no sculpt ID, as the LOD invariance already tells prims from sculpts. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 5 +-- indra/llmath/tests/llvolume_test.cpp | 53 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 078efb75b2..7d0c6e6ae6 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -8503,9 +8503,10 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) // quad's two triangles, bottom left, top right, top left has two corners // on its left column and on its top row, and bottom left, bottom right, // top right on its right column and its bottom row. A face that would be - // left with none keeps them all. + // left with none keeps them all. A sculpt of no stitching is built from + // its map, whatever its parameters would make. Collapse collapse; - if (!volume->isUnique() && sculpt_stitching == LL_SCULPT_TYPE_NONE) + if (!volume->isUnique() && sculpt_stitching == LL_SCULPT_TYPE_NONE && volume->getParams().getSculptID().isNull()) { collapse = collapse_of(volume, mTypeMask & INNER_MASK); if (mTypeMask & END_MASK) diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index ef263b7761..a554c311b1 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -3331,6 +3331,59 @@ namespace } // The placeholders' normals have a case of their own, below. + // A sculpt block of no stitching with a map builds from the map, on + // whatever parameters its prim has. None of its rows or columns is the + // pole or apex those parameters would make of a prim's, and it keeps + // every triangle of its grid, as it always has. + TEST_CASE("llvolume: a sculpt of no stitching keeps every triangle of its map", "[llvolume]") + { + struct Shape + { + const char* name; + U8 profile; + U8 path; + F32 ratio; + }; + const Shape shapes[] = { + { "sphere", LL_PCODE_PROFILE_CIRCLE_HALF, LL_PCODE_PATH_CIRCLE, 1.f }, + { "line tapered to its end", LL_PCODE_PROFILE_CIRCLE, LL_PCODE_PATH_LINE, 0.f }, + { "line tapered from its beginning", LL_PCODE_PROFILE_CIRCLE, LL_PCODE_PATH_LINE, 2.f }, + }; + const SculptMap map = sphereMap(64, 64); + Report report; + for (const Shape& shape : shapes) + { + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + LLVolumeParams p; + p.setType(shape.profile, shape.path); + p.setRatio(shape.ratio, shape.ratio); + p.setSculptID(LLUUID("c8b26d0b-6f5a-4f10-8d1e-5c3b3e4a9a01"), LL_SCULPT_TYPE_NONE); + LLPointer volume = new LLVolume(p, LOD_DETAIL[lod]); + volume->sculpt(map.width, map.height, 3, map.rgb.data(), 0, true); + S32 sides = 0; + for (S32 f = 0; f < volume->getNumVolumeFaces(); ++f) + { + const LLVolumeFace& face = volume->getVolumeFace(f); + if ((face.mTypeMask & LLVolumeFace::CAP_MASK) || (face.mTypeMask & LLVolumeFace::FLAT_MASK)) + { + continue; + } + ++sides; + const S32 grid = 6 * (face.mNumS - 1) * (face.mNumT - 1); + if (face.mNumIndices != grid) + { + report.fail(fmt::format("{} at LOD {}: {} has {} indices of its grid's {}", shape.name, lod, faceName(face), + face.mNumIndices, grid)); + } + } + CHECK(sides > 0); + } + } + INFO(report.text()); + CHECK(report.failures == 0); + } + TEST_CASE("llvolume: an empty sculpt map makes a placeholder", "[llvolume]") { Inspect how; From 38c2bf367e7ace3824bdea1ec31c1c7a11fa6a33 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:33:58 -0400 Subject: [PATCH 03/15] A reversed cap's texture reads the right way round A cap that faces back along its station, as a twisted sphere's turns to the other side of its axis, is reversed so that it faces out, but kept the mapping of a cap facing its own way, and its texture read mirrored from the side it now faces. It takes the other cap's mapping, mirrored in v as a bottom cap's is against a top cap's. The sweep checks every cap's triangles wind the same way in UV as seen from in front. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 10 ++++++++++ indra/llmath/tests/llvolume_test.cpp | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 7d0c6e6ae6..1cf5967798 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -7981,6 +7981,16 @@ bool LLVolumeFace::createCap(LLVolume* volume, bool partial_build) } const LLVector4a normal = cap_normal(volume, mTypeMask & TOP_MASK, !partial_build, mReversed); + if (mReversed) + { + // Seen from the side it faces, a reversed cap is the other cap seen + // from its front, and takes that cap's mapping, mirrored in v, so + // that its texture reads the right way round as a bottom cap's does. + for (S32 i = 0; i < num_vertices; ++i) + { + mTexCoords[i].mV[1] = 1.f - mTexCoords[i].mV[1]; + } + } if (mFlexible) { // mCapTangent is plain floats, not aligned as a vector stores. diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index a554c311b1..50009d1f8e 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -861,6 +861,25 @@ namespace } } + // A cap's texture reads the right way round from the side it faces, a + // bottom cap's as a top cap's: its triangles wind the same way in UV as + // they do seen from in front, whichever way the cap faces. + void checkCapUV(const LLVolumeFace& face, Report& report, const std::string& where) + { + for (S32 t = 0; t + 2 < face.mNumIndices; t += 3) + { + const LLVector2& a = face.mTexCoords[face.mIndices[t]]; + const LLVector2& b = face.mTexCoords[face.mIndices[t + 1]]; + const LLVector2& c = face.mTexCoords[face.mIndices[t + 2]]; + const F32 area = (b.mV[0] - a.mV[0]) * (c.mV[1] - a.mV[1]) - (c.mV[0] - a.mV[0]) * (b.mV[1] - a.mV[1]); + if (area < -1e-6f) + { + report.fail(fmt::format("{}: triangle {} winds against its UVs: the texture reads mirrored from in front", where, t / 3)); + return; + } + } + } + // A face whose positions lie in a plane and, for a side, whose every // grid quad is a parallelogram, maps its UVs as an affine function of // position: least squares over the plane must fit every vertex. @@ -1628,6 +1647,7 @@ namespace if ((state.checks & Checks::CapFacing) && isCap(face)) { checkCapFacing(volume, face, state.raw, report, where); + checkCapUV(face, report, where); } // A sculpt's UVs are its grid, wherever the map puts the points. if ((state.checks & Checks::AffineUV) && !isSculpt(volume)) From d7fcae035361b13bad172b8210fe718b2b38d228 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:34:12 -0400 Subject: [PATCH 04/15] swept_side_normals reads the stations' frames from the stations The side normals read each station's frame as a run of vectors, five a frame, a station's size apart, through a pointer to the first station's frame's first row: an array of two, which every read past its second indexes out of bounds as far as a compiler's bounds analysis or _FORTIFY_SOURCE knows. The pointer is now taken from the station array at the frame's offset, which the reads stay within. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 1cf5967798..0de29d0cb0 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -6396,8 +6396,12 @@ namespace // The rows' frames, each in its station. static_assert(sizeof(LLPath::PathFrame) == 5 * sizeof(LLVector4a), "a frame is five vectors"); static_assert(sizeof(LLPath::PathPt) % sizeof(LLVector4a) == 0, "stations are a whole number of vectors apart"); + static_assert(offsetof(LLPath::PathPt, mFrame) % sizeof(LLVector4a) == 0, "a frame starts on a vector"); constexpr size_t STRIDE = sizeof(LLPath::PathPt) / sizeof(LLVector4a); - const LLVector4a* const frames = stations[begin_t].mFrame.mRow; + // A run of vectors over the stations, taken from the station rather + // than its frame's two rows, which the reads run past. + const LLVector4a* const frames = + reinterpret_cast(stations + begin_t) + offsetof(LLPath::PathPt, mFrame) / sizeof(LLVector4a); // A first or last row the path's scale narrows to a line or a point // takes its frame a step into the face. From 9e7aff35946315fb4e08a991f68d663dc77c3fc7 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:34:25 -0400 Subject: [PATCH 05/15] A double-sided face is held at its rim where another meets it Simplified face by face, a face holds the positions on its open border where another face meets it, so that the two keep meeting. The border was the edges one of the face's triangles alone has, and a face made double-sided, as clothing is, holds each triangle twice, once turned over: every edge of its rim had two, it had no border, and two such faces welded along a seam each collapsed their rim on their own, apart. Each face's triangles are now counted once at their positions, and its rim is its border as a face of one side's is. On two double-sided halves of a sphere every mode and decimation had dropped some of the 48 seam positions from both faces; now each keeps all of them. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/almeshsimplifier.cpp | 26 ++++++++--- indra/llmath/tests/almeshsimplifier_test.cpp | 45 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/indra/llmath/almeshsimplifier.cpp b/indra/llmath/almeshsimplifier.cpp index 57c0a7dd33..4a31340f26 100644 --- a/indra/llmath/almeshsimplifier.cpp +++ b/indra/llmath/almeshsimplifier.cpp @@ -34,7 +34,9 @@ #include #include +#include #include +#include #include // base's faces as meshoptimizer takes them, their vertices and indices @@ -240,21 +242,33 @@ namespace // 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. + // outside, share positions across their insides too. A face made + // double-sided holds each triangle twice, once turned over, and each + // is counted once, so that its rim is its border as a face of one + // side's is. std::vector border(vertex_count, 0); std::vector edges; + std::vector> triangles; for (S32 f = 0; f < faces; ++f) { - edges.clear(); + triangles.clear(); for (U32 i = mesh.first_index[f]; i < mesh.first_index[f + 1]; i += 3) { - for (U32 k = 0; k < 3; ++k) + std::array corners = { remap[mesh.indices[i]], remap[mesh.indices[i + 1]], remap[mesh.indices[i + 2]] }; + std::sort(corners.begin(), corners.end()); + triangles.push_back(corners); + } + std::sort(triangles.begin(), triangles.end()); + triangles.erase(std::unique(triangles.begin(), triangles.end()), triangles.end()); + + edges.clear(); + for (const std::array& corners : triangles) + { + for (const auto& [a, b] : { std::pair{ corners[0], corners[1] }, std::pair{ corners[1], corners[2] }, std::pair{ corners[0], corners[2] } }) { - 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)); + edges.push_back(((U64)a << 32) | b); } } } diff --git a/indra/llmath/tests/almeshsimplifier_test.cpp b/indra/llmath/tests/almeshsimplifier_test.cpp index eeea7c6c70..32d383db68 100644 --- a/indra/llmath/tests/almeshsimplifier_test.cpp +++ b/indra/llmath/tests/almeshsimplifier_test.cpp @@ -821,6 +821,51 @@ namespace } } + // A face made double-sided, as clothing is: its triangles again, turned + // over, on vertices of their own at the same positions facing the other + // way. + Geometry doubleSided(const Geometry& g) + { + Geometry out = g; + const U16 back = (U16)g.positions.size(); + for (size_t v = 0; v < g.positions.size(); ++v) + { + out.add(g.positions[v], -g.normals[v], g.uvs[v]); + } + for (size_t i = 0; i < g.indices.size(); i += 3) + { + out.triangle((U16)(back + g.indices[i]), (U16)(back + g.indices[i + 2]), (U16)(back + g.indices[i + 1])); + } + return out; + } + + // Two double-sided faces that meet: each uses every edge of its rim + // twice, once on each side, and its rim is still its open border, held + // where the other meets it. + TEST_CASE("almeshsimplifier: two double-sided faces simplified face by face still meet", "[almeshsimplifier]") + { + ArenaInstalled installed; + LLPointer base = volumeOf({ doubleSided(sphereRows(48, 24, 0, 12)), doubleSided(sphereRows(48, 24, 12, 24, 10.f)) }); + const std::set shared = sharedPositions(*base); + REQUIRE(shared.size() == 48); + for (F32 decimator : DECIMATORS) + { + for (Seams seams : { Seams::ALL, Seams::UV, Seams::NONE }) + { + INFO(fmt::format("a {}th, {}", decimator, nameOf(seams))); + LLPointer target = targetFor(*base); + for (S32 f = 0; f < 2; ++f) + { + INFO(fmt::format("face {}", f)); + CHECK(ALMeshSimplifier(*base).simplifyFace(f, target->getVolumeFace(f), toCount(decimator), seams) > 0.f); + REQUIRE(wellFormed(target->getVolumeFace(f))); + const std::set kept = positionsOf(target->getVolumeFace(f)); + CHECK(std::includes(kept.begin(), kept.end(), shared.begin(), shared.end())); + } + } + } + } + // ------------------------------------------------------------------ // Seams From bc97fb8563ae275fd402b2e0ff62390dca81c6b5 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:34:38 -0400 Subject: [PATCH 06/15] unit_cube_codes clamps a coordinate far outside the cube on x86 too A coordinate clamps to the cube's end, as its contract says, but on x86 the conversion gives a value past what an int32 holds, and infinity, the least one, which the packs then took to 0 where NEON's saturating conversion gives 255. The scaled coordinate is clamped to [0, 255] before the conversion at every width. No caller passes such a point: the pinch test takes sculpt samples, and a point that is none is checked against its position. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/alsimdkernels.inl | 14 +++++++++++-- indra/llmath/tests/alsimdkernels_test.cpp | 25 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/indra/llmath/alsimdkernels.inl b/indra/llmath/alsimdkernels.inl index 511eeac452..455f5c9452 100644 --- a/indra/llmath/alsimdkernels.inl +++ b/indra/llmath/alsimdkernels.inl @@ -619,7 +619,13 @@ void unit_cube_codes_impl(const LLVector4a* src, size_t n, U32* dst) // The packs keep each 128-bit half apart: the codes come out as // points 0, 2, 4, 6, 1, 3, 5, 7. const __m256i order = _mm256_setr_epi32(0, 4, 1, 5, 2, 6, 3, 7); - const auto code = [&](const F32* p) { return _mm256_cvtps_epi32(_mm256_mul_ps(_mm256_add_ps(_mm256_loadu_ps(p), half), steps)); }; + // Clamped before the conversion, which gives a value past what an + // int32 holds the least one, and the packs then the low end. + const auto code = [&](const F32* p) + { + const __m256 scaled = _mm256_mul_ps(_mm256_add_ps(_mm256_loadu_ps(p), half), steps); + return _mm256_cvtps_epi32(_mm256_min_ps(_mm256_max_ps(scaled, _mm256_setzero_ps()), steps)); + }; for (; i + 8 <= n; i += 8) { const F32* p = src[i].getF32ptr(); @@ -631,7 +637,11 @@ void unit_cube_codes_impl(const LLVector4a* src, size_t n, U32* dst) const f32x4 half = alsimd::set1(0.5f); const f32x4 steps = alsimd::set1(255.f); const i32x4 xyz = alsimd::set1_i32(0x00FFFFFF); - const auto code = [&](size_t k) { return alsimd::cvt_round(alsimd::mul(alsimd::add(alsimd::load(src[k].getF32ptr()), half), steps)); }; + const auto code = [&](size_t k) + { + const f32x4 scaled = alsimd::mul(alsimd::add(alsimd::load(src[k].getF32ptr()), half), steps); + return alsimd::cvt_round(alsimd::min(alsimd::max(scaled, alsimd::zero()), steps)); + }; for (; i + 4 <= n; i += 4) { alsimd::storeu_i32(reinterpret_cast(dst + i), alsimd::and_i32(alsimd::pack_u8(code(i), code(i + 1), code(i + 2), code(i + 3)), xyz)); diff --git a/indra/llmath/tests/alsimdkernels_test.cpp b/indra/llmath/tests/alsimdkernels_test.cpp index d4d1bf9013..a84ca59c16 100644 --- a/indra/llmath/tests/alsimdkernels_test.cpp +++ b/indra/llmath/tests/alsimdkernels_test.cpp @@ -429,6 +429,31 @@ namespace } }); } + // Far outside the cube, past what a 32-bit integer holds and at + // infinity, a coordinate still clamps to its end. + { + const F32 inf = std::numeric_limits::infinity(); + const F32 outside[] = { 1e7f, -1e7f, 3e9f, -3e9f, inf, -inf, 0.6f, -0.6f }; + std::vector points; + std::vector expected; + for (size_t i = 0; i < 24; ++i) + { + const F32 x = outside[i % 8], y = outside[(i * 3 + 1) % 8], z = outside[(i * 5 + 2) % 8]; + points.emplace_back(x, y, z); + const auto end = [](F32 v) { return v > 0.f ? 255u : 0u; }; + expected.push_back(end(x) | (end(y) << 8) | (end(z) << 16)); + } + for_each_width([&](const char* width) + { + std::vector codes(points.size()); + unit_cube_codes_impl(points.data(), points.size(), codes.data()); + for (size_t i = 0; i < points.size(); ++i) + { + CAPTURE(width, i); + REQUIRE(codes[i] == expected[i]); + } + }); + } for (size_t n : COUNTS) { std::vector points(n + 1); From 7a14a542f51ad27609e93d731b27331c8267f34b Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:34:53 -0400 Subject: [PATCH 07/15] A sculpt map averaging can give no area is not averaged, and the rest is averaged faster A sculpt whose samples have no area samples its map averaged down by two, until they have or no axis is left to average, and the main thread averaged the whole map at each step. The map a sculpt is handed may be the full decode of a texture on screen: a 2048 by 2048 map of one colour took 14 ms a build, all of it averaging that could never give area. - A map each of whose rows is one colour, or, where the stitching does not pinch its first and last rows to their middle pixels, whose rows are all alike, samples one point a mesh row, or the same points every row, and so does every map averaging down makes of it. It is no longer averaged: the loop still takes the sizes it would go through, so the sphere it ends as is the one it did. Over 1,440 builds of four such maps and one that is not, every stitching, flag, size and LOD, each volume's faces are bit for bit the same as before. - The averaging walks the map a row at a time, two or four pixels to one, with no index arithmetic per sample, rounding as it did. llvolume_bench gains three 2048 by 2048 maps, a plane sculpt at LOD 3, pinned to one core, thirty samples: before after one colour 14.0 ms 0.25 ms rows alike 14.0 ms 0.25 ms diagonal halves 16.0 ms 2.86 ms Diagonal halves still averages down to the end. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 99 +++++++++++++++++++++------ indra/llmath/tests/llvolume_bench.cpp | 48 +++++++++++++ 2 files changed, 126 insertions(+), 21 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 0de29d0cb0..f96dd75361 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -3641,33 +3641,79 @@ void sculpt_calc_mesh_resolution(U16 width, U16 height, U8 type, F32 detail, S32 // A sculpt map averaged down by two along x, y or both: each pixel the rounded mean of the two or four it covers. static std::vector sculpt_map_averaged_down(const U8* data, U16 width, U16 height, S8 components, bool along_x, bool along_y) { - const S32 step_x = along_x ? 2 : 1; - const S32 step_y = along_y ? 2 : 1; - const S32 out_width = width / step_x; - const S32 out_height = height / step_y; - std::vector out((size_t)out_width * out_height * components); - for (S32 y = 0; y < out_height; ++y) - { - for (S32 x = 0; x < out_width; ++x) + const size_t pixel = (size_t)components; + const size_t out_width = along_x ? width / 2 : width; + const size_t out_height = along_y ? height / 2 : height; + const size_t in_row = (size_t)width * pixel; + const size_t out_row = out_width * pixel; + std::vector out(out_row * out_height); + for (size_t y = 0; y < out_height; ++y) + { + const U8* upper = data + (along_y ? 2 * y : y) * in_row; + const U8* lower = along_y ? upper + in_row : upper; + U8* to = out.data() + y * out_row; + if (along_x && along_y) + { + for (size_t x = 0; x < out_width; ++x, upper += 2 * pixel, lower += 2 * pixel) + { + for (size_t c = 0; c < pixel; ++c) + { + *to++ = (U8)((upper[c] + upper[pixel + c] + lower[c] + lower[pixel + c] + 2) >> 2); + } + } + } + else if (along_x) { - for (S32 c = 0; c < components; ++c) + for (size_t x = 0; x < out_width; ++x, upper += 2 * pixel) { - S32 sum = 0; - for (S32 dy = 0; dy < step_y; ++dy) + for (size_t c = 0; c < pixel; ++c) { - for (S32 dx = 0; dx < step_x; ++dx) - { - sum += data[((size_t)(y * step_y + dy) * width + (x * step_x + dx)) * components + c]; - } + *to++ = (U8)((upper[c] + upper[pixel + c] + 1) >> 1); } - const S32 count = step_x * step_y; - out[((size_t)y * out_width + x) * components + c] = (U8)((sum + count / 2) / count); + } + } + else + { + for (size_t i = 0; i < out_row; ++i) + { + to[i] = (U8)((upper[i] + lower[i] + 1) >> 1); } } } return out; } +// Whether every pixel of each row of a sculpt map is its row's first, or, where the stitching does not pinch its first +// and last rows to their middle pixels, every row is the first: each mesh row of such a map samples one point, or +// every mesh row the same points, and so does each of the map averaged down any number of times, which has each row one +// colour or its rows alike as it does. +static bool sculpt_map_has_no_area_at_any_size(const U8* data, U16 width, U16 height, S8 components, U8 sculpt_stitching) +{ + const size_t row = (size_t)width * components; + if (sculpt_stitching != LL_SCULPT_TYPE_SPHERE) + { + bool rows_alike = true; + for (U16 y = 1; y < height && rows_alike; ++y) + { + rows_alike = std::memcmp(data + y * row, data, row) == 0; + } + if (rows_alike) + { + return true; + } + } + for (U16 y = 0; y < height; ++y) + { + // Each pixel the one before it: the row from its second pixel on is the row up to its last. + const U8* start = data + y * row; + if (std::memcmp(start + components, start, row - components) != 0) + { + return false; + } + } + return true; +} + // sculpt replaces generate() for sculpted surfaces void LLVolume::sculpt(U16 sculpt_width, U16 sculpt_height, S8 sculpt_components, const U8* sculpt_data, S32 sculpt_level, bool visible_placeholder) { @@ -3687,10 +3733,13 @@ void LLVolume::sculpt(U16 sculpt_width, U16 sculpt_height, S8 sculpt_components, // detail nothing but the collapsed lines, and no area. Where the samples have no area, the map averaged down by // two along each such axis is sampled instead, as the level below it would be, until one has or no axis is left // to average; a map whose samples have their area builds as it always has. + // A map that no averaging down gives area is not averaged, and the sizes it would go through are taken as they + // would be, so that the sphere it ends as is the one it would. U16 width = sculpt_width; U16 height = sculpt_height; const U8* data = sculpt_data; std::vector averaged; + bool never_has_area = false; while (true) { S32 requested_sizeS = 0; @@ -3718,7 +3767,10 @@ void LLVolume::sculpt(U16 sculpt_width, U16 sculpt_height, S8 sculpt_components, break; } - sculptGenerateMapVertices(width, height, sculpt_components, data, sculpt_type); + if (!never_has_area) + { + sculptGenerateMapVertices(width, height, sculpt_components, data, sculpt_type); + } // don't test lowest LOD to support legacy content DEV-33670 if (mDetail <= SCULPT_MIN_AREA_DETAIL) @@ -3726,7 +3778,7 @@ void LLVolume::sculpt(U16 sculpt_width, U16 sculpt_height, S8 sculpt_components, break; } - F32 area = sculptGetSurfaceArea(); + F32 area = never_has_area ? 0.f : sculptGetSurfaceArea(); mSurfaceArea = area; @@ -3747,8 +3799,13 @@ void LLVolume::sculpt(U16 sculpt_width, U16 sculpt_height, S8 sculpt_components, visible_placeholder = true; break; } - averaged = sculpt_map_averaged_down(data, width, height, sculpt_components, along_x, along_y); - data = averaged.data(); + never_has_area = + never_has_area || sculpt_map_has_no_area_at_any_size(data, width, height, sculpt_components, sculpt_type & LL_SCULPT_TYPE_MASK); + if (!never_has_area) + { + averaged = sculpt_map_averaged_down(data, width, height, sculpt_components, along_x, along_y); + data = averaged.data(); + } width = along_x ? width / 2 : width; height = along_y ? height / 2 : height; } diff --git a/indra/llmath/tests/llvolume_bench.cpp b/indra/llmath/tests/llvolume_bench.cpp index f5f53c6009..4c401c826b 100644 --- a/indra/llmath/tests/llvolume_bench.cpp +++ b/indra/llmath/tests/llvolume_bench.cpp @@ -183,6 +183,30 @@ namespace return map; } + // A map as large as a decode on screen hands a sculpt, each pixel what + // pixel gives: its samples have no area, and the map is averaged down + // until they have or none is left to average. + template + SculptMap largeSculptMap(U16 size, Pixel pixel) + { + SculptMap map; + map.width = size; + map.height = size; + map.rgb.resize((size_t)size * size * 3); + for (U16 y = 0; y < size; ++y) + { + for (U16 x = 0; x < size; ++x) + { + const LLVector3 p = pixel(x, y); + U8* out = &map.rgb[((size_t)y * size + x) * 3]; + out[0] = toByte(p.mV[0]); + out[1] = toByte(p.mV[1]); + out[2] = toByte(p.mV[2]); + } + } + return map; + } + LLPointer sculptVolume(U8 stitching, S32 lod) { LLVolumeParams p; @@ -672,6 +696,30 @@ namespace return volume->getNumVolumeFaces(); }; } + + // Large maps whose samples have no area: one colour and one of rows + // alike, which no averaging gives any, and one of two halves either + // side of a diagonal, which is averaged down to the end. + constexpr U16 LARGE = 2048; + const struct + { + const char* name; + SculptMap map; + } arealess[] = { + { "one colour", largeSculptMap(LARGE, [](U16, U16) { return LLVector3(0.1f, -0.2f, 0.3f); }) }, + { "rows alike", largeSculptMap(LARGE, [](U16 x, U16) { return LLVector3((F32)x / (LARGE - 1) - 0.5f, 0.f, 0.f); }) }, + { "diagonal halves", + largeSculptMap(LARGE, [](U16 x, U16 y) { return x + y < LARGE ? LLVector3(-0.25f, 0.f, 0.f) : LLVector3(0.25f, 0.f, 0.f); }) }, + }; + for (const auto& [name, map] : arealess) + { + LLPointer volume = sculptVolume(LL_SCULPT_TYPE_PLANE, 3); + BENCHMARK(fmt::format("plane sculpt, LOD 3, {} by {} map of {}", map.width, map.height, name)) + { + volume->sculpt(map.width, map.height, 3, map.rgb.data(), 0, true); + return volume->getNumVolumeFaces(); + }; + } } // createTangents sums meshoptimizer's default weighting a vertex at a From bff2be1727bf94a77c1a285f1ba4cc98c3367c79 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:41:27 -0400 Subject: [PATCH 08/15] An alpha-masked planar face is picked where its texture is opaque LLVOVolume::lineSegmentIntersect tests a hit on a face with a texture against the texture's alpha at the hit's texture coordinate, which surfaceToTexture works out from the hit's position and normal on a face whose texgen is not the default. It takes them in agent space, and the hit is in the volume's own space wherever the segment was taken into it: a planar face of an object away from the origin, turned or scaled read its alpha at another point, and a click went through opaque texels or caught on clear ones. The hit is taken back to agent space first, as the intersection the caller is given already is. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llvovolume.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/indra/newview/llvovolume.cpp b/indra/newview/llvovolume.cpp index 552222598b..4cc603d299 100644 --- a/indra/newview/llvovolume.cpp +++ b/indra/newview/llvovolume.cpp @@ -5105,7 +5105,20 @@ bool LLVOVolume::lineSegmentIntersect(const LLVector4a& start, const LLVector4a& } bool no_texture = !face->getTexture() || !face->getTexture()->hasGLTexture(); - bool mask = no_texture ? false : face->getTexture()->getMask(face->surfaceToTexture(tc, p, n)); + bool mask = false; + if (!no_texture) + { + // surfaceToTexture takes the hit in agent space, which a + // planar face projects from. + LLVector4a agent_p = p; + LLVector4a agent_n = n; + if (transform) + { + agent_p.load3(volumePositionToAgent(LLVector3(p.getF32ptr())).mV); + agent_n.load3(volumeDirectionToAgent(LLVector3(n.getF32ptr())).mV); + } + mask = face->getTexture()->getMask(face->surfaceToTexture(tc, agent_p, agent_n)); + } if (face && (ignore_alpha || pick_transparent || no_texture || mask)) { From d465a266983047501f580b62a54e9c09ce81e250 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:41:39 -0400 Subject: [PATCH 09/15] A sculpt map of fewer than three components reads nothing back A sculpt builds nothing from a map of one or two components: the volume keeps no pixels and level -1, which the texture updates take for no build yet and mark the object to build again, as they always have. Each of those builds now also asked for the texture on screen to be read back, since the volume had fewer pixels than the copy would, and the read, landing, marked it to build again: a readback every few frames for as long as such a map was in view. A texture of fewer than three components asks for none. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llvovolume.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/indra/newview/llvovolume.cpp b/indra/newview/llvovolume.cpp index 4cc603d299..e1feeb4593 100644 --- a/indra/newview/llvovolume.cpp +++ b/indra/newview/llvovolume.cpp @@ -1504,13 +1504,14 @@ void LLVOVolume::sculpt() // Until then the volume builds from the image there is, and the read, with more pixels at the same level, // builds it again below. A texture with nothing on screen to read builds from the copy there is, which the // decode setForSculpt asks for brings up to size. - // The copy the texture on screen makes is within the sculpts' budget, as a decode's is kept. + // The copy the texture on screen makes is within the sculpts' budget, as a decode's is kept. A map of fewer than + // three components is no map a sculpt builds from, and reads nothing back. S32 copy_width = 0; S32 copy_height = 0; S32 copy_discard = -1; mSculptTexture->getSculptCopySize(copy_width, copy_height, copy_discard); const S32 copy_pixels = copy_width * copy_height; - if ((!raw_image || raw_image->getWidth() * raw_image->getHeight() < copy_pixels) && + if (mSculptTexture->getComponents() >= 3 && (!raw_image || raw_image->getWidth() * raw_image->getHeight() < copy_pixels) && getVolume()->getSculptPixels() < copy_pixels) { mSculptTexture->requestSculptReadback(); From 2bb25308c22656eb00b7b5d95d8ec20d189abc0b Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:41:50 -0400 Subject: [PATCH 10/15] A GLTF primitive whose attributes do not match its positions fails to load Primitive::prep sized each attribute by its own accessor's count and read every one at each position's index: the weld, the flat-normal unroll and the vertex buffer fill read past a NORMAL, TANGENT, COLOR_0 or TEXCOORD array shorter than POSITION, and through a null JOINTS_0 where WEIGHTS_0 came alone. A crafted file loaded for upload crashed the viewer. A primitive with an attribute of another count, or joints without weights or weights without joints, now fails its load, as one with an index past its positions does; the format allows neither. Co-Authored-By: Claude Opus 5.5 --- indra/newview/gltf/primitive.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/indra/newview/gltf/primitive.cpp b/indra/newview/gltf/primitive.cpp index 9c4e52a0d4..5ee0ef6fcd 100644 --- a/indra/newview/gltf/primitive.cpp +++ b/indra/newview/gltf/primitive.cpp @@ -374,6 +374,23 @@ bool Primitive::prep(Asset& asset) } } + // Every attribute is read at each position's index, and skinning takes + // joints and weights together. + for (size_t count : { mNormals.size(), mTangents.size(), mColors.size(), mTexCoords0.size(), mTexCoords1.size(), mJoints.size(), + mWeights.size() }) + { + if (count != 0 && count != mPositions.size()) + { + LL_WARNS("GLTF") << "Attribute of " << count << " elements for " << mPositions.size() << " positions" << LL_ENDL; + return false; + } + } + if (mJoints.empty() != mWeights.empty()) + { + LL_WARNS("GLTF") << "Joints and weights not given together" << LL_ENDL; + return false; + } + // copy index buffer if (mIndices != INVALID_INDEX) { From ea8e64184baad8a52f2f4cd1f27454c6a6301df2 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:42:03 -0400 Subject: [PATCH 11/15] Restoring generated normals puts each model's own faces back The uploader's Generate normals keeps a copy of the faces it rewrites, for unticking it to put back, and the copies were lists in the order of the models they came from: - At the high LOD the base's models are the LOD's own. The base was rewritten first, so the LOD's copy was of faces already rewritten, and restoring wrote them back over the base's originals: unticking left the generated normals. - Nothing dropped the copies when a LOD's models were replaced, by a load, LOD generation or "use LOD above", and restoring then wrote the old models' faces into the new ones, past the end of the copy where there were more of them. Each copy is now of a model, kept with it, made once before any of the model's faces are rewritten, and restored into that model alone. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llmodelpreview.cpp | 59 ++++++++++++-------------------- indra/newview/llmodelpreview.h | 12 ++++--- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/indra/newview/llmodelpreview.cpp b/indra/newview/llmodelpreview.cpp index bec580ff53..b5a28aa992 100644 --- a/indra/newview/llmodelpreview.cpp +++ b/indra/newview/llmodelpreview.cpp @@ -1310,18 +1310,25 @@ void LLModelPreview::generateNormals() angle_cutoff *= DEG_TO_RAD; - if (which_lod == 3 && !mBaseModel.empty()) + // Every model's faces are copied before any is rewritten: the base's + // models are the high LOD's own. + const auto keep = [](faces_copy_t& copy, const LLModelLoader::model_list& models) { - if (mBaseModelFacesCopy.empty()) + for (const LLPointer& model : models) { - mBaseModelFacesCopy.reserve(mBaseModel.size()); - for (LLModelLoader::model_list::iterator it = mBaseModel.begin(), itE = mBaseModel.end(); it != itE; ++it) + if (std::none_of(copy.begin(), copy.end(), [&](const faces_copy_t::value_type& kept) { return kept.first == model; })) { v_LLVolumeFace_t faces; - (*it)->copyFacesTo(faces); - mBaseModelFacesCopy.push_back(faces); + model->copyFacesTo(faces); + copy.emplace_back(model, std::move(faces)); } } + }; + keep(mModelFacesCopy[which_lod], mModel[which_lod]); + + if (which_lod == 3 && !mBaseModel.empty()) + { + keep(mBaseModelFacesCopy, mBaseModel); for (LLModelLoader::model_list::iterator it = mBaseModel.begin(), itE = mBaseModel.end(); it != itE; ++it) { @@ -1331,20 +1338,8 @@ void LLModelPreview::generateNormals() mVertexBuffer[LLModel::NUM_LODS].clear(); } - bool perform_copy = mModelFacesCopy[which_lod].empty(); - if (perform_copy) { - mModelFacesCopy[which_lod].reserve(mModel[which_lod].size()); - } - for (LLModelLoader::model_list::iterator it = mModel[which_lod].begin(), itE = mModel[which_lod].end(); it != itE; ++it) { - if (perform_copy) - { - v_LLVolumeFace_t faces; - (*it)->copyFacesTo(faces); - mModelFacesCopy[which_lod].push_back(faces); - } - (*it)->generateNormals(angle_cutoff); } @@ -1363,29 +1358,17 @@ void LLModelPreview::restoreNormals() return; } - if (!mBaseModelFacesCopy.empty()) + // Each model takes back its own faces, whatever the LOD's models are now. + const auto restore = [](faces_copy_t& copy) { - llassert(mBaseModelFacesCopy.size() == mBaseModel.size()); - - vv_LLVolumeFace_t::const_iterator itF = mBaseModelFacesCopy.begin(); - for (LLModelLoader::model_list::iterator it = mBaseModel.begin(), itE = mBaseModel.end(); it != itE; ++it, ++itF) - { - (*it)->copyFacesFrom((*itF)); - } - - mBaseModelFacesCopy.clear(); - } - - if (!mModelFacesCopy[which_lod].empty()) - { - vv_LLVolumeFace_t::const_iterator itF = mModelFacesCopy[which_lod].begin(); - for (LLModelLoader::model_list::iterator it = mModel[which_lod].begin(), itE = mModel[which_lod].end(); it != itE; ++it, ++itF) + for (const faces_copy_t::value_type& kept : copy) { - (*it)->copyFacesFrom((*itF)); + kept.first->copyFacesFrom(kept.second); } - - mModelFacesCopy[which_lod].clear(); - } + copy.clear(); + }; + restore(mBaseModelFacesCopy); + restore(mModelFacesCopy[which_lod]); mVertexBuffer[which_lod].clear(); refresh(); diff --git a/indra/newview/llmodelpreview.h b/indra/newview/llmodelpreview.h index fa831e151f..9e85c6c925 100644 --- a/indra/newview/llmodelpreview.h +++ b/indra/newview/llmodelpreview.h @@ -284,10 +284,14 @@ class LLModelPreview : public LLViewerDynamicTexture, public LLMutex, public LLH LLModelLoader::model_list mBaseModel; typedef std::vector v_LLVolumeFace_t; - typedef std::vector vv_LLVolumeFace_t; - - vv_LLVolumeFace_t mModelFacesCopy[LLModel::NUM_LODS]; - vv_LLVolumeFace_t mBaseModelFacesCopy; + // Each model's faces as they were before generateNormals first rewrote + // them, for restoreNormals to put back into that model: a model the + // base and a LOD share is copied once, before either is rewritten, and + // a model loaded or made since takes nothing of another's. + typedef std::vector, v_LLVolumeFace_t>> faces_copy_t; + + faces_copy_t mModelFacesCopy[LLModel::NUM_LODS]; + faces_copy_t mBaseModelFacesCopy; U32 mGroup; From d1da0360ee14b0cd1910a31c6af8660f4bb0210e Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:42:16 -0400 Subject: [PATCH 12/15] A face whose volume face outgrew its slots is laid out again getGeometryVolume writes a volume face into the slots its batch laid out for the face, as large as the volume face was then, and checked only that it stayed inside the whole buffer. A sculpt's index count now depends on its map, not its grid alone: a shared sculpt built again at the same level from a better copy of its map may leave out other triangles, and an object sharing it, filled again without its batch being laid out, wrote past its own slot into the next face's indices where the count grew, or left its old tail drawn where it shrank. A face whose volume face no longer fits its slots now fails the fill, which a rebuild of the mesh turns into laying the group out again. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llface.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/indra/newview/llface.cpp b/indra/newview/llface.cpp index b06a87b844..9ad17398ae 100644 --- a/indra/newview/llface.cpp +++ b/indra/newview/llface.cpp @@ -1118,6 +1118,21 @@ bool LLFace::getGeometryVolume(const LLVolume& volume, if (mVertexBuffer.notNull()) { + // A face fills the slots its batch laid out for it, as large as its + // volume face was then. A shared sculpt built again from a better map + // keeps its grid but may leave out other triangles, and its faces + // filled into slots of another size would write into the next face's + // or leave their own tails drawn: the batch is laid out again. + if (num_indices != mIndicesCount || num_vertices > mGeomCount) + { + if (gDebugGL) + { + LL_WARNS() << "Face slots no longer fit: " << num_vertices << " vertices and " << num_indices << " indices into " + << mGeomCount << " and " << mIndicesCount << LL_ENDL; + } + return false; + } + if (num_indices + mIndicesIndex > mVertexBuffer->getNumIndices()) { if (gDebugGL) From 0bf5fe16333a4f4381a4e45c3c873bd3130573fa Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 07:57:25 -0400 Subject: [PATCH 13/15] The uploader drops the face copies of models it no longer holds Each copy Generate normals keeps is held with its model, so that restoring puts each model's own faces back, and the copy kept the model alive. A load, a cleared or incompatible LOD, LOD generation or "use LOD above" replaces a LOD's models without restoring them, and each model they left, with its faces copied, was kept until the next restore or the preview closed: repeated imports kept every superseded model. The copies of models no LOD and not the base holds are dropped wherever a LOD's or the base's models are replaced. A model another LOD still holds keeps its copy. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llmodelpreview.cpp | 35 ++++++++++++++++++++++++++++++++ indra/newview/llmodelpreview.h | 3 +++ 2 files changed, 38 insertions(+) diff --git a/indra/newview/llmodelpreview.cpp b/indra/newview/llmodelpreview.cpp index 262f1ed81b..374821d3e4 100644 --- a/indra/newview/llmodelpreview.cpp +++ b/indra/newview/llmodelpreview.cpp @@ -717,6 +717,7 @@ void LLModelPreview::clearModel(S32 lod) mVertexBuffer[lod].clear(); mModel[lod].clear(); mScene[lod].clear(); + dropFacesCopiesOfLostModels(); } void LLModelPreview::getJointAliases(JointMap& joint_map) @@ -929,6 +930,7 @@ void LLModelPreview::clearIncompatible(S32 lod) } } } + dropFacesCopiesOfLostModels(); if (replaced_base_model && !mGenLOD) { @@ -1276,6 +1278,7 @@ void LLModelPreview::loadModelCallback(S32 loaded_lod) LLFloaterModelPreview::addStringToLog("ModelLoaded", args, false, loaded_lod); } } + dropFacesCopiesOfLostModels(); refresh(); mModelLoadedSignal(); @@ -1406,6 +1409,35 @@ const ALMeshSimplifier& LLModelPreview::simplifierFor(U32 mdl_idx) return *kept.mSimplifier; } +void LLModelPreview::dropFacesCopiesOfLostModels() +{ + const auto held = [this](const LLPointer& model) + { + if (std::find(mBaseModel.begin(), mBaseModel.end(), model) != mBaseModel.end()) + { + return true; + } + for (const LLModelLoader::model_list& models : mModel) + { + if (std::find(models.begin(), models.end(), model) != models.end()) + { + return true; + } + } + return false; + }; + const auto drop = [&](faces_copy_t& copy) + { + copy.erase(std::remove_if(copy.begin(), copy.end(), [&](const faces_copy_t::value_type& kept) { return !held(kept.first); }), + copy.end()); + }; + drop(mBaseModelFacesCopy); + for (faces_copy_t& copy : mModelFacesCopy) + { + drop(copy); + } +} + void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 decimation, bool enforce_tri_limit) { LL_DEBUGS("Upload") << "Generating lod " << which_lod << " using meshoptimizer" << LL_ENDL; @@ -1799,6 +1831,8 @@ void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 d } } + dropFacesCopiesOfLostModels(); + LL_INFOS("Upload") << "LOD " << which_lod << ", Mesh optimizer processed meshes : " << meshes_processed <<" simplified: " << meshes_simplified << ", slopily simplified: " << meshes_sloppy_simplified @@ -2404,6 +2438,7 @@ void LLModelPreview::updateLodControls(S32 lod) mModel[lod] = mModel[lod + 1]; mScene[lod] = mScene[lod + 1]; mVertexBuffer[lod].clear(); + dropFacesCopiesOfLostModels(); // Also update lower LoD if (lod > LLModel::LOD_IMPOSTOR) diff --git a/indra/newview/llmodelpreview.h b/indra/newview/llmodelpreview.h index 7879448aee..b62030a349 100644 --- a/indra/newview/llmodelpreview.h +++ b/indra/newview/llmodelpreview.h @@ -208,6 +208,9 @@ class LLModelPreview : public LLViewerDynamicTexture, public LLMutex, public LLH void warnTextureScaling(); void lookupLODModelFiles(S32 lod); + // Drops the face copies of models no LOD and not the base holds any + // longer, which nothing restores into and the copies alone keep. + void dropFacesCopiesOfLostModels(); private: //Utility function for controller vertex compare From 4dd25aae45e36dca11fecbc49b399376c2a94781 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 08:35:53 -0400 Subject: [PATCH 14/15] The registry's baseline moves to meshoptimizer 1.3#5 meshoptimizer 1.3#5 bounds the vertex weld that cacheOptimize, remap and generateNormals run on every mesh face. Its hash table probed without limit, and a mesh asset could choose weights whose hashes share the table's index bits: a face of 65,535 such vertices took 20.5 s to weld on a mesh decode thread, against 1.5 ms for ordinary vertices. Past 16 probes a vertex plus 1,024 the weld now sorts the vertices instead, which numbers them exactly as the table does, and that face takes 18 ms. Ordinary welds cost 2 to 4 percent more. Over the mesh cache's 40,455 faces the four welds come out the same as upstream's and none comes near the budget. llmath_tests passes on the new port, its report the same as on 1.3#4. The baseline moves to ee9cfc3, 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 741a2ec9ea..db196d2fa9 100644 --- a/indra/vcpkg-configuration.json +++ b/indra/vcpkg-configuration.json @@ -2,7 +2,7 @@ "registries": [ { "kind": "git", - "baseline": "18989321bd0cb4b6163827833989621fce1e9209", + "baseline": "ee9cfc3d9ac97195a137d8fc1a1fd1902f1858d6", "reference": "main", "repository": "https://github.com/AlchemyViewer/alchemy-registry", "packages": [ From 4dedc6905357830e635340c2d4c44c9151d3dd79 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 26 Oct 2026 09:11:56 -0400 Subject: [PATCH 15/15] The double-sided seam test checks the seam's edges on each side The test checked that each simplified face kept every position of the seam, which a face can do and still lose an edge between two of them, leaving a gap. A rim edge of a double-sided face is an edge of one triangle on each side, so each of the seam's 48 edges must now be used by exactly two of the face's triangles. Without the fix, every mode and decimation kept none of them on both sides. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/tests/almeshsimplifier_test.cpp | 34 +++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/indra/llmath/tests/almeshsimplifier_test.cpp b/indra/llmath/tests/almeshsimplifier_test.cpp index 94fcbac5d8..78d730f69e 100644 --- a/indra/llmath/tests/almeshsimplifier_test.cpp +++ b/indra/llmath/tests/almeshsimplifier_test.cpp @@ -866,15 +866,39 @@ namespace return out; } + // How many of a face's triangles have each edge, at its positions. + std::map edgeUses(const LLVolumeFace& face) + { + std::map uses; + for (S32 i = 0; i < face.mNumIndices; i += 3) + { + for (S32 k = 0; k < 3; ++k) + { + ++uses[edgeOf(keyOf(face.mPositions[face.mIndices[i + k]]), keyOf(face.mPositions[face.mIndices[i + (k + 1) % 3]]))]; + } + } + return uses; + } + // Two double-sided faces that meet: each uses every edge of its rim // twice, once on each side, and its rim is still its open border, held - // where the other meets it. + // where the other meets it: each side of each keeps every edge of the + // seam. TEST_CASE("almeshsimplifier: two double-sided faces simplified face by face still meet", "[almeshsimplifier]") { ArenaInstalled installed; LLPointer base = volumeOf({ doubleSided(sphereRows(48, 24, 0, 12)), doubleSided(sphereRows(48, 24, 12, 24, 10.f)) }); const std::set shared = sharedPositions(*base); REQUIRE(shared.size() == 48); + std::set seam; + for (const auto& [edge, uses] : edgeUses(base->getVolumeFace(0))) + { + if (shared.count(edge.first) && shared.count(edge.second)) + { + seam.insert(edge); + } + } + REQUIRE(seam.size() == 48); for (F32 decimator : DECIMATORS) { for (Seams seams : { Seams::ALL, Seams::UV, Seams::NONE }) @@ -888,6 +912,14 @@ namespace REQUIRE(wellFormed(target->getVolumeFace(f))); const std::set kept = positionsOf(target->getVolumeFace(f)); CHECK(std::includes(kept.begin(), kept.end(), shared.begin(), shared.end())); + const std::map uses = edgeUses(target->getVolumeFace(f)); + size_t sides_kept = 0; + for (const EdgeKey& edge : seam) + { + const auto found = uses.find(edge); + sides_kept += found != uses.end() && found->second == 2; + } + CHECK(sides_kept == seam.size()); } } }