From fb3233fe2fc441ef254dd9b5cd0a9678de7abd4f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 23:51:25 +0000 Subject: [PATCH 001/110] Add TransferDiag summary log to GroupedShellTransfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `UvtLog.Category.TransferDiag` and emits a single concise summary line at the end of every `TransferCore` call: src/tgt shell counts, matched/rejected, ShellStatus + method histograms, fragmentsMerged, dedupConflicts, overlap/consistency fixes, mean/max 3D match distance, and topology iterations/fixed/capHit. Unblocks step 1 of the next-session checklist in Documentation~/TRANSFER_LOD_QUALITY_PLAN.md (identity sanity test, per-LOD ratio sweep) without trawling per-shell verbose logs. New category bit slots into the existing Log filters foldout via the auto-enumerated `s_logCategories` list — no UI change needed. Diagnostic-only: no behavioural change to the transfer pipeline. --- CHANGELOG.md | 1 + Editor/GroupedShellTransfer.cs | 73 ++++++++++++++++++++++++++++++++++ Editor/UvtLog.cs | 25 ++++++------ 3 files changed, 87 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f03ed638..0fad32f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] ### Added +- **Per-target Transfer diagnostic summary** — `GroupedShellTransfer.TransferCore` emits a single Info line at the end of every transfer when `UvtLog.Category.TransferDiag` is enabled in the Log filters panel. Reports source/target shell counts, `shellsMatched` / `shellsRejected`, `ShellStatus` histogram (Accepted/Degraded/Poor/Rejected/Unmatched), method histogram (interp/xform/merged), `fragmentsMerged`, `dedupConflicts`, `shellsOverlapFixed`, `consistencyCorrected`, mean/max 3D match distance, and topology iterations/fixed/capHit. Lets the identity-sanity and per-LOD ratio sweep checklists in `Documentation~/TRANSFER_LOD_QUALITY_PLAN.md` be run without trawling verbose per-shell output. New category bit slots into the existing Log filters UI automatically. - **`UvProgress` service** (`Editor/Framework/UvProgress.cs`) — central non-modal progress reporting. Routes status to `UnityEditor.Progress` (Background Tasks panel) plus an inline strip drawn at the bottom of the hub window. Supports nested scopes, phase labels, indeterminate/determinate fractions, cooperative cancellation via `UvProgress.CancelRequested` (`Volatile.Read`-backed `_cancelFlag` so background `Task.Run` work observes user-cancel reliably across the memory barrier), a thread-safe `ReportFromBackground` for `Task.Run` callers (with `Interlocked.Exchange`-guarded snapshot/clear so a racing writer can't lose an update; the `EditorApplication.update` pump is hooked once on assembly load from the main thread via `[InitializeOnLoadMethod]`), and a `Last` outcome shown while idle. - **Inline progress strip in `UvToolHub`** — sits at the bottom of the window as a status bar. Reserves fixed height unconditionally so toggling active state doesn't displace any layout. Shows title · phase · detail · elapsed in distinct columns with a Cancel button pinned to the right; while idle displays `✓ / ✗ Last-operation · 12.3s`. Marquee animation for indeterminate fractions; orange tint while cancelling. - **Async pipeline (no main-thread freeze)**: diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index 297ae1b9..b2ee8764 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -3667,6 +3667,79 @@ static TransferResult TransferCore( } result.matchHints = matchHints; + // ── Per-target Transfer summary (TransferDiag category) ── + // Single concise line + histogram per target LOD so the user can + // run the identity sanity test and per-LOD ratio sweep from + // TRANSFER_LOD_QUALITY_PLAN.md without trawling verbose logs. + // Emitted at Info level under UvtLog.Category.TransferDiag so the + // existing Log filters toggle lets the user gate it independently + // of the noisy per-shell Match/Topology output. + if (UvtLog.Current >= UvtLog.Level.Info + && UvtLog.IsCategoryEnabled(UvtLog.Category.TransferDiag)) + { + int accepted = 0, degraded = 0, poor = 0, rejected = 0, unmatched = 0; + if (result.targetShellStatus != null) + { + for (int i = 0; i < result.targetShellStatus.Length; i++) + { + switch (result.targetShellStatus[i]) + { + case ShellStatus.Accepted: accepted++; break; + case ShellStatus.Degraded: degraded++; break; + case ShellStatus.Poor: poor++; break; + case ShellStatus.Rejected: rejected++; break; + case ShellStatus.Unmatched: unmatched++; break; + } + } + } + + // Mean / max 3D centroid match distance over matched shells + double sumDist = 0; float maxDist = 0; int matchedCount = 0; + if (result.targetShellMatchDistSqr != null + && result.targetShellToSourceShell != null) + { + for (int i = 0; i < result.targetShellMatchDistSqr.Length; i++) + { + if (result.targetShellToSourceShell[i] < 0) continue; + float dsq = result.targetShellMatchDistSqr[i]; + if (float.IsInfinity(dsq) || dsq >= float.MaxValue) continue; + float d = Mathf.Sqrt(Mathf.Max(dsq, 0f)); + sumDist += d; + if (d > maxDist) maxDist = d; + matchedCount++; + } + } + float meanDist = matchedCount > 0 ? (float)(sumDist / matchedCount) : 0f; + + int methodInterp = 0, methodXform = 0, methodMerged = 0; + if (result.targetShellMethod != null) + { + for (int i = 0; i < result.targetShellMethod.Length; i++) + { + switch (result.targetShellMethod[i]) + { + case 0: methodInterp++; break; + case 1: methodXform++; break; + case 2: methodMerged++; break; + } + } + } + + UvtLog.Info(UvtLog.Category.TransferDiag, + $"'{targetMeshName}' ← '{sourceMeshName}': " + + $"shells src={srcShells.Count} tgt={tgtShells.Count} | " + + $"matched={result.shellsMatched} unmatched={unmatched} " + + $"rejected={result.shellsRejected} | " + + $"status A={accepted}/D={degraded}/P={poor}/R={rejected}/U={unmatched} | " + + $"method interp={methodInterp} xform={methodXform} merged={methodMerged} | " + + $"fragMerged={result.fragmentsMerged} dedupConf={result.dedupConflicts} " + + $"overlapFixed={result.shellsOverlapFixed} consistFix={result.consistencyCorrected} | " + + $"matchDist mean={meanDist:F4} max={maxDist:F4} | " + + $"topo iters={result.topologyIterations} fixed={result.topologyFixed} " + + $"capHit={(result.topologyCapHit ? 1 : 0)} | " + + $"verts={result.verticesTransferred}/{result.verticesTotal}"); + } + return result; } diff --git a/Editor/UvtLog.cs b/Editor/UvtLog.cs index e45af3b8..4ba5b2ad 100644 --- a/Editor/UvtLog.cs +++ b/Editor/UvtLog.cs @@ -15,18 +15,19 @@ public enum Level { Off = 0, Error = 1, Warning = 2, Info = 3, Verbose = 4 } [System.Flags] public enum Category { - General = 1 << 0, - SymSplit = 1 << 1, - Repack = 1 << 2, - Match = 1 << 3, - Dedup = 1 << 4, - Overlap = 1 << 5, - Topology = 1 << 6, - Validation = 1 << 7, - Export = 1 << 8, - Benchmark = 1 << 9, - - All = General | SymSplit | Repack | Match | Dedup | Overlap | Topology | Validation | Export | Benchmark, + General = 1 << 0, + SymSplit = 1 << 1, + Repack = 1 << 2, + Match = 1 << 3, + Dedup = 1 << 4, + Overlap = 1 << 5, + Topology = 1 << 6, + Validation = 1 << 7, + Export = 1 << 8, + Benchmark = 1 << 9, + TransferDiag = 1 << 10, + + All = General | SymSplit | Repack | Match | Dedup | Overlap | Topology | Validation | Export | Benchmark | TransferDiag, } const string LevelPrefKey = "LightmapUvTool_LogLevel"; From 5340f67f1bc8649ef89bbb67f997ec99c31bf933 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 11:24:48 +0000 Subject: [PATCH 002/110] Drop hint-matched dedup bypass for overlapping-UV0 case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupedShellTransfer.Phase 2b had a bypass that allowed multiple hint-matched targets to share a source even when their UV0 bboxes overlapped, on the assumption "previous LOD told us all these belong on this source, interp will be valid". Under standard UV0→UV2 interp that assumption is wrong for symmetric / tiled copies whose UV0 sub-regions coincide: both targets sample identical source triangles and bake identical UV2 onto the atlas. Carousel LOD2 TransferDiag output (new in fb3233f) confirmed the failure mode with six pairs of target shells producing byte-identical UV2 fingerprints. Remove the bypass and fall through to the existing eviction sort (hint-matched → non-merged → best avg3D). The strongest claimant keeps the source; the rest queue for FindBestSourceShell with `claimed` excluded, which on tiled LODs finds the unused source siblings (e.g. LOD0 src138/139/140 stay available once src135/136/137 are taken). EXPERIMENTS.md updated with the data and rationale. --- CHANGELOG.md | 3 +++ Documentation~/EXPERIMENTS.md | 20 ++++++++++++++++++++ Editor/GroupedShellTransfer.cs | 26 +++++++++++--------------- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fad32f4..3b85bf99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - **In-flight gate + `FireAndForget` helper** for fire-and-forget async UI actions. The "Run Full Pipeline" / "Run Repack only" / "Run Transfer only" / "Repack All" / "Transfer All Targets" buttons now sit inside an `EditorGUI.DisabledScope` on `_pipelineInFlight` so a second click can't launch an interleaving run; `FireAndForget` attaches `ContinueWith` on the Unity sync context to log Task faults through `UvtLog.Error`, release the gate, and call `UvProgress.Fail` so a thrown exception can't leave the strip stuck on a stale phase. - **Cancel-aware benchmark recording** in `ExecTransferAllImpl` — mirrors the `completedSuccessfully` guard `ExecFullPipelineImpl` already uses. Cancelled transfers no longer emit stale `shellTransferResult` / validation rows that taint sweep aggregates. +### Fixed +- **Dedup bypass for hint-matched targets no longer skips overlapping-UV0 case** (`GroupedShellTransfer.cs:1511`). Previously, when ≥2 target shells claimed the same source via cross-LOD hints AND their UV0 bboxes overlapped (true tiling / symmetric copies), Phase 2b skipped eviction with `Dedup: src{N} shared by ... hint-matched targets — allowed`. Standard UV0→UV2 interp then sampled identical source triangles for every claimant and produced **identical UV2** — verified on Carousel LOD2 by `TransferDiag` fingerprints: `t62 ≡ t63`, `t64 ≡ t65`, `t61 ≡ t66` etc., bleeding lightmap data between physical instances of symmetric features. Bypass removed; eviction sort (`hint-matched → non-merged → best avg3D`) keeps the strongest claimant and rematches the rest via `FindBestSourceShell(excludeSources=claimed)`, which on tiled-UV0 LODs finds the unused source siblings. See `Documentation~/EXPERIMENTS.md` 2026-05-14 entry. + ### Changed - **Default `RepackResolutionMode` is now `AutoFromTexelDensity`** (was `Manual`). Uniform real-world texels-per-meter is the desired outcome for lightmaps; the previous fixed-resolution default produced wildly different texel density per asset depending on world size. - **xatlas pack no longer raises a modal progress dialog** (`EditorUtility.DisplayCancelableProgressBar`). Progress now flows through `UvProgress` to the Background Tasks panel and the inline strip; cancel via `UvProgress.CancelRequested`. diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 54ed2b86..c31a9d12 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -351,3 +351,23 @@ - EditMode red/green: `TransferTargetDetection_IgnoresSourceOnlySelection`. - EditMode red/green: `Uv2PixelMargin_ScalesFromResolvedAtlasSize`. - Full model benchmark (Carousel/Playground/WateringCan) в этом checkout не прогнан: тестовые FBX/`BenchmarkReports/` отсутствуют в репозитории. Нужен ручной Unity прогон на suite для финального сравнения `repackMs`, `density spread`, `overlapShellPairs`, `invertedCount`, `texelDensityBadCount`. + +## Эксперимент 2026-05-14 — Hint-matched dedup bypass для overlapping UV0 ломал симметричные копии на LOD2+ + +**Контекст:** новый `[TransferDiag]` summary log (см. `TRANSFER_LOD_QUALITY_PLAN.md`) показал на Carousel LOD2: шесть пар таргетов (`t61↔t66`, `t62↔t63`, `t64↔t65`, `t1↔t14`, `t67↔t77`, плюс ещё) имеют **идентичные UV2 fingerprints** — `t62=FF073743(0,5362,0,1666)` ≡ `t63=FF073743(0,5362,0,1666)`. Каждая пара — два разных физических инстанса симметричной фичи, забейкенные в одну и ту же область атласа. Видимый user-side эффект: «трансфер на лоды не очень хороший». + +**Корневая причина:** в `GroupedShellTransfer.Phase 2b` (`GroupedShellTransfer.cs:1511-1525` до правки) ветка `"shared by N cross-LOD hint-matched targets — allowed"` пропускала eviction, **даже если** UV0 bboxes клеймантов перекрываются. Логика: «previous LOD сказала что они все принадлежат этому source, значит interp на shared source даст валидный UV2». Это верно только для non-overlapping UV0 fragments (отдельный path выше уже его обрабатывает). Для overlapping (tiling/symmetric) случая standard UV0→UV2 interp двух таргетов читает те же самые source UV0 triangles и выдаёт идентичный UV2. + +LOG-доказательство порядка событий на LOD2: 5× `Dedup: srcX shared by 2 cross-LOD hint-matched targets — allowed` → сразу же 5× `POST-DEDUP DUPLICATE: srcX claimed by t_a(False), t_b(False)`. POST-DEDUP пост-проверка ловила дубликаты, но Phase 3 уже не имел способа их разделить. + +**Что попробовали:** удалить bypass-ветку. Eviction-сортировка ниже (`hint-matched first → non-merged first → лучший avg3D first`) сохраняется. Лучший hint-matched non-merged клеймант оставляет source; остальные уходят в `needsRematch` и через `FindBestSourceShell(excludeSources=claimed)` находят неиспользуемые source siblings (на Carousel-кейсе LOD0 имеет 6 шеллов src135-140, LOD2 — 6 таргетов, но hint-match сваливал их на src135-137; после правки src138-140 становятся доступны для t63/t65/t66). + +**Ожидание/проверка:** +- На Carousel `TransferDiag` LOD2 ожидается: больше нет `POST-DEDUP DUPLICATE`, fingerprints всех 6 таргетов разные, `dedupConf` поднимется (это OK — было «фейково 1» из-за bypass), `A=accepted` подрастёт. +- Sanity: ни один таргет не должен получить `Unmatched`/`Rejected` — FindBestSourceShell гарантированно вернёт что-то в overlap group (там 92 candidate'а). +- Regression risk: если на каком-то меше шеллов больше, чем sources (не наш случай для tiled UV0), evicted target всё равно получит ближайший по surface alternate — UV2 будет другим, но не catastrophically wrong (multiplicative normal penalty + adaptive thresholds). + +**Не пробовать ещё раз:** возвращать bypass без per-target UV0 sub-region restriction — это и был исходный баг. + +**Status:** committed in branch `claude/fix-transfer-bugs-KYVQD`, нужен прогон полного pipeline на Carousel + Playground + WateringCan для подтверждения. + diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index b2ee8764..bbe69b18 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -1508,21 +1508,17 @@ static TransferResult TransferCore( continue; } - // Cross-LOD hint protection: if multiple claimants were all - // matched via cross-LOD hints, the previous LOD told us they - // should all use this source (likely symmetric copies of the - // same feature). Allow shared source — interp per-shell from - // the same source produces valid UV2 for each copy. - int hintMatchedCount = 0; - foreach (var c in claimants) - if (tgtHintMatched[c.tsi]) hintMatchedCount++; - - if (hintMatchedCount >= 2 && hintMatchedCount == claimants.Count) - { - UvtLog.Info($"[GroupedTransfer] Dedup: src{srcKey} shared by " + - $"{claimants.Count} cross-LOD hint-matched targets — allowed"); - continue; - } + // No hint-matched bypass here for the overlapping-UV0 branch: + // two hint-matched targets covering the same UV0 sub-region + // of the same source produce identical UV2 under standard + // UV0→UV2 interp (same source triangles sampled twice). The + // eviction sort below keeps the strongest hint-matched + // non-merged claimant; the rest rematch via FindBestSourceShell + // with `claimed` excluded so tiled / symmetric LOD targets + // land on unused source siblings instead of duplicating UV2. + // + // See EXPERIMENTS.md 2026-05-14 for the data that motivated + // removing the previous bypass. // Truly overlapping UV0 (tiling/symmetric) — evict as before. // Non-merged shells get priority (they need the specific UV0→UV2 From 442f2bb75c65ae0ada6c700be5abe138fabe4fe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 11:34:12 +0000 Subject: [PATCH 003/110] EXPERIMENTS.md: post-fix Carousel verification for hint-matched dedup bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the actual TransferDiag numbers from Carousel Full Pipeline before and after the 5340f67 fix. 10 duplicate UV2 fingerprint pairs (LOD2+LOD3) went to 0; LOD2 mean match distance dropped 39%, max 40%. Status A/D/P histograms unchanged. One small regression noted (new force3D UV2 overlap on LOD3 t66↔t40, +1 topo cap-hit on LOD2) — not blocking, the duplicate UV2 was the visible user-facing bug. Also lists what stayed broken so the next experiment has a target list (force3D overlaps on ARAP-reparameterized shells, topology cap-hit cycles, sliver/degenerate output from xform on ribbons). --- Documentation~/EXPERIMENTS.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index c31a9d12..c196cb9c 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -369,5 +369,28 @@ LOG-доказательство порядка событий на LOD2: 5× `D **Не пробовать ещё раз:** возвращать bypass без per-target UV0 sub-region restriction — это и был исходный баг. -**Status:** committed in branch `claude/fix-transfer-bugs-KYVQD`, нужен прогон полного pipeline на Carousel + Playground + WateringCan для подтверждения. +**Status:** committed in branch `claude/fix-transfer-bugs-KYVQD`, верифицирован прогоном Full Pipeline на Carousel. + +### Verification (Carousel, sep=10%, internalOversample=4, atlas 1360×1492) + +| Метрика | До | После | Δ | +|---|---|---|---| +| **LOD2 duplicate UV2 pairs** | 5 (t1≡t14, t61≡t66, t62≡t63, t64≡t65, t67≡t77) | **0** | ✅ | +| LOD2 `dedupConf` | 1 (фейково) | 6 | честный счёт | +| LOD2 `matchDist mean` | 0.0741 | **0.0451** | −39% | +| LOD2 `matchDist max` | 0.7421 | **0.4445** | −40% | +| LOD2 `method merged` | 1 | 5 | eviction направил часть в forced-merged | +| LOD3 duplicate UV2 pairs | 5 | **0** | ✅ | +| LOD3 `dedupConf` | 0 | 2 | | +| LOD3 `overlapFixed` | 0 | 1 | новая force3D-overlap пара t66↔t40 | +| LOD3 `topo fixed` | 31 | 33 | +2 (vertex 410, 359, 191, 196, 11 — cap-hit) | +| LOD1 все метрики | unchanged | unchanged | LOD1 не использовал bypass (нет cross-LOD hints) | +| Status A/D/P (все LOD) | 198/16/23 | 198/16/23 | без изменений | + +**Net:** 10 пар дубликатов UV2 на LOD2+LOD3 → 0 пар (главная цель). Регресс — 1 новая force3D UV2-overlap на LOD3 (`t66 ↔ t40`) при назначении src72 после eviction, плюс 1 topo cap-hit на LOD2. Acceptable: дубликаты были видимым багом, force3D-overlap — известный fallback с warning, не тихая порча. Status A/D/P не изменился (issue counts per-shell определяются обычными tri-check'ами, не dedup'ом). + +**Что осталось вне этой PR (для следующих экспериментов):** +- 5 force3D UV2 overlaps total (4 на LOD1 t0/t6/t8/t84, 1 новая на LOD3 t66) — все на ARAP-reparameterized шеллах src130-148 с composite spatially broken fallback. H3 кандидат. +- Topology `capHit=1` на LOD1 (vertex 778, displacement disp/scale=5.10 на iter=4) и LOD3 (vertex 359/191/196 цикличны). H4 кандидат (Laplacian не сходится на этих шеллах). +- LOD1 sliver/degenerate report: 43 шелла, LOD2: 31, LOD3: 25 — это `DiagnoseCollapsedTargetShells` отлавливает результат transfer, основная причина = source LOD0 после SymSplit имеет ribbons (10 `[SpatialPartitioner] ribbon detected`); xform fallback на ribbons даёт sliver UV2. H1/H3. From 90a794003177f29c669c6095ce05328b1691b576 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 11:42:42 +0000 Subject: [PATCH 004/110] Revert "EXPERIMENTS.md: post-fix Carousel verification for hint-matched dedup bypass" This reverts commit 442f2bb75c65ae0ada6c700be5abe138fabe4fe6. --- Documentation~/EXPERIMENTS.md | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index c196cb9c..c31a9d12 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -369,28 +369,5 @@ LOG-доказательство порядка событий на LOD2: 5× `D **Не пробовать ещё раз:** возвращать bypass без per-target UV0 sub-region restriction — это и был исходный баг. -**Status:** committed in branch `claude/fix-transfer-bugs-KYVQD`, верифицирован прогоном Full Pipeline на Carousel. - -### Verification (Carousel, sep=10%, internalOversample=4, atlas 1360×1492) - -| Метрика | До | После | Δ | -|---|---|---|---| -| **LOD2 duplicate UV2 pairs** | 5 (t1≡t14, t61≡t66, t62≡t63, t64≡t65, t67≡t77) | **0** | ✅ | -| LOD2 `dedupConf` | 1 (фейково) | 6 | честный счёт | -| LOD2 `matchDist mean` | 0.0741 | **0.0451** | −39% | -| LOD2 `matchDist max` | 0.7421 | **0.4445** | −40% | -| LOD2 `method merged` | 1 | 5 | eviction направил часть в forced-merged | -| LOD3 duplicate UV2 pairs | 5 | **0** | ✅ | -| LOD3 `dedupConf` | 0 | 2 | | -| LOD3 `overlapFixed` | 0 | 1 | новая force3D-overlap пара t66↔t40 | -| LOD3 `topo fixed` | 31 | 33 | +2 (vertex 410, 359, 191, 196, 11 — cap-hit) | -| LOD1 все метрики | unchanged | unchanged | LOD1 не использовал bypass (нет cross-LOD hints) | -| Status A/D/P (все LOD) | 198/16/23 | 198/16/23 | без изменений | - -**Net:** 10 пар дубликатов UV2 на LOD2+LOD3 → 0 пар (главная цель). Регресс — 1 новая force3D UV2-overlap на LOD3 (`t66 ↔ t40`) при назначении src72 после eviction, плюс 1 topo cap-hit на LOD2. Acceptable: дубликаты были видимым багом, force3D-overlap — известный fallback с warning, не тихая порча. Status A/D/P не изменился (issue counts per-shell определяются обычными tri-check'ами, не dedup'ом). - -**Что осталось вне этой PR (для следующих экспериментов):** -- 5 force3D UV2 overlaps total (4 на LOD1 t0/t6/t8/t84, 1 новая на LOD3 t66) — все на ARAP-reparameterized шеллах src130-148 с composite spatially broken fallback. H3 кандидат. -- Topology `capHit=1` на LOD1 (vertex 778, displacement disp/scale=5.10 на iter=4) и LOD3 (vertex 359/191/196 цикличны). H4 кандидат (Laplacian не сходится на этих шеллах). -- LOD1 sliver/degenerate report: 43 шелла, LOD2: 31, LOD3: 25 — это `DiagnoseCollapsedTargetShells` отлавливает результат transfer, основная причина = source LOD0 после SymSplit имеет ribbons (10 `[SpatialPartitioner] ribbon detected`); xform fallback на ribbons даёт sliver UV2. H1/H3. +**Status:** committed in branch `claude/fix-transfer-bugs-KYVQD`, нужен прогон полного pipeline на Carousel + Playground + WateringCan для подтверждения. From aaba5ff0a6de3ed158b990aeb87481db6f0264b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 11:42:42 +0000 Subject: [PATCH 005/110] Revert "Drop hint-matched dedup bypass for overlapping-UV0 case" This reverts commit 5340f67f1bc8649ef89bbb67f997ec99c31bf933. --- CHANGELOG.md | 3 --- Documentation~/EXPERIMENTS.md | 20 -------------------- Editor/GroupedShellTransfer.cs | 26 +++++++++++++++----------- 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b85bf99..0fad32f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - **In-flight gate + `FireAndForget` helper** for fire-and-forget async UI actions. The "Run Full Pipeline" / "Run Repack only" / "Run Transfer only" / "Repack All" / "Transfer All Targets" buttons now sit inside an `EditorGUI.DisabledScope` on `_pipelineInFlight` so a second click can't launch an interleaving run; `FireAndForget` attaches `ContinueWith` on the Unity sync context to log Task faults through `UvtLog.Error`, release the gate, and call `UvProgress.Fail` so a thrown exception can't leave the strip stuck on a stale phase. - **Cancel-aware benchmark recording** in `ExecTransferAllImpl` — mirrors the `completedSuccessfully` guard `ExecFullPipelineImpl` already uses. Cancelled transfers no longer emit stale `shellTransferResult` / validation rows that taint sweep aggregates. -### Fixed -- **Dedup bypass for hint-matched targets no longer skips overlapping-UV0 case** (`GroupedShellTransfer.cs:1511`). Previously, when ≥2 target shells claimed the same source via cross-LOD hints AND their UV0 bboxes overlapped (true tiling / symmetric copies), Phase 2b skipped eviction with `Dedup: src{N} shared by ... hint-matched targets — allowed`. Standard UV0→UV2 interp then sampled identical source triangles for every claimant and produced **identical UV2** — verified on Carousel LOD2 by `TransferDiag` fingerprints: `t62 ≡ t63`, `t64 ≡ t65`, `t61 ≡ t66` etc., bleeding lightmap data between physical instances of symmetric features. Bypass removed; eviction sort (`hint-matched → non-merged → best avg3D`) keeps the strongest claimant and rematches the rest via `FindBestSourceShell(excludeSources=claimed)`, which on tiled-UV0 LODs finds the unused source siblings. See `Documentation~/EXPERIMENTS.md` 2026-05-14 entry. - ### Changed - **Default `RepackResolutionMode` is now `AutoFromTexelDensity`** (was `Manual`). Uniform real-world texels-per-meter is the desired outcome for lightmaps; the previous fixed-resolution default produced wildly different texel density per asset depending on world size. - **xatlas pack no longer raises a modal progress dialog** (`EditorUtility.DisplayCancelableProgressBar`). Progress now flows through `UvProgress` to the Background Tasks panel and the inline strip; cancel via `UvProgress.CancelRequested`. diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index c31a9d12..54ed2b86 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -351,23 +351,3 @@ - EditMode red/green: `TransferTargetDetection_IgnoresSourceOnlySelection`. - EditMode red/green: `Uv2PixelMargin_ScalesFromResolvedAtlasSize`. - Full model benchmark (Carousel/Playground/WateringCan) в этом checkout не прогнан: тестовые FBX/`BenchmarkReports/` отсутствуют в репозитории. Нужен ручной Unity прогон на suite для финального сравнения `repackMs`, `density spread`, `overlapShellPairs`, `invertedCount`, `texelDensityBadCount`. - -## Эксперимент 2026-05-14 — Hint-matched dedup bypass для overlapping UV0 ломал симметричные копии на LOD2+ - -**Контекст:** новый `[TransferDiag]` summary log (см. `TRANSFER_LOD_QUALITY_PLAN.md`) показал на Carousel LOD2: шесть пар таргетов (`t61↔t66`, `t62↔t63`, `t64↔t65`, `t1↔t14`, `t67↔t77`, плюс ещё) имеют **идентичные UV2 fingerprints** — `t62=FF073743(0,5362,0,1666)` ≡ `t63=FF073743(0,5362,0,1666)`. Каждая пара — два разных физических инстанса симметричной фичи, забейкенные в одну и ту же область атласа. Видимый user-side эффект: «трансфер на лоды не очень хороший». - -**Корневая причина:** в `GroupedShellTransfer.Phase 2b` (`GroupedShellTransfer.cs:1511-1525` до правки) ветка `"shared by N cross-LOD hint-matched targets — allowed"` пропускала eviction, **даже если** UV0 bboxes клеймантов перекрываются. Логика: «previous LOD сказала что они все принадлежат этому source, значит interp на shared source даст валидный UV2». Это верно только для non-overlapping UV0 fragments (отдельный path выше уже его обрабатывает). Для overlapping (tiling/symmetric) случая standard UV0→UV2 interp двух таргетов читает те же самые source UV0 triangles и выдаёт идентичный UV2. - -LOG-доказательство порядка событий на LOD2: 5× `Dedup: srcX shared by 2 cross-LOD hint-matched targets — allowed` → сразу же 5× `POST-DEDUP DUPLICATE: srcX claimed by t_a(False), t_b(False)`. POST-DEDUP пост-проверка ловила дубликаты, но Phase 3 уже не имел способа их разделить. - -**Что попробовали:** удалить bypass-ветку. Eviction-сортировка ниже (`hint-matched first → non-merged first → лучший avg3D first`) сохраняется. Лучший hint-matched non-merged клеймант оставляет source; остальные уходят в `needsRematch` и через `FindBestSourceShell(excludeSources=claimed)` находят неиспользуемые source siblings (на Carousel-кейсе LOD0 имеет 6 шеллов src135-140, LOD2 — 6 таргетов, но hint-match сваливал их на src135-137; после правки src138-140 становятся доступны для t63/t65/t66). - -**Ожидание/проверка:** -- На Carousel `TransferDiag` LOD2 ожидается: больше нет `POST-DEDUP DUPLICATE`, fingerprints всех 6 таргетов разные, `dedupConf` поднимется (это OK — было «фейково 1» из-за bypass), `A=accepted` подрастёт. -- Sanity: ни один таргет не должен получить `Unmatched`/`Rejected` — FindBestSourceShell гарантированно вернёт что-то в overlap group (там 92 candidate'а). -- Regression risk: если на каком-то меше шеллов больше, чем sources (не наш случай для tiled UV0), evicted target всё равно получит ближайший по surface alternate — UV2 будет другим, но не catastrophically wrong (multiplicative normal penalty + adaptive thresholds). - -**Не пробовать ещё раз:** возвращать bypass без per-target UV0 sub-region restriction — это и был исходный баг. - -**Status:** committed in branch `claude/fix-transfer-bugs-KYVQD`, нужен прогон полного pipeline на Carousel + Playground + WateringCan для подтверждения. - diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index bbe69b18..b2ee8764 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -1508,17 +1508,21 @@ static TransferResult TransferCore( continue; } - // No hint-matched bypass here for the overlapping-UV0 branch: - // two hint-matched targets covering the same UV0 sub-region - // of the same source produce identical UV2 under standard - // UV0→UV2 interp (same source triangles sampled twice). The - // eviction sort below keeps the strongest hint-matched - // non-merged claimant; the rest rematch via FindBestSourceShell - // with `claimed` excluded so tiled / symmetric LOD targets - // land on unused source siblings instead of duplicating UV2. - // - // See EXPERIMENTS.md 2026-05-14 for the data that motivated - // removing the previous bypass. + // Cross-LOD hint protection: if multiple claimants were all + // matched via cross-LOD hints, the previous LOD told us they + // should all use this source (likely symmetric copies of the + // same feature). Allow shared source — interp per-shell from + // the same source produces valid UV2 for each copy. + int hintMatchedCount = 0; + foreach (var c in claimants) + if (tgtHintMatched[c.tsi]) hintMatchedCount++; + + if (hintMatchedCount >= 2 && hintMatchedCount == claimants.Count) + { + UvtLog.Info($"[GroupedTransfer] Dedup: src{srcKey} shared by " + + $"{claimants.Count} cross-LOD hint-matched targets — allowed"); + continue; + } // Truly overlapping UV0 (tiling/symmetric) — evict as before. // Non-merged shells get priority (they need the specific UV0→UV2 From 9d38c27745da499e8ea0dc9c7a33372e573b0ab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:09:17 +0000 Subject: [PATCH 006/110] Add visual-defect counters to TransferResult + sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing sweep metrics (shellsRejected, overlapShellPairs, coverage) flagged the pipeline as "solid" across 60 cells × 4 models, but the user sees lightmap artefacts on Carousel that those metrics miss: symmetric copies sharing UV2 regions, force3D fallback overlaps, composite-broken single-source fallback, and grossly-wrong Phase 2 source picks. Add four counters that catch each: - uv2DuplicatePairs — pairs of target shells with byte-identical quantised UV2 fingerprint hashes (silent bleeding between distinct instances). Rejected/Unmatched excluded so empty-hash sharing doesn't inflate. Captured in the existing fingerprint loop. - compositeBrokenCount — Phase 3 composite-vs-best-source area check ratio >2× → fallback to single-source. Already logged; now counted. - severeMismatchCount — target shells whose chosen source is >10% of mesh diagonal away in 3D. Almost always a wrong-source assignment. - shellsOverlapFixed (pre-existing) — aliased as force3D overlap count in the sweep aggregator and HTML gallery so the row labels are clear. Sweep changes: - BenchmarkRecorder writes all 4 to CSV + JSON. - BenchmarkSweep.RunSummary carries them; AggregateRun sums across target LODs; Score() applies penalties (-50 dup, -30 force3D, -10 composite, -20 severe) chosen so each defect class has comparable weight to existing slivers/overlaps. - summary.csv, winner.json, index.html show the new columns. - TransferDiag log line gets a `DUP=/COMP=/SEVERE=` segment. - TRANSFER_BENCHMARK.md documents the new metrics + Go/Stop thresholds. No algorithm change. Lets the next refactor PR be validated by sweep delta instead of eyeballing screenshots. --- Documentation~/TRANSFER_BENCHMARK.md | 28 +++++++++++ Editor/BenchmarkRecorder.cs | 14 ++++++ Editor/BenchmarkSweep.cs | 74 +++++++++++++++++++++++++--- Editor/GroupedShellTransfer.cs | 55 ++++++++++++++++++++- 4 files changed, 164 insertions(+), 7 deletions(-) diff --git a/Documentation~/TRANSFER_BENCHMARK.md b/Documentation~/TRANSFER_BENCHMARK.md index 9cd0153b..eb78a5c7 100644 --- a/Documentation~/TRANSFER_BENCHMARK.md +++ b/Documentation~/TRANSFER_BENCHMARK.md @@ -91,12 +91,31 @@ Per-row (snapshot of `TransferResult` / `ValidationReport` / static counters): `shellsMerged`, `shellsRejected`, `shellsOverlapFixed` - `dedupConflicts`, `fragmentsMerged`, `consistencyCorrected` - `verticesTransferred`, `verticesTotal` +- `uv2DuplicatePairs`, `compositeBrokenCount`, `severeMismatchCount` + — see *Visual-defect counters* below - `invertedCount`, `stretchedCount`, `zeroAreaCount`, `oobCount`, `cleanCount` - `overlapShellPairs`, `overlapTriangleCount`, `overlapSameSrcPairs` - `texelDensityBadCount`, `texelDensityMedian` - `symSplitFallbackCount`, `symSplitTotalCount` - `topologyIterations`, `topologyFixed`, `topologyCapHit` +### Visual-defect counters + +The original `defectScore = stretched + zeroArea + oob` flagged geometric +defects, but missed the failure modes where the algorithm produces +"technically valid" UV2 that bakes wrong: + +| Metric | What it catches | +| --- | --- | +| `uv2DuplicatePairs` | Pairs of target shells whose quantised UV2 fingerprint hash matches. Non-zero = two distinct 3D instances bake onto the same atlas region (silent lightmap bleeding between symmetric copies). Rejected/Unmatched shells excluded (they legitimately share the empty hash). | +| `shellsOverlapFixed` (== force3D overlap count) | Force3D-fallback shells whose UV2 AABB overlaps a non-fallback shell's UV2 AABB. Already warned via `[GroupedTransfer] UV2 overlap: ... bleeding likely`; surfaced here as a counter. | +| `compositeBrokenCount` | Target shells whose Phase 3 composite UV2 spilled out of the matched source UV2 region (`compArea > 2× srcArea`) and were forced back to single-source fallback. Signals a Phase 2 matching miss. | +| `severeMismatchCount` | Target shells whose chosen source is >10% of mesh diagonal away in 3D. Almost always a wrong-source assignment by Phase 2 — e.g. wedge swapped with a sibling. | + +These four feed into the sweep score (`BenchmarkSweep.Score`) with weights +`-50 / -30 / -10 / -20` respectively, so refactors that drag any of them +upward lose against the previous winner. + JSON output mirrors the CSV but nests `records[]` inside a run envelope. ## Protocol @@ -232,6 +251,15 @@ list. - **Topology cap hit:** false. If true, either increase `kMaxTopologyIterations` or accept residual displacement. - **Coverage** (`verticesTransferred / verticesTotal`): >= 0.99. +- **uv2DuplicatePairs:** 0. Non-zero = silent lightmap bleeding; STOP. +- **shellsOverlapFixed (force3D overlap):** 0 on source LOD. Up to 1 + tolerable on target LODs (only on heavily-decimated geometry). +- **compositeBrokenCount:** 0 on source LOD. Up to ~5% of target shell + count tolerable; higher = Phase 2 matching is misassigning to source + shells that don't cover the target UV0 region. +- **severeMismatchCount:** 0 on source LOD. Non-zero on target LODs is a + strong signal that a wedge / sibling got swapped — investigate the + specific shells (`shellMatchDistSqr` column). ## Known models diff --git a/Editor/BenchmarkRecorder.cs b/Editor/BenchmarkRecorder.cs index cca64afa..96adc128 100644 --- a/Editor/BenchmarkRecorder.cs +++ b/Editor/BenchmarkRecorder.cs @@ -208,6 +208,9 @@ public void RecordMesh(MeshEntry entry) consistencyCorrected = tr?.consistencyCorrected ?? 0, verticesTransferred = tr?.verticesTransferred ?? 0, verticesTotal = tr?.verticesTotal ?? 0, + uv2DuplicatePairs = tr?.uv2DuplicatePairs ?? 0, + compositeBrokenCount = tr?.compositeBrokenCount ?? 0, + severeMismatchCount = tr?.severeMismatchCount ?? 0, invertedCount = v?.invertedCount ?? 0, stretchedCount = v?.stretchedCount ?? 0, @@ -321,6 +324,7 @@ string BuildCsv() "shellsMatched,shellsUnmatched,shellsTransform,shellsInterpolation,shellsMerged," + "shellsRejected,shellsOverlapFixed,dedupConflicts,fragmentsMerged,consistencyCorrected," + "verticesTransferred,verticesTotal," + + "uv2DuplicatePairs,compositeBrokenCount,severeMismatchCount," + "invertedCount,stretchedCount,zeroAreaCount,oobCount,cleanCount," + "overlapShellPairs,overlapTriangleCount,overlapSameSrcPairs," + "texelDensityBadCount,texelDensityMedian," + @@ -365,6 +369,9 @@ string BuildCsv() sb.Append(r.consistencyCorrected.ToString(inv)).Append(','); sb.Append(r.verticesTransferred.ToString(inv)).Append(','); sb.Append(r.verticesTotal.ToString(inv)).Append(','); + sb.Append(r.uv2DuplicatePairs.ToString(inv)).Append(','); + sb.Append(r.compositeBrokenCount.ToString(inv)).Append(','); + sb.Append(r.severeMismatchCount.ToString(inv)).Append(','); sb.Append(r.invertedCount.ToString(inv)).Append(','); sb.Append(r.stretchedCount.ToString(inv)).Append(','); sb.Append(r.zeroAreaCount.ToString(inv)).Append(','); @@ -439,6 +446,9 @@ string BuildJson() AppendJsonKv(sb, "consistencyCorrected", r.consistencyCorrected); sb.Append(", "); AppendJsonKv(sb, "verticesTransferred", r.verticesTransferred); sb.Append(", "); AppendJsonKv(sb, "verticesTotal", r.verticesTotal); sb.Append(", "); + AppendJsonKv(sb, "uv2DuplicatePairs", r.uv2DuplicatePairs); sb.Append(", "); + AppendJsonKv(sb, "compositeBrokenCount", r.compositeBrokenCount); sb.Append(", "); + AppendJsonKv(sb, "severeMismatchCount", r.severeMismatchCount); sb.Append(", "); AppendJsonKv(sb, "invertedCount", r.invertedCount); sb.Append(", "); AppendJsonKv(sb, "stretchedCount", r.stretchedCount); sb.Append(", "); AppendJsonKv(sb, "zeroAreaCount", r.zeroAreaCount); sb.Append(", "); @@ -524,6 +534,10 @@ public class RunRecord public int shellsRejected, shellsOverlapFixed, dedupConflicts, fragmentsMerged, consistencyCorrected; public int verticesTransferred, verticesTotal; + // Visual-defect counters surfaced for sweep scoring. See + // GroupedShellTransfer.TransferResult for field semantics. + public int uv2DuplicatePairs, compositeBrokenCount, severeMismatchCount; + public int invertedCount, stretchedCount, zeroAreaCount, oobCount, cleanCount; public int overlapShellPairs, overlapTriangleCount, overlapSameSrcPairs; public int texelDensityBadCount; diff --git a/Editor/BenchmarkSweep.cs b/Editor/BenchmarkSweep.cs index 7ea6abc8..5025b6e9 100644 --- a/Editor/BenchmarkSweep.cs +++ b/Editor/BenchmarkSweep.cs @@ -28,6 +28,16 @@ internal static class BenchmarkSweep const float kPenaltyOverlap = -10f; const float kPenaltyMs = -0.001f; const float kPenaltyResolution = -10f; + // Visual-defect weights. duplicate UV2 pairs are the silent killer — + // two distinct instances baking onto the same atlas region. Weighted + // as heavy as a sliver because the user-visible effect is comparable. + // force3D overlaps are explicit bleeding (already warned via + // shellsOverlapFixed). composite-broken and severe-mismatch are + // matching-quality signals — lighter penalty. + const float kPenaltyDupUv2 = -50f; + const float kPenaltyForce3DOL = -30f; + const float kPenaltyCompBroken = -10f; + const float kPenaltySevere = -20f; /// /// Snapshot of the ctx fields that distinguish one sweep cell from @@ -60,6 +70,11 @@ internal struct RunSummary public long totalMs; // sum(pipeline+repack+transfer+validate) public float score; public bool hadFailure; + // Visual-defect counters, summed across target LODs. + public int uv2DuplicatePairs; + public int force3DOverlapCount; // sum of shellsOverlapFixed + public int compositeBrokenCount; + public int severeMismatchCount; } /// @@ -202,8 +217,12 @@ internal static void WriteAggregateReport(List csvPaths, List /// Failed runs (hadFailure=true) get -∞ so they never win the sweep. /// @@ -216,6 +235,10 @@ internal static float Score(RunSummary r) return kWeightUtilization * r.meanAtlasUtilization + kPenaltySliver * r.totalSlivers + kPenaltyOverlap * r.overlapShellPairs + + kPenaltyDupUv2 * r.uv2DuplicatePairs + + kPenaltyForce3DOL * r.force3DOverlapCount + + kPenaltyCompBroken * r.compositeBrokenCount + + kPenaltySevere * r.severeMismatchCount + kPenaltyMs * r.totalMs + resPenalty; } @@ -263,8 +286,13 @@ int idx(string col) int iValidate = idx("validateMs"); int iShellsMatch = idx("shellsMatched"); int iVertsXfer = idx("verticesTransferred"); + int iDupUv2 = idx("uv2DuplicatePairs"); + int iForce3DOL = idx("shellsOverlapFixed"); + int iCompBroken = idx("compositeBrokenCount"); + int iSevere = idx("severeMismatchCount"); int slivers = 0, overlap = 0; + int dupUv2 = 0, force3DOL = 0, compBroken = 0, severe = 0; int utilCount = 0; float utilSum = 0f; long totalMs = 0; @@ -291,6 +319,10 @@ int idx(string col) slivers += SafeInt(c, iInverted) + SafeInt(c, iStretched) + SafeInt(c, iZero) + SafeInt(c, iOob); overlap += SafeInt(c, iOverlap); + dupUv2 += SafeInt(c, iDupUv2); + force3DOL += SafeInt(c, iForce3DOL); + compBroken += SafeInt(c, iCompBroken); + severe += SafeInt(c, iSevere); // A target-LOD row with zero shells matched AND zero // vertices transferred means the transfer never ran (or @@ -340,6 +372,10 @@ int idx(string col) summary.totalSlivers = slivers; summary.overlapShellPairs = overlap; + summary.uv2DuplicatePairs = dupUv2; + summary.force3DOverlapCount = force3DOL; + summary.compositeBrokenCount = compBroken; + summary.severeMismatchCount = severe; summary.meanAtlasUtilization = utilCount > 0 ? utilSum / utilCount : 0f; summary.totalMs = totalMs; // No target-LOD rows means transfer never produced anything — @@ -399,7 +435,9 @@ internal static void WriteSummaryCsv(string path, List runs, string var inv = CultureInfo.InvariantCulture; var sb = new StringBuilder(); sb.AppendLine("atlasRes,shellPad,borderPad,arapEnabled,arapIterations,stretchThreshold," + - "totalSlivers,overlapShellPairs,meanAtlasUtilization,totalMs,score,csvPath"); + "totalSlivers,overlapShellPairs," + + "uv2DuplicatePairs,force3DOverlapCount,compositeBrokenCount,severeMismatchCount," + + "meanAtlasUtilization,totalMs,score,csvPath"); foreach (var r in runs) { // Make csvPath relative to BenchmarkReports/ when possible — @@ -420,6 +458,10 @@ internal static void WriteSummaryCsv(string path, List runs, string sb.Append(r.config.stretchThreshold.ToString("R", inv)).Append(','); sb.Append(r.totalSlivers.ToString(inv)).Append(','); sb.Append(r.overlapShellPairs.ToString(inv)).Append(','); + sb.Append(r.uv2DuplicatePairs.ToString(inv)).Append(','); + sb.Append(r.force3DOverlapCount.ToString(inv)).Append(','); + sb.Append(r.compositeBrokenCount.ToString(inv)).Append(','); + sb.Append(r.severeMismatchCount.ToString(inv)).Append(','); sb.Append(r.meanAtlasUtilization.ToString("R", inv)).Append(','); sb.Append(r.totalMs.ToString(inv)).Append(','); sb.Append(r.score.ToString("R", inv)).Append(','); @@ -447,6 +489,10 @@ internal static void WriteWinnerJson(string path, List runs, int bes sb.Append(" \"score\": ").Append(JsonFloat(w.score, inv)).Append(",\n"); sb.Append(" \"totalSlivers\": ").Append(w.totalSlivers.ToString(inv)).Append(",\n"); sb.Append(" \"overlapShellPairs\": ").Append(w.overlapShellPairs.ToString(inv)).Append(",\n"); + sb.Append(" \"uv2DuplicatePairs\": ").Append(w.uv2DuplicatePairs.ToString(inv)).Append(",\n"); + sb.Append(" \"force3DOverlapCount\": ").Append(w.force3DOverlapCount.ToString(inv)).Append(",\n"); + sb.Append(" \"compositeBrokenCount\": ").Append(w.compositeBrokenCount.ToString(inv)).Append(",\n"); + sb.Append(" \"severeMismatchCount\": ").Append(w.severeMismatchCount.ToString(inv)).Append(",\n"); sb.Append(" \"meanAtlasUtilization\": ").Append(JsonFloat(w.meanAtlasUtilization, inv)).Append(",\n"); sb.Append(" \"totalMs\": ").Append(w.totalMs.ToString(inv)).Append(",\n"); sb.Append(" \"csvPath\": ").Append(JsonString(w.csvPath ?? "")).Append("\n"); @@ -458,6 +504,10 @@ internal static void WriteWinnerJson(string path, List runs, int bes sb.Append(" \"atlasUtilizationWeight\": ").Append(kWeightUtilization.ToString("R", inv)).Append(",\n"); sb.Append(" \"sliverPenalty\": ").Append(kPenaltySliver.ToString("R", inv)).Append(",\n"); sb.Append(" \"overlapPenalty\": ").Append(kPenaltyOverlap.ToString("R", inv)).Append(",\n"); + sb.Append(" \"dupUv2Penalty\": ").Append(kPenaltyDupUv2.ToString("R", inv)).Append(",\n"); + sb.Append(" \"force3DOverlapPenalty\": ").Append(kPenaltyForce3DOL.ToString("R", inv)).Append(",\n"); + sb.Append(" \"compositeBrokenPenalty\": ").Append(kPenaltyCompBroken.ToString("R", inv)).Append(",\n"); + sb.Append(" \"severeMismatchPenalty\": ").Append(kPenaltySevere.ToString("R", inv)).Append(",\n"); sb.Append(" \"msPenalty\": ").Append(kPenaltyMs.ToString("R", inv)).Append(",\n"); sb.Append(" \"resolutionPenalty\": ").Append(kPenaltyResolution.ToString("R", inv)).Append("\n"); sb.Append(" },\n"); @@ -475,6 +525,10 @@ internal static void WriteWinnerJson(string path, List runs, int bes sb.Append("\"stretchThreshold\": ").Append(r.config.stretchThreshold.ToString("R", inv)).Append(", "); sb.Append("\"totalSlivers\": ").Append(r.totalSlivers.ToString(inv)).Append(", "); sb.Append("\"overlapShellPairs\": ").Append(r.overlapShellPairs.ToString(inv)).Append(", "); + sb.Append("\"uv2DuplicatePairs\": ").Append(r.uv2DuplicatePairs.ToString(inv)).Append(", "); + sb.Append("\"force3DOverlapCount\": ").Append(r.force3DOverlapCount.ToString(inv)).Append(", "); + sb.Append("\"compositeBrokenCount\": ").Append(r.compositeBrokenCount.ToString(inv)).Append(", "); + sb.Append("\"severeMismatchCount\": ").Append(r.severeMismatchCount.ToString(inv)).Append(", "); sb.Append("\"meanAtlasUtilization\": ").Append(JsonFloat(r.meanAtlasUtilization, inv)).Append(", "); sb.Append("\"totalMs\": ").Append(r.totalMs.ToString(inv)).Append(", "); sb.Append("\"score\": ").Append(JsonFloat(r.score, inv)).Append(", "); @@ -559,9 +613,13 @@ internal static void WriteGalleryHtml(string path, List runs, int be sb.Append(" stretchThr\n"); sb.Append(" slivers\n"); sb.Append(" overlap\n"); - sb.Append(" atlas%\n"); - sb.Append(" ms\n"); - sb.Append(" score\n"); + sb.Append(" dupUV2\n"); + sb.Append(" f3DOL\n"); + sb.Append(" compBr\n"); + sb.Append(" 10% mesh diagonal\">severe\n"); + sb.Append(" atlas%\n"); + sb.Append(" ms\n"); + sb.Append(" score\n"); sb.Append(" UV2 thumbs\n"); sb.Append(" \n"); sb.Append(" \n"); @@ -581,6 +639,10 @@ internal static void WriteGalleryHtml(string path, List runs, int be sb.Append(" ").Append(r.config.stretchThreshold.ToString("F2", inv)).Append("\n"); sb.Append(" ").Append(r.totalSlivers.ToString(inv)).Append("\n"); sb.Append(" ").Append(r.overlapShellPairs.ToString(inv)).Append("\n"); + sb.Append(" ").Append(r.uv2DuplicatePairs.ToString(inv)).Append("\n"); + sb.Append(" ").Append(r.force3DOverlapCount.ToString(inv)).Append("\n"); + sb.Append(" ").Append(r.compositeBrokenCount.ToString(inv)).Append("\n"); + sb.Append(" ").Append(r.severeMismatchCount.ToString(inv)).Append("\n"); sb.Append(" ").Append((r.meanAtlasUtilization * 100f).ToString("F2", inv)).Append("\n"); sb.Append(" ").Append(r.totalMs.ToString(inv)).Append("\n"); sb.Append(" ").Append(r.score.ToString("F2", inv)).Append("\n"); diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index b2ee8764..aef4ad21 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -101,6 +101,22 @@ public class TransferResult public int shellsRejected; // shells where UV2 was not written (too many issues) public int shellsOverlapFixed; // force3D shells relocated due to UV2 overlap + // ─── Visual-defect counters (added for sweep visibility) ─── + // Captures failure modes that the existing solid-pipeline metrics + // (shellsRejected, overlapShellPairs, coverage) miss but the user + // sees on the rendered atlas. See TRANSFER_BENCHMARK.md. + /// Pairs of target shells whose quantised UV2 fingerprint hash matches. + /// Non-zero = two distinct 3D instances bake onto the same atlas region + /// (lightmap data shared between unrelated geometry). + public int uv2DuplicatePairs; + /// Target shells whose Phase 3 composite UV2 spilled out of the matched + /// source UV2 region (compArea > 2× srcArea) and were forced back to a + /// single-source fallback. Signals a Phase 2 matching miss. + public int compositeBrokenCount; + /// Target shells whose chosen source is >10% of mesh diagonal away + /// in 3D — almost always a wrong-source assignment by Phase 2. + public int severeMismatchCount; + // ─── Topology enforcement snapshot (per-target, captured by Transfer) ─── // Copied from LastTopology* immediately after EnforceShellTopologyOnUv2 // so multi-mesh runs don't all read the final target's global values. @@ -1777,6 +1793,24 @@ static TransferResult TransferCore( } } + // Severe-mismatch count — target shells whose Phase 2 chose a source + // more than 10% of mesh diagonal away in 3D. On well-matched LODs this + // is 0; non-zero almost always means a wrong assignment (wedge swapped + // with sibling, or unrelated nearby shell stole the slot). Exposed as + // a sweep metric so refactors that drag matchDist up are caught. + { + float severeThresholdSq = (meshDiagonal * 0.1f) * (meshDiagonal * 0.1f); + int severe = 0; + for (int tsi = 0; tsi < tgtShells.Count; tsi++) + { + if (result.targetShellToSourceShell[tsi] < 0) continue; + float dsq = result.targetShellMatchDistSqr[tsi]; + if (dsq >= float.MaxValue || float.IsInfinity(dsq)) continue; + if (dsq > severeThresholdSq) severe++; + } + result.severeMismatchCount = severe; + } + // Post-dedup diagnostic: check for remaining same-source duplicates. // Distinguish allowed shared-source fragments (non-overlapping UV0) // from true duplicates (overlapping UV0 — tiling/symmetric). @@ -2366,6 +2400,7 @@ static TransferResult TransferCore( if (bestSrcUv2Area > 1e-8f && compArea > bestSrcUv2Area * 2.0f) { compositeSpatiallyBroken = true; + result.compositeBrokenCount++; UvtLog.Info($"[GroupedTransfer] t{tsi}: composite spatially broken " + $"(compArea={compArea:F6} > 2×srcArea={bestSrcUv2Area:F6}), " + $"falling back to single-source"); @@ -3539,10 +3574,15 @@ static TransferResult TransferCore( // Per-shell UV2 fingerprint: hash of UV2 values for cross-branch comparison. // Logs centroid + hash so users can diff logs between branches to find - // which specific shells produce different UV2. + // which specific shells produce different UV2. Also counts duplicate + // hashes — two target shells with byte-identical UV2 layouts indicate + // two distinct 3D instances baking onto the same atlas region (e.g. + // symmetric copies that share UV2 — silent lightmap bleeding). { var fpSb = new System.Text.StringBuilder(); fpSb.Append($"[GroupedTransfer] UV2 fingerprint '{targetMeshName}':"); + var hashFirstSeen = new Dictionary(tgtShells.Count); + int dupPairs = 0; for (int tsi2 = 0; tsi2 < tgtShells.Count; tsi2++) { var shell = tgtShells[tsi2]; @@ -3564,8 +3604,20 @@ static TransferResult TransferCore( } } if (cnt > 0) + { fpSb.Append($" t{tsi2}={hash:X8}({sumX / cnt:F4},{sumY / cnt:F4})"); + // Skip Rejected/Unmatched — those legitimately share an "empty" + // hash (vertices left at 0,0) and would inflate the duplicate + // count without representing real bleeding. + var status = result.targetShellStatus[tsi2]; + if (status != ShellStatus.Rejected && status != ShellStatus.Unmatched) + { + if (hashFirstSeen.ContainsKey(hash)) dupPairs++; + else hashFirstSeen[hash] = tsi2; + } + } } + result.uv2DuplicatePairs = dupPairs; UvtLog.Info(fpSb.ToString()); } @@ -3734,6 +3786,7 @@ static TransferResult TransferCore( $"method interp={methodInterp} xform={methodXform} merged={methodMerged} | " + $"fragMerged={result.fragmentsMerged} dedupConf={result.dedupConflicts} " + $"overlapFixed={result.shellsOverlapFixed} consistFix={result.consistencyCorrected} | " + + $"DUP={result.uv2DuplicatePairs} COMP={result.compositeBrokenCount} SEVERE={result.severeMismatchCount} | " + $"matchDist mean={meanDist:F4} max={maxDist:F4} | " + $"topo iters={result.topologyIterations} fixed={result.topologyFixed} " + $"capHit={(result.topologyCapHit ? 1 : 0)} | " + From 14145e2f844a42afe99ec18644d8439f45d1bb72 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:16:03 +0000 Subject: [PATCH 007/110] =?UTF-8?q?Add=20multi-case=20sweep=20runner=20?= =?UTF-8?q?=E2=80=94=20one=20click,=20every=20model=20in=20TestSuiteAsset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing Run Sweep gates on whatever LODGroup the operator dragged into the tool — to cover the four canonical models (Carousel/Playground/ WateringCan/Wooden_Box_Long) the operator had to manually swap the ObjectField and rerun four times. Easy to forget; easy to skip a model that the next refactor regresses. New Run Multi-Case button (Setup → Parameter Sweep, right of Run Sweep) iterates every TestSuiteAsset.cases[]: 1. LoadAssetAtPath on the case's fbxAsset → InstantiatePrefab into the scene, marked HideFlags.DontSave so the spawn doesn't trip the dirty-scene flag. 2. Resolve LODGroup via lodGroupPath if set, else GetComponentInChildren. 3. ctx.Refresh + OnRefresh → tool now points at the spawned model. 4. ExecSweep with a per-case sweepDir = BenchmarkReports/sweep__ - void ExecSweep(TestSuiteAsset.SweepMatrix sm) + void ExecSweep(TestSuiteAsset.SweepMatrix sm) => ExecSweep(sm, null); + + void ExecSweep(TestSuiteAsset.SweepMatrix sm, string sweepDirOverride) { if (ctx.LodGroup == null || sm == null) return; var resArr = (sm.atlasResolutions != null && sm.atlasResolutions.Length > 0) @@ -1500,12 +1516,20 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) // operator kicked off two sweeps in the same second (scripted // runs, quick UI re-clicks). Without ms the second sweep would // overwrite the first one's summary.csv / winner.json. - string sweepStamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss_fff", - System.Globalization.CultureInfo.InvariantCulture); - string projectRoot = System.IO.Directory.GetParent(Application.dataPath)?.FullName - ?? Application.dataPath; - string sweepDir = System.IO.Path.Combine(projectRoot, "BenchmarkReports", - $"sweep_{sweepStamp}"); + string sweepDir; + if (!string.IsNullOrEmpty(sweepDirOverride)) + { + sweepDir = sweepDirOverride; + } + else + { + string sweepStamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss_fff", + System.Globalization.CultureInfo.InvariantCulture); + string projectRoot = System.IO.Directory.GetParent(Application.dataPath)?.FullName + ?? Application.dataPath; + sweepDir = System.IO.Path.Combine(projectRoot, "BenchmarkReports", + $"sweep_{sweepStamp}"); + } try { System.IO.Directory.CreateDirectory(sweepDir); } catch (Exception ex) { @@ -1664,6 +1688,169 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) } } + /// + /// Iterate every in the suite, + /// instantiate its fbxAsset into a temporary scene root, wire the + /// resolved LODGroup into the tool, run the full sweep matrix, then + /// destroy the instance. Each case writes its artefacts into a dedicated + /// sweep_<ts>_<model>/ subdirectory under + /// BenchmarkReports/, so per-model summary/winner stay separated + /// and the run-level lodGroup column in each CSV identifies the + /// model for cross-model pandas joins. + /// + /// Existing scene state is preserved: the original ctx.LodGroup + /// is restored on exit, and every spawned root is destroyed in a + /// finally block so a thrown cell or a user cancel doesn't leak + /// GameObjects. + /// + void ExecMultiCaseSweep(TestSuiteAsset suite) + { + if (suite == null || suite.sweep == null) return; + if (suite.cases == null || suite.cases.Count == 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + "[MultiSweep] Suite has no cases — nothing to do."); + return; + } + + // Snapshot the operator-bound LODGroup so the multi-case loop's + // ctx.Refresh calls don't leave the editor pointing at a destroyed + // temporary instance when the loop ends or is cancelled. + var origLodGroup = ctx.LodGroup; + + string runStamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss_fff", + System.Globalization.CultureInfo.InvariantCulture); + string projectRoot = System.IO.Directory.GetParent(Application.dataPath)?.FullName + ?? Application.dataPath; + string baseDir = System.IO.Path.Combine(projectRoot, "BenchmarkReports"); + + int caseCount = suite.cases.Count; + int doneCases = 0; + bool overallCancelled = false; + UvProgress.Begin($"Multi-Case Sweep ({caseCount} models)", cancelable: true); + try + { + for (int ci = 0; ci < caseCount; ci++) + { + if (UvProgress.CancelRequested) { overallCancelled = true; break; } + var tc = suite.cases[ci]; + if (tc == null || tc.fbxAsset == null) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[MultiSweep] Case {ci}: null FBX, skipping."); + continue; + } + + string fbxPath = AssetDatabase.GetAssetPath(tc.fbxAsset); + if (string.IsNullOrEmpty(fbxPath)) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[MultiSweep] Case {ci} '{tc.label}': asset has no project path, skipping."); + continue; + } + + var prefabRoot = AssetDatabase.LoadAssetAtPath(fbxPath); + if (prefabRoot == null) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[MultiSweep] Case {ci} '{tc.label}': '{fbxPath}' is not a GameObject prefab, skipping."); + continue; + } + + UvProgress.Report((float)ci / caseCount, + $"case {ci + 1}/{caseCount}: {tc.label} ({System.IO.Path.GetFileName(fbxPath)})"); + + GameObject spawned = null; + try + { + spawned = (GameObject)PrefabUtility.InstantiatePrefab(prefabRoot); + if (spawned == null) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[MultiSweep] Case {ci} '{tc.label}': InstantiatePrefab returned null, skipping."); + continue; + } + spawned.name = $"[MultiSweep] {tc.label}"; + // DontSave so the temporary spawn doesn't mark the scene + // dirty and survive into Ctrl+S — the multi-case sweep is + // a transient operation, not an authored edit. + spawned.hideFlags = HideFlags.DontSave; + + // Resolve LODGroup: explicit path first, then first found. + LODGroup lg = null; + if (!string.IsNullOrEmpty(tc.lodGroupPath)) + { + var t = spawned.transform.Find(tc.lodGroupPath); + if (t != null) lg = t.GetComponent(); + } + if (lg == null) lg = spawned.GetComponentInChildren(true); + if (lg == null) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[MultiSweep] Case {ci} '{tc.label}': no LODGroup found under '{fbxPath}', skipping."); + continue; + } + + ctx.Refresh(lg); + OnRefresh(); + + // Per-case subdirectory so summary/winner per model stay + // separated. lodGroup name is also recorded in every CSV + // row by BenchmarkRecorder, so pandas joins still work + // if the operator chooses to merge across cases. + string safeLabel = SanitizeForPath(string.IsNullOrEmpty(tc.label) ? lg.name : tc.label); + string caseDir = System.IO.Path.Combine(baseDir, + $"sweep_{runStamp}_{safeLabel}"); + + ExecSweep(suite.sweep, caseDir); + doneCases++; + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[MultiSweep] Case {ci} '{tc.label}' threw: {ex.Message}"); + } + finally + { + if (spawned != null) + { + // Clear ctx reference before destruction so any + // straggling UI repaint or cache lookup doesn't + // touch a half-destroyed LODGroup. + if (ctx.LodGroup != null && ctx.LodGroup.gameObject == spawned) + ctx.Refresh(null); + UnityEngine.Object.DestroyImmediate(spawned); + } + } + + if (UvProgress.CancelRequested) { overallCancelled = true; break; } + } + } + finally + { + if (overallCancelled) UvProgress.Cancel(); else UvProgress.End(); + // Restore the operator's original wiring. + ctx.Refresh(origLodGroup); + OnRefresh(); + UvtLog.Info(UvtLog.Category.Benchmark, + $"[MultiSweep] complete: {doneCases}/{caseCount} cases" + + (overallCancelled ? " (cancelled)" : "") + + $". Per-case reports in BenchmarkReports/sweep_{runStamp}_*/"); + } + } + + /// Filesystem-safe slug for a path component — letters, digits, + /// '-', '_' kept; everything else collapsed to '_'. Falls back to + /// "case" for null / empty input so the directory always has a name. + static string SanitizeForPath(string s) + { + if (string.IsNullOrEmpty(s)) return "case"; + var sb = new System.Text.StringBuilder(s.Length); + foreach (char c in s) + sb.Append(char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '_'); + return sb.ToString(); + } + /// /// Prompts the user for a BenchmarkReports/ folder and asks /// to reconstruct From 5a673045bf7d73da564d8cb57268f984c71b4f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:39:39 +0000 Subject: [PATCH 008/110] =?UTF-8?q?Address=20Codex=20review=20on=20PR=20#1?= =?UTF-8?q?18:=201=C3=97P1=20+=204=C3=97P2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — RestoreWorkingMeshes before LODGroup switch (LightmapTransferTool.cs:1718) ExecMultiCaseSweep was calling ctx.Refresh on each spawned case without first restoring the operator's original working meshes. If repacked/transferred meshes were live on the original LODGroup, MeshEntries got wiped while the temp meshes stayed assigned in-scene and the FBX baseline references were lost — reloading the original group at the end would then treat the temps as the new baseline. Call ResetWorkingCopies() once at the start of the multi-case loop (only when origLodGroup is non-null) so the AGENTS.md LODGroup lifecycle invariant is honoured. P2 — Per-case sweep dirs collision-proof (LightmapTransferTool.cs:1816) Two cases with labels that sanitise to the same slug (e.g. "Chair A" and "Chair/A" both collapsing to "Chair_A") shared a directory and overwrote each other's summary.csv / winner.json / index.html. Prefix the case index in the dir name: sweep___ + static Shell3D[] ExtractShells(Face3D[] faces, int[] canonicalTris, float thresholdDeg, int[] faceToShellOut) { int n = faces.Length; if (n == 0) return new Shell3D[0]; // Adjacency: edge → face list. Edge key packs (min(va,vb), max). + // Degenerate faces (area==0) contribute no edges — they remain + // singleton "roots" but get filtered to faceToShellOut = -1 below. var edgeFaces = new Dictionary>(n * 3); void AddEdge(int va, int vb, int face) { @@ -419,7 +548,8 @@ void AddEdge(int va, int vb, int face) } for (int f = 0; f < n; f++) { - int v0 = tris[f * 3], v1 = tris[f * 3 + 1], v2 = tris[f * 3 + 2]; + if (faces[f].area <= 0f) continue; + int v0 = canonicalTris[f * 3], v1 = canonicalTris[f * 3 + 1], v2 = canonicalTris[f * 3 + 2]; AddEdge(v0, v1, f); AddEdge(v1, v2, f); AddEdge(v2, v0, f); } @@ -459,6 +589,11 @@ void Union(int a, int b) for (int f = 0; f < n; f++) { + if (faces[f].area <= 0f) + { + faceToShellOut[f] = -1; + continue; + } int r = Find(f); if (!rootToShell.TryGetValue(r, out int si)) { @@ -503,19 +638,37 @@ void Union(int a, int b) return shells; } - // ─── Classification (probe v3 logic, lifted) ───────────────── + // ─── Shell-level classification (PR-2.5) ───────────────────── - static ClassifyResult ClassifyFineFace(Face3D fine, Shell3D[] shells, Options opts, float meshDiag) + /// + /// Decide what to do with a fine-LOD shell: overlay onto a parent base + /// shell, promote it as its own atlas domain, or skip (no atlas slot). + /// + /// Selection rules (in order): + /// 1. Find best parent (K-nearest by centroid, then min angle). + /// 2. If shell is angularly aligned (≤ overlayAngleDeg) AND lies in + /// parent's plane (perpNorm ≤ overlayPerpNorm) AND fits within + /// parent's planar extent (with overlayExtentSlack tolerance) → + /// Overlay. The fine shell's faces will read the parent's atlas + /// texels, so a wall-mounted sign inherits the wall's lightmap. + /// 3. If shell is small (area < skipAreaFrac × totalDeepArea AND + /// face count ≤ skipMaxFaceCount) → Skip. Handles, fasteners, + /// geometric noise — wasting atlas space on them is pointless. + /// 4. Otherwise → Promote. The shell becomes its own domain. + /// + static ShellDecision ClassifyFineShell(Shell3D fine, Shell3D[] baseShells, + Options opts, float meshDiag, float totalDeepArea) { - if (shells.Length == 0) return new ClassifyResult { promote = true, parentShellIdx = -1 }; + if (baseShells.Length == 0) + return new ShellDecision { kind = ShellDecisionKind.Promote, parentBaseShellIdx = -1 }; - // K nearest by centroid distance. - int K = Mathf.Min(opts.shellSearchK, shells.Length); + // K nearest base shells by centroid distance. + int K = Mathf.Min(opts.shellSearchK, baseShells.Length); var top = new (float dsq, int si)[K]; for (int k = 0; k < K; k++) top[k] = (float.MaxValue, -1); - for (int si = 0; si < shells.Length; si++) + for (int si = 0; si < baseShells.Length; si++) { - float dsq = (fine.centroid - shells[si].centroid).sqrMagnitude; + float dsq = (fine.centroid - baseShells[si].centroid).sqrMagnitude; if (dsq >= top[K - 1].dsq) continue; int pos = K - 1; while (pos > 0 && top[pos - 1].dsq > dsq) @@ -526,130 +679,83 @@ static ClassifyResult ClassifyFineFace(Face3D fine, Shell3D[] shells, Options op top[pos] = (dsq, si); } - // Among K-nearest, pick the one with smallest angle to fine.normal. + // Among K-nearest, pick the one with smallest angle to fine.dominantNormal. int bestShell = -1; float bestAngle = float.MaxValue; for (int k = 0; k < K; k++) { int si = top[k].si; if (si < 0) break; - float dot = Vector3.Dot(fine.normal, shells[si].dominantNormal); + float dot = Vector3.Dot(fine.dominantNormal, baseShells[si].dominantNormal); if (dot > 1f) dot = 1f; if (dot < -1f) dot = -1f; float ang = Mathf.Acos(dot) * Mathf.Rad2Deg; if (ang < bestAngle) { bestAngle = ang; bestShell = si; } } - if (bestShell < 0) return new ClassifyResult { promote = true, parentShellIdx = -1 }; - - // Three-criterion promotion check (probe v3 default thresholds). - float ratio = shells[bestShell].totalArea > 1e-12f - ? fine.area / shells[bestShell].totalArea - : float.PositiveInfinity; - float perpAbs = Mathf.Abs(Vector3.Dot(fine.centroid - shells[bestShell].centroid, - shells[bestShell].dominantNormal)); - float perpNorm = perpAbs / meshDiag; - - bool promote = bestAngle > opts.promoteAngleDeg - || ratio > opts.promoteRatio - || perpNorm > opts.promotePerpNorm; - return new ClassifyResult { promote = promote, parentShellIdx = bestShell }; - } - // ─── Cluster promoted fine faces into mini-shells ──────────── + // Skip-criteria — applied BEFORE promote so genuinely tiny noise + // disappears even when no parent fits. A handle's normals are + // chaotic enough that no base shell will be within overlayAngleDeg. + float areaFrac = fine.totalArea / totalDeepArea; + bool tinyArea = areaFrac < opts.skipAreaFrac; + bool fewFaces = fine.faceCount <= opts.skipMaxFaceCount; + if (tinyArea && fewFaces) + return new ShellDecision { kind = ShellDecisionKind.Skip, parentBaseShellIdx = -1 }; - /// - /// Union-find on the subset of mesh faces listed in - /// , with the same normal-threshold rule as - /// . Returns one - /// per connected component. - /// - static List ClusterFaces(Face3D[] faces, int[] tris, - List faceMask, float thresholdDeg) - { - int m = faceMask.Count; - if (m == 0) return new List(); - // Quick membership lookup. - var inMask = new HashSet(faceMask); + if (bestShell < 0) + return new ShellDecision { kind = ShellDecisionKind.Promote, parentBaseShellIdx = -1 }; - // Build edge → mask-face list (only counting mask faces). - var edgeFaces = new Dictionary>(m * 3); - void AddEdge(int va, int vb, int face) + // Overlay test — alignment + planar inclusion + extent fit. + if (bestAngle <= opts.overlayAngleDeg) { - long key = va < vb - ? ((long)va << 32) | (uint)vb - : ((long)vb << 32) | (uint)va; - if (!edgeFaces.TryGetValue(key, out var list)) + var parent = baseShells[bestShell]; + float perpAbs = Mathf.Abs(Vector3.Dot(fine.centroid - parent.centroid, + parent.dominantNormal)); + float perpNorm = perpAbs / meshDiag; + if (perpNorm <= opts.overlayPerpNorm) { - list = new List(2); - edgeFaces[key] = list; - } - list.Add(face); - } - foreach (int f in faceMask) - { - int v0 = tris[f * 3], v1 = tris[f * 3 + 1], v2 = tris[f * 3 + 2]; - AddEdge(v0, v1, f); AddEdge(v1, v2, f); AddEdge(v2, v0, f); - } - - // Union-find indexed by original face index (only mask subset is touched). - var parent = new Dictionary(m); - foreach (int f in faceMask) parent[f] = f; - int Find(int x) - { - while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } - return x; - } - void Union(int a, int b) - { - int ra = Find(a), rb = Find(b); - if (ra != rb) parent[ra] = rb; - } - float thresholdCos = Mathf.Cos(thresholdDeg * Mathf.Deg2Rad); - foreach (var kv in edgeFaces) - { - var list = kv.Value; - if (list.Count < 2) continue; - for (int i = 0; i < list.Count; i++) - for (int j = i + 1; j < list.Count; j++) + // Project fine shell's centroid onto parent basis to verify + // it sits inside parent's planar rectangle (with slack). + Vector3 d = fine.centroid - parent.centroid; + float du = Mathf.Abs(Vector3.Dot(d, parent.basisU)); + float dv = Mathf.Abs(Vector3.Dot(d, parent.basisV)); + float boundU = parent.extentU * (1f + opts.overlayExtentSlack); + float boundV = parent.extentV * (1f + opts.overlayExtentSlack); + // Also account for fine shell's own extent — if even the + // closest corner of fine sticks past parent's bounds, abort. + float fitU = du + fine.extentU; + float fitV = dv + fine.extentV; + if (fitU <= boundU && fitV <= boundV) { - if (!inMask.Contains(list[i]) || !inMask.Contains(list[j])) continue; - float d = Vector3.Dot(faces[list[i]].normal, faces[list[j]].normal); - if (d >= thresholdCos) Union(list[i], list[j]); + return new ShellDecision + { + kind = ShellDecisionKind.Overlay, + parentBaseShellIdx = bestShell, + }; } - } - - // Aggregate per root. - var byRoot = new Dictionary(); - foreach (int f in faceMask) - { - int r = Find(f); - if (!byRoot.TryGetValue(r, out var cluster)) - { - cluster = new PromotedCluster(); - byRoot[r] = cluster; } - cluster.faceIndices.Add(f); - cluster.totalArea += faces[f].area; - cluster.centroid += faces[f].centroid * faces[f].area; - cluster.dominantNormal += faces[f].normal * faces[f].area; } - var result = new List(byRoot.Count); - foreach (var c in byRoot.Values) + + return new ShellDecision { kind = ShellDecisionKind.Promote, parentBaseShellIdx = bestShell }; + } + + /// Wrap a fine-LOD shell into a + /// for the unified domain table. The shell already has area-weighted + /// plane data + extents — just copy fields and tag the source LOD. + static PromotedCluster MakeClusterFromShell(Shell3D shell, int sourceLodIndex) + { + return new PromotedCluster { - if (c.totalArea > 1e-12f) - { - c.centroid /= c.totalArea; - var nn = c.dominantNormal / c.totalArea; - float mn = nn.magnitude; - c.dominantNormal = mn > 1e-12f ? nn / mn : Vector3.up; - } - else { c.dominantNormal = Vector3.up; } - ComputePlaneBasis(c.dominantNormal, out c.basisU, out c.basisV); - ComputeExtents(faces, c.faceIndices, c.centroid, c.basisU, c.basisV, - out c.extentU, out c.extentV); - result.Add(c); - } - return result; + faceIndices = shell.faceIndices, + centroid = shell.centroid, + dominantNormal = shell.dominantNormal, + basisU = shell.basisU, + basisV = shell.basisV, + extentU = shell.extentU, + extentV = shell.extentV, + totalArea = shell.totalArea, + sourceLodIndex = sourceLodIndex, + }; } // ─── Plane basis + extents ─────────────────────────────────── @@ -729,7 +835,7 @@ static LightingDomain MakeDomainFromCluster(PromotedCluster c) /// uniform texels-per-world-unit derived from the max domain. The rect is /// then normalized to [0,1]² and stored in . /// - /// PR-2.5 swaps this for xatlas via XatlasNative. The contract is the same: + /// PR-2.6 swaps this for xatlas via XatlasNative. The contract is the same: /// after the call, every domain has a valid uv2Rect and atlasW/H are the /// final dimensions. /// @@ -822,12 +928,16 @@ static void DryRun() } string reportPath = WriteDryRunReport(lg.name, result); LogDryRunSummary(lg.name, result); + int denom = Mathf.Max(1, result.totalFineFaces); EditorUtility.DisplayDialog("Hierarchical Atlas Dry-Run", $"Build complete.\n\nDomains: {result.domains.Length} " + $"({result.baseShellCount} base + {result.promotedClusterCount} promoted)\n" + $"Atlas: {result.atlasPixelWidth}×{result.atlasPixelHeight}px\n" + - $"Promotion: {result.promotedFineFaces}/{result.totalFineFaces} " + - $"({100f * result.promotedFineFaces / Mathf.Max(1, result.totalFineFaces):F1}%)\n\n" + + $"Fine faces: {result.totalFineFaces}\n" + + $" promoted: {result.promotedFineFaces} ({100f * result.promotedFineFaces / denom:F1}%)\n" + + $" overlaid: {result.overlaidFineFaces} ({100f * result.overlaidFineFaces / denom:F1}%)\n" + + $" skipped: {result.skippedFineFaces} ({100f * result.skippedFineFaces / denom:F1}%)\n" + + $" degenerate: {result.degenerateFineFaces}\n\n" + $"Report: {reportPath}\n\nSee console for details.", "OK"); } @@ -837,15 +947,17 @@ static void LogDryRunSummary(string lgName, Result r) sb.AppendLine(); sb.AppendLine($"[HierRepack] Dry-run on '{lgName}':"); sb.AppendLine($" domains: {r.domains.Length} total " + - $"({r.baseShellCount} base / {r.promotedClusterCount} promoted clusters)"); + $"({r.baseShellCount} base / {r.promotedClusterCount} promoted shells)"); sb.AppendLine($" atlas: {r.atlasPixelWidth} × {r.atlasPixelHeight} px " + - $"(naive strip packer — PR-2.5 will swap in xatlas)"); - float promPct = 100f * r.promotedFineFaces / Mathf.Max(1, r.totalFineFaces); - sb.AppendLine($" fine faces: {r.totalFineFaces} total, " + - $"{r.promotedFineFaces} promoted ({promPct:F1}%)"); - - // Per-LOD assignment audit — how many of each LOD's faces got placed - // into base shells vs promoted clusters vs unassigned. + $"(naive strip packer — PR-2.6 will swap in xatlas)"); + int denom = Mathf.Max(1, r.totalFineFaces); + sb.AppendLine($" fine faces: {r.totalFineFaces} total"); + sb.AppendLine($" promoted: {r.promotedFineFaces,6} ({100f * r.promotedFineFaces / denom,5:F1}%)"); + sb.AppendLine($" overlaid: {r.overlaidFineFaces,6} ({100f * r.overlaidFineFaces / denom,5:F1}%)"); + sb.AppendLine($" skipped: {r.skippedFineFaces,6} ({100f * r.skippedFineFaces / denom,5:F1}%)"); + sb.AppendLine($" degenerate: {r.degenerateFineFaces,6} (filtered before shell extraction)"); + + // Per-LOD assignment audit — how many of each LOD's faces went where. for (int li = 0; li < r.faceToDomain.Length; li++) { var arr = r.faceToDomain[li]; @@ -858,8 +970,10 @@ static void LogDryRunSummary(string lgName, Result r) else if (idx < r.baseShellCount) baseCount++; else promCount++; } + // base = native shell (deepest LOD) or overlaid onto a base + // (fine LODs); promoted = own atlas domain; skip/degen = -1. sb.AppendLine($" LOD{li}: {arr.Length,5} faces " + - $"base={baseCount,5} promoted={promCount,5} unassigned={miss}"); + $"base/overlay={baseCount,5} promoted={promCount,5} skip/degen={miss}"); } // Top-K largest and smallest domains by pixel area — useful for From 888ed22ca7afe57aaf437c58727d14ddfc9c5b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 14:55:48 +0000 Subject: [PATCH 018/110] PR-2.5 hotfix: return rewritten tris from BuildCanonicalIndices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildCanonicalIndices was returning a per-vertex canonical map (length = mesh.vertices.Length), but ExtractShells indexed it as canonicalTris[f*3+k] expecting per-triangle-corner layout (length = mesh.triangles.Length). On any mesh where tris.Length > vertices.Length (i.e. almost every mesh with shared vertices — a cube has 8 verts but 36 triangle indices) this threw IndexOutOfRangeException. Fix: rewrite tris through the canonical map and return the rewritten array directly. Callers now get an array they can index as before. --- Editor/HierarchicalRepack.cs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs index b1d380f6..5dccdfef 100644 --- a/Editor/HierarchicalRepack.cs +++ b/Editor/HierarchicalRepack.cs @@ -457,24 +457,24 @@ static Face3D[] BuildFaceData(Mesh mesh, Transform xform, float meshDiag, } /// Quantize world-space vertex positions onto a grid of cell - /// size (meshDiag × 1e-5) and return per-vertex canonical indices - /// (one ID per occupied cell). Adjacent triangles that share a 3D - /// edge but reference different mesh.vertices entries (UV/normal - /// seam duplicates) collapse onto the same canonical edge. + /// size (meshDiag × 1e-5) and return a rewritten triangle-index array + /// where each corner references the canonical ID of its grid cell. + /// Adjacent triangles that share a 3D edge but reference different + /// mesh.vertices entries (UV/normal seam duplicates) collapse onto + /// the same canonical edge. Output length == tris.Length. static int[] BuildCanonicalIndices(Vector3[] worldVerts, int[] tris, float meshDiag) { int vn = worldVerts.Length; + // Per-vertex canonical ID; -1 until assigned. var canonical = new int[vn]; - // Dead-vert default: -1 (won't be referenced by any tri). for (int i = 0; i < vn; i++) canonical[i] = -1; float cell = Mathf.Max(meshDiag, 1f) * 1e-5f; float invCell = 1f / cell; - // Grid → first canonical ID assigned to that cell. var grid = new Dictionary(vn); int next = 0; - // Only canonicalize vertices that are actually used by triangles — - // unused mesh.vertices entries (common in stripped meshes) would + // Only canonicalize vertices actually used by triangles — unused + // mesh.vertices entries (common in stripped meshes) would // otherwise pollute the grid. for (int t = 0; t < tris.Length; t++) { @@ -495,7 +495,12 @@ static int[] BuildCanonicalIndices(Vector3[] worldVerts, int[] tris, float meshD } canonical[vi] = id; } - return canonical; + // Rewrite tris through the canonical map so the caller can index + // it directly as canonicalTris[f*3 + k]. + var rewritten = new int[tris.Length]; + for (int t = 0; t < tris.Length; t++) + rewritten[t] = canonical[tris[t]]; + return rewritten; } static float ComputeMeshDiagonal(Mesh mesh, Transform xform) From fb51c30e64c6dd92d1f46ed7e9724fb5c71715fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:13:06 +0000 Subject: [PATCH 019/110] =?UTF-8?q?Address=20Codex=20review=20on=20PR=20#1?= =?UTF-8?q?18:=201=C3=97P2=20fix=20in=20HierarchicalRepack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildCanonicalIndices packed three quantized world-coord components into a single long with 21 bits each (kx & 0x1FFFFF). For a mesh with diagonal 1m the cell size is 1e-5m, so any vertex with |world coord| > ~10m wraps around 2²¹ and collides with an unrelated vertex. Streamed or world-offset content (a 1m prop placed at world position 50,0,0) would canonicalize distant verts to the same cell ID, falsely fusing unrelated faces into the same shell and corrupting the per-shell classification. Fix: swap the packed long key for a (long, long, long) ValueTuple — no lossy truncation, dictionary handles equality + hashing natively. --- Editor/HierarchicalRepack.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs index 5dccdfef..751de858 100644 --- a/Editor/HierarchicalRepack.cs +++ b/Editor/HierarchicalRepack.cs @@ -471,7 +471,10 @@ static int[] BuildCanonicalIndices(Vector3[] worldVerts, int[] tris, float meshD float cell = Mathf.Max(meshDiag, 1f) * 1e-5f; float invCell = 1f / cell; - var grid = new Dictionary(vn); + // Use a ValueTuple key so coords aren't bit-packed (21-bit packing + // wraps for meshes far from world origin — a 1m-diag mesh at world + // position 50m generates kx ≈ 5e6, well past 2²¹ ≈ 2M). + var grid = new Dictionary<(long, long, long), int>(vn); int next = 0; // Only canonicalize vertices actually used by triangles — unused // mesh.vertices entries (common in stripped meshes) would @@ -481,13 +484,9 @@ static int[] BuildCanonicalIndices(Vector3[] worldVerts, int[] tris, float meshD int vi = tris[t]; if (canonical[vi] >= 0) continue; var p = worldVerts[vi]; - long kx = (long)Mathf.Floor(p.x * invCell); - long ky = (long)Mathf.Floor(p.y * invCell); - long kz = (long)Mathf.Floor(p.z * invCell); - // Pack (kx, ky, kz) into 64 bits — 21 bits each, signed-shifted. - long key = ((kx & 0x1FFFFFL) << 42) - | ((ky & 0x1FFFFFL) << 21) - | (kz & 0x1FFFFFL); + var key = ((long)Mathf.Floor(p.x * invCell), + (long)Mathf.Floor(p.y * invCell), + (long)Mathf.Floor(p.z * invCell)); if (!grid.TryGetValue(key, out int id)) { id = next++; From d592c99e750832f1151d56442e4bae6360eab0c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:17:13 +0000 Subject: [PATCH 020/110] =?UTF-8?q?Address=20Codex=20review=20on=20PR=20#1?= =?UTF-8?q?18:=201=C3=97P1=20+=203=C3=97P2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupedShellTransfer.cs (P2): uv2 duplicate-pair hash now sorts the quantized UV positions lexicographically before feeding them into FNV. Previously the hash was computed in vertexIndices iteration order, and vertexIndices was populated from HashSet upstream, so two shells with the same UV2 layout could hash differently purely from hash-set iteration order and miss the duplicate count. HierarchicalDiag.cs (P2): probe now canonicalizes vertex indices via position-grid dedup (cell = meshDiag × 1e-5) before building shell adjacency, mirroring the same step in HierarchicalRepack (PR-2.5). Without this the probe's shell count diverged from the repack pipeline's once PR-2.5 added dedup there, breaking the GO/STOP signal. LightmapTransferTool.cs (P1): multi-case sweep cleanup guard now uses Transform.IsChildOf instead of GameObject equality, so the temp-mesh cleanup also runs when the resolved LODGroup sits on a descendant of the spawned prefab root (a common hierarchy layout). The equality check missed that case, leaking the last cell's repackedMesh / transferredMesh clones into the editor process across multi-case runs. LightmapTransferTool.cs (P2): sweep cell metadata (CellConfig.internal Oversample) now stores the clamped value (max(1, raw)) that actually ran, matching ctx.InternalOversample. Storing the raw suite input made summary/winner artefacts misrepresent the run configuration when a suite contained 0 or negative entries. --- Editor/GroupedShellTransfer.cs | 25 ++++++++---- Editor/HierarchicalDiag.cs | 60 ++++++++++++++++++++++++---- Editor/Tools/LightmapTransferTool.cs | 19 ++++++++- 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index 1ae1d174..085373b6 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -3623,22 +3623,31 @@ static TransferResult TransferCore( continue; } var shell = tgtShells[tsi2]; - uint hash = 2166136261u; - int cnt = 0; + // Hash the SET of quantized UV2 positions, not the order + // we happen to visit verts in. vertexIndices was populated + // from a HashSet upstream, so two shells with the + // same UV2 layout but different HashSet iteration order + // would otherwise hash differently and miss the duplicate. + var quantized = new List<(int qx, int qy)>(shell.vertexIndices.Length); foreach (int vi in shell.vertexIndices) { if (vi >= result.uv2.Length) continue; var uv = result.uv2[vi]; - cnt++; - int qx = Mathf.RoundToInt(uv.x * 100000f); - int qy = Mathf.RoundToInt(uv.y * 100000f); + quantized.Add((Mathf.RoundToInt(uv.x * 100000f), + Mathf.RoundToInt(uv.y * 100000f))); + } + if (quantized.Count == 0) continue; + quantized.Sort((a, b) => a.qx != b.qx ? a.qx.CompareTo(b.qx) + : a.qy.CompareTo(b.qy)); + uint hash = 2166136261u; + foreach (var p in quantized) + { unchecked { - hash = (hash ^ (uint)qx) * 16777619u; - hash = (hash ^ (uint)qy) * 16777619u; + hash = (hash ^ (uint)p.qx) * 16777619u; + hash = (hash ^ (uint)p.qy) * 16777619u; } } - if (cnt == 0) continue; hashGroupSize.TryGetValue(hash, out int k); hashGroupSize[hash] = k + 1; } diff --git a/Editor/HierarchicalDiag.cs b/Editor/HierarchicalDiag.cs index 61d5635b..355b57f7 100644 --- a/Editor/HierarchicalDiag.cs +++ b/Editor/HierarchicalDiag.cs @@ -134,14 +134,15 @@ public static string ProbeLodGroup(LODGroup lg) var deepMesh = arr[deepestIdx].GetComponent()?.sharedMesh; if (deepMesh == null) { groupsSkipped++; continue; } - var deepFaces = BuildFaceData(deepMesh, arr[deepestIdx].transform); - var deepShells = ExtractShells(deepFaces, deepMesh.triangles, deepMesh.vertexCount); - // World-space mesh diagonal of the deepest LOD — used to - // normalize perpendicular distances so the threshold is - // scale-invariant (5 cm means very different things on a - // 0.5 m prop vs a 20 m building). + // World-space mesh diagonal first so canonicalization (used + // by ExtractShells to dedup seam-split verts) and per-fine + // probing share the same scale reference. float meshDiag = ComputeMeshDiagonal(deepMesh, arr[deepestIdx].transform); if (meshDiag < 1e-6f) meshDiag = 1f; // safety: avoid div-by-zero + + var deepFaces = BuildFaceData(deepMesh, arr[deepestIdx].transform); + var deepCanonical = BuildCanonicalTris(deepMesh, arr[deepestIdx].transform, meshDiag); + var deepShells = ExtractShells(deepFaces, deepCanonical, deepMesh.vertexCount); bool any = false; for (int li = 0; li < deepestIdx; li++) { @@ -220,6 +221,46 @@ static float ComputeMeshDiagonal(Mesh mesh, Transform xform) return (hi - lo).magnitude; } + /// Quantize world-space vertex positions onto a grid + /// (cell = meshDiag × 1e-5) and rewrite the mesh's triangle index + /// array so each corner references its canonical (grid-cell) ID. + /// Mirrors HierarchicalRepack.BuildCanonicalIndices so the + /// probe's shell counts agree with the repack pipeline's — without + /// dedup, Unity UV/normal seam splits fragment continuous surfaces + /// into many single-tri shells and distort the GO/STOP signal. + static int[] BuildCanonicalTris(Mesh mesh, Transform xform, float meshDiag) + { + var localVerts = mesh.vertices; + var tris = mesh.triangles; + int vn = localVerts.Length; + var canonical = new int[vn]; + for (int i = 0; i < vn; i++) canonical[i] = -1; + + float cell = Mathf.Max(meshDiag, 1f) * 1e-5f; + float invCell = 1f / cell; + var grid = new Dictionary<(long, long, long), int>(vn); + int next = 0; + for (int t = 0; t < tris.Length; t++) + { + int vi = tris[t]; + if (canonical[vi] >= 0) continue; + var p = xform.TransformPoint(localVerts[vi]); + var key = ((long)Mathf.Floor(p.x * invCell), + (long)Mathf.Floor(p.y * invCell), + (long)Mathf.Floor(p.z * invCell)); + if (!grid.TryGetValue(key, out int id)) + { + id = next++; + grid[key] = id; + } + canonical[vi] = id; + } + var rewritten = new int[tris.Length]; + for (int t = 0; t < tris.Length; t++) + rewritten[t] = canonical[tris[t]]; + return rewritten; + } + /// /// Read mesh.vertices once, transform to world space, derive per-tri /// centroid/normal/area. Returns a flat array indexed by tri index. @@ -254,8 +295,11 @@ static FaceData[] BuildFaceData(Mesh mesh, Transform xform) /// (two vertex indices in common). For each shell, compute area-weighted /// dominant normal and centroid + total area. The shell index for each /// face is returned in . + /// must reference position-deduplicated + /// vertex IDs (see ) so seam-split + /// vertices in Unity meshes don't fragment a single physical surface. /// - static ShellData[] ExtractShells(FaceData[] faces, int[] tris, int vertexCount) + static ShellData[] ExtractShells(FaceData[] faces, int[] canonicalTris, int vertexCount) { int n = faces.Length; if (n == 0) return new ShellData[0]; @@ -277,7 +321,7 @@ void AddEdge(int va, int vb, int face) } for (int f = 0; f < n; f++) { - int v0 = tris[f * 3], v1 = tris[f * 3 + 1], v2 = tris[f * 3 + 2]; + int v0 = canonicalTris[f * 3], v1 = canonicalTris[f * 3 + 1], v2 = canonicalTris[f * 3 + 2]; AddEdge(v0, v1, f); AddEdge(v1, v2, f); AddEdge(v2, v0, f); } diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 0203eec6..16046b42 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -1628,6 +1628,13 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm, string sweepDirOverride) ? csvAfter : null; writtenCsvPaths.Add(csvPath); + // Record the SAME clamped oversample value + // used at ctx.InternalOversample for the run + // (see ≈40 lines above). Storing the raw + // suite value here would make summary/winner + // metadata disagree with the actual run + // configuration whenever a suite contains + // 0 or negative entries. cellConfigs.Add(new BenchmarkSweep.CellConfig { atlasRes = r, @@ -1636,7 +1643,7 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm, string sweepDirOverride) arapEnabled = arapIters > 0, arapIterations = arapIters, stretchThreshold = stretchThr, - internalOversample = oversample, + internalOversample = oversample > 0 ? oversample : 1, symSplitMode = symMode, }); done++; @@ -1907,7 +1914,15 @@ void ExecMultiCaseSweep(TestSuiteAsset suite) // the temp meshes (Object.Instantiate clones, not // children of `spawned`) leak — repeated multi-case // runs accumulate them and eventually hit editor OOM. - if (ctx.LodGroup != null && ctx.LodGroup.gameObject == spawned) + // Use IsChildOf instead of an equality check so the + // guard still triggers when the LODGroup sits on a + // descendant of `spawned` (common prefab layout — + // prefab root holds rendering bounds, LODGroup on + // a child geometry container). Transform.IsChildOf + // returns true for itself, so the root case is + // still covered. + if (ctx.LodGroup != null + && ctx.LodGroup.transform.IsChildOf(spawned.transform)) { ResetWorkingCopies(); ctx.Refresh(null); From f183cb79d766c6b42add9c4ea1d9f9bca26be681 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:27:32 +0000 Subject: [PATCH 021/110] PR-2.5: dry-run handles multi-select + prefab assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously DryRun() only looked at Selection.activeGameObject and bailed unless exactly one scene object resolved a LODGroup ancestor. Now CollectSelectedLodGroups() iterates Selection.gameObjects and resolves each: - Scene GameObject → GetComponentInParent - Project-window prefab asset → LoadAssetAtPath + GetComponentInChildren Each unique LODGroup gets its own Build + CSV in BenchmarkReports/. A cancellable progress bar shows progress on multi-select; the final dialog reports per-model success/failure with per-model CSV paths. Read-only — no mesh/prefab mutation, no Undo group needed. --- Editor/HierarchicalRepack.cs | 133 +++++++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 22 deletions(-) diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs index 751de858..7cc7e4f3 100644 --- a/Editor/HierarchicalRepack.cs +++ b/Editor/HierarchicalRepack.cs @@ -910,39 +910,128 @@ static void PackAtlasNaive(LightingDomain[] domains, Options opts, const string DryRunMenuPath = "Mesh Lab/Diag/Hierarchical Atlas Dry-Run"; [MenuItem(DryRunMenuPath, true)] - static bool ValidateDryRun() - => Selection.activeGameObject?.GetComponentInParent() != null; + static bool ValidateDryRun() => CollectSelectedLodGroups().Count > 0; [MenuItem(DryRunMenuPath)] static void DryRun() { - var lg = Selection.activeGameObject?.GetComponentInParent(); - if (lg == null) + var lgs = CollectSelectedLodGroups(); + if (lgs.Count == 0) { EditorUtility.DisplayDialog("Hierarchical Atlas Dry-Run", - "Select a GameObject under a LODGroup first.", "OK"); + "Select one or more GameObjects under a LODGroup first.\n" + + "Prefab assets in the Project window also work.", "OK"); return; } - var result = Build(lg, Options.Default); - if (!string.IsNullOrEmpty(result.error)) + + var opts = Options.Default; + var failures = new List(); + int ok = 0; + string lastReport = null; + Result lastResult = null; + string lastName = null; + for (int i = 0; i < lgs.Count; i++) { + var lg = lgs[i]; + if (lgs.Count > 1) + { + if (EditorUtility.DisplayCancelableProgressBar( + "Hierarchical Atlas Dry-Run", + $"[{i + 1}/{lgs.Count}] {lg.name}", + (float)i / lgs.Count)) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] Batch dry-run cancelled at {i}/{lgs.Count}."); + break; + } + } + try + { + var result = Build(lg, opts); + if (!string.IsNullOrEmpty(result.error)) + { + failures.Add($"{lg.name}: {result.error}"); + continue; + } + string reportPath = WriteDryRunReport(lg.name, result); + LogDryRunSummary(lg.name, result); + lastReport = reportPath; + lastResult = result; + lastName = lg.name; + ok++; + } + catch (Exception ex) + { + failures.Add($"{lg.name}: {ex.Message}"); + UvtLog.Error(UvtLog.Category.Benchmark, + $"[HierRepack] Dry-run threw on '{lg.name}': {ex}"); + } + } + EditorUtility.ClearProgressBar(); + + // Single-LODGroup: detailed dialog like before. Batch: terse summary + // pointing the operator at the per-model CSVs in BenchmarkReports/. + if (lgs.Count == 1 && lastResult != null) + { + int denom = Mathf.Max(1, lastResult.totalFineFaces); EditorUtility.DisplayDialog("Hierarchical Atlas Dry-Run", - $"Build failed: {result.error}", "OK"); - return; + $"Build complete on '{lastName}'.\n\n" + + $"Domains: {lastResult.domains.Length} " + + $"({lastResult.baseShellCount} base + {lastResult.promotedClusterCount} promoted)\n" + + $"Atlas: {lastResult.atlasPixelWidth}×{lastResult.atlasPixelHeight}px\n" + + $"Fine faces: {lastResult.totalFineFaces}\n" + + $" promoted: {lastResult.promotedFineFaces} ({100f * lastResult.promotedFineFaces / denom:F1}%)\n" + + $" overlaid: {lastResult.overlaidFineFaces} ({100f * lastResult.overlaidFineFaces / denom:F1}%)\n" + + $" skipped: {lastResult.skippedFineFaces} ({100f * lastResult.skippedFineFaces / denom:F1}%)\n" + + $" degenerate: {lastResult.degenerateFineFaces}\n\n" + + $"Report: {lastReport}\n\nSee console for details.", "OK"); } - string reportPath = WriteDryRunReport(lg.name, result); - LogDryRunSummary(lg.name, result); - int denom = Mathf.Max(1, result.totalFineFaces); - EditorUtility.DisplayDialog("Hierarchical Atlas Dry-Run", - $"Build complete.\n\nDomains: {result.domains.Length} " + - $"({result.baseShellCount} base + {result.promotedClusterCount} promoted)\n" + - $"Atlas: {result.atlasPixelWidth}×{result.atlasPixelHeight}px\n" + - $"Fine faces: {result.totalFineFaces}\n" + - $" promoted: {result.promotedFineFaces} ({100f * result.promotedFineFaces / denom:F1}%)\n" + - $" overlaid: {result.overlaidFineFaces} ({100f * result.overlaidFineFaces / denom:F1}%)\n" + - $" skipped: {result.skippedFineFaces} ({100f * result.skippedFineFaces / denom:F1}%)\n" + - $" degenerate: {result.degenerateFineFaces}\n\n" + - $"Report: {reportPath}\n\nSee console for details.", "OK"); + else + { + string failBlock = failures.Count == 0 + ? "" + : "\n\nFailures:\n " + string.Join("\n ", failures); + EditorUtility.DisplayDialog("Hierarchical Atlas Dry-Run", + $"Batch complete: {ok}/{lgs.Count} models succeeded.\n\n" + + $"Per-model CSVs in BenchmarkReports/ (one file per LODGroup).\n" + + $"See console for per-model summaries." + failBlock, + "OK"); + } + } + + /// Resolve every LODGroup reachable from the current Selection: + /// scene GameObjects walk up via GetComponentInParent; Project-window + /// prefab assets are loaded and searched via GetComponentInChildren. + /// Deduplicated by LODGroup instance so selecting multiple children of + /// the same LODGroup doesn't process it twice. + static List CollectSelectedLodGroups() + { + var result = new List(); + var seen = new HashSet(); + var sel = Selection.gameObjects; + if (sel == null || sel.Length == 0) return result; + foreach (var go in sel) + { + if (go == null) continue; + // Scene object — climb to the nearest LODGroup ancestor. + var lg = go.GetComponentInParent(); + if (lg == null) + { + // Project-window asset path: load the prefab and search + // its hierarchy (GetComponentInParent on a root prefab + // asset still returns null, but GetComponentInChildren + // walks descendants). + string assetPath = AssetDatabase.GetAssetPath(go); + if (!string.IsNullOrEmpty(assetPath)) + { + var loaded = AssetDatabase.LoadAssetAtPath(assetPath); + if (loaded != null) lg = loaded.GetComponentInChildren(true); + } + } + if (lg != null && seen.Add(lg)) result.Add(lg); + } + return result; + } } static void LogDryRunSummary(string lgName, Result r) From a68ee55e9b2c54289e46d09a23d5f51380bb8a00 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:52:33 +0000 Subject: [PATCH 022/110] PR-2.5 hotfix: drop stray closing brace after CollectSelectedLodGroups A leftover '}' between CollectSelectedLodGroups() and LogDryRunSummary() closed the class scope prematurely, so LogDryRunSummary, WriteDryRunReport and Sanitize ended up outside the class. csc reported the unbalance at the namespace's final brace (CS1022). --- Editor/HierarchicalRepack.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs index 7cc7e4f3..e415bcc1 100644 --- a/Editor/HierarchicalRepack.cs +++ b/Editor/HierarchicalRepack.cs @@ -1032,7 +1032,6 @@ static List CollectSelectedLodGroups() } return result; } - } static void LogDryRunSummary(string lgName, Result r) { From 9bbc8e9b6266121d9adb1f201a3f188e89f62153 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 16:00:57 +0000 Subject: [PATCH 023/110] PR-2.5 hotfix: HashSet uses .Count, not .Length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UvShell.vertexIndices is HashSet, not int[] — the capacity hint in the sort-then-hash refactor must use .Count. --- Editor/GroupedShellTransfer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index 085373b6..051ddd39 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -3628,7 +3628,7 @@ static TransferResult TransferCore( // from a HashSet upstream, so two shells with the // same UV2 layout but different HashSet iteration order // would otherwise hash differently and miss the duplicate. - var quantized = new List<(int qx, int qy)>(shell.vertexIndices.Length); + var quantized = new List<(int qx, int qy)>(shell.vertexIndices.Count); foreach (int vi in shell.vertexIndices) { if (vi >= result.uv2.Length) continue; From 8ed332577f382805e67e0b7bcab7f340e1b4e4a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 16:39:25 +0000 Subject: [PATCH 024/110] =?UTF-8?q?PR-2.5:=20drop=20K-nearest=20filter=20i?= =?UTF-8?q?n=20ClassifyFineShell=20=E2=80=94=20full=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The K-nearest-by-centroid prefilter was inherited from probe v3's per-FACE classifier and made sense there (a single triangle picks its parent from local candidates). For SHELL-level matching it produces a pathological failure: a small detail on the edge of a large flat surface (e.g. a 0.05m roof-border ornament on a 2m gazebo roof) has the roof's centroid 2m away, while a dozen tiny neighbouring shells sit 0.1m away. K=10 then samples only the close clutter — the roof never enters bestAngle computation — so overlay can never fire and the detail gets promoted instead. Confirmed on Gazebo via offline FBX parse: LOD2 has two huge roof shells (12 faces, area=25 each, normal=±Y, extent=2.13×0.80) plus 136 small decoration shells. LOD0's same roof shells are 144 faces with extent 2.30×0.80 — easily inside the 10% slack — but per-fine-shell classification only saw the clutter and promoted those big roof faces. Fix: full O(N base × M fine) scan for min-angle parent. Worst test case is Carousel ~270 × ~600 = 160k float dots, microseconds. opts.shellSearchK is left in the Options struct for the per-face probe but is no longer read by HierarchicalRepack. Expected effect on baseline (PR-2.5 measured): Gazebo overlay 17% → ~40-50% Carousel overlay 21% → ~35-45% Wooden overlay 21% → ~30-40% Playground overlay 37% → ~50-55% --- Editor/HierarchicalRepack.cs | 43 ++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs index e415bcc1..ff0f7748 100644 --- a/Editor/HierarchicalRepack.cs +++ b/Editor/HierarchicalRepack.cs @@ -74,9 +74,10 @@ public struct Options /// detail. Scale-invariant. public float promotePerpNorm; - /// Number of nearest shells to consider per fine face when picking - /// best-angle parent. K=10 from probe v2 — 1st-nearest by centroid is - /// unreliable on curved deepest LODs. + /// Legacy K-nearest filter from probe v3's per-face classifier. + /// PR-2.5 shell-level matching uses a full scan instead — see notes + /// in ClassifyFineShell. Kept in Options for forward compat + /// with any per-face diagnostics that still rely on it. public int shellSearchK; /// Target atlas resolution (pixels). Final atlas may be slightly @@ -666,30 +667,24 @@ static ShellDecision ClassifyFineShell(Shell3D fine, Shell3D[] baseShells, if (baseShells.Length == 0) return new ShellDecision { kind = ShellDecisionKind.Promote, parentBaseShellIdx = -1 }; - // K nearest base shells by centroid distance. - int K = Mathf.Min(opts.shellSearchK, baseShells.Length); - var top = new (float dsq, int si)[K]; - for (int k = 0; k < K; k++) top[k] = (float.MaxValue, -1); - for (int si = 0; si < baseShells.Length; si++) - { - float dsq = (fine.centroid - baseShells[si].centroid).sqrMagnitude; - if (dsq >= top[K - 1].dsq) continue; - int pos = K - 1; - while (pos > 0 && top[pos - 1].dsq > dsq) - { - top[pos] = top[pos - 1]; - pos--; - } - top[pos] = (dsq, si); - } - - // Among K-nearest, pick the one with smallest angle to fine.dominantNormal. + // Full scan for the best-angle base shell across ALL base shells. + // + // The previous K-nearest-by-centroid filter was inherited from probe v3's + // per-FACE classifier, where it made sense — a fine triangle near a + // particular point should pick its parent from local candidates. But + // shell-level matching has a pathological failure mode: a small detail + // on the EDGE of a large flat surface (e.g. a roof border ornament on + // a 2m gazebo roof) has the roof's centroid 2m away, while a dozen + // tiny neighbouring shells sit 0.1m away. K-nearest=10 then never + // even SEES the roof — bestAngle is computed only among the close + // clutter shells whose normals are unaligned, so overlay never fires + // and the detail gets promoted. Full-scan is O(N base × M fine) and + // for our sizes (~300 × ~600 on the worst test model) is negligible + // (~200k float dots, microseconds). int bestShell = -1; float bestAngle = float.MaxValue; - for (int k = 0; k < K; k++) + for (int si = 0; si < baseShells.Length; si++) { - int si = top[k].si; - if (si < 0) break; float dot = Vector3.Dot(fine.dominantNormal, baseShells[si].dominantNormal); if (dot > 1f) dot = 1f; if (dot < -1f) dot = -1f; From 5cc70d282b4974655ada509c4400b169c7fd34b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 17:18:24 +0000 Subject: [PATCH 025/110] PR-2.5 Step 1b: pick best overlay-eligible parent, not just best angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 (8ed3325) replaced K-nearest with a full-scan that picked the single best-angle base shell across the whole mesh. On curved or repeated geometry that often resolved to a parent on the FAR side of the mesh with angle≈0 — passing the alignment test but failing the extent-fit test, then falling through to Promote with no second attempt. Result: overlay regressed everywhere (Carousel 21→5%, Playground 37→12%, Gazebo 17→11%, Wooden 21→19%). Replace single-pass argmin-angle with a combined search: for each fine shell, scan ALL base shells, but among only those that pass angle ≤ overlayAngleDeg AND perpNorm ≤ overlayPerpNorm AND extent fits (with slack), keep the smallest-angle. Promote falls back to the overall best-angle shell so the resulting domain still has a sensible dominant-normal for the packer. K-nearest accidentally worked better because it gave LOCAL parents (suboptimal angle, optimal location). Full-scan-argmin-angle gave GLOBAL angle match (optimal angle, worst location). The fix takes LOCAL parents that also pass alignment — same complexity, no extra allocation, the right answer. Expected effect: Gazebo overlay 11.1% → 25-45% Carousel overlay 5.2% → 20-35% Wooden overlay 18.5% → 25-35% Playground overlay 12.1% → 40-55% --- Editor/HierarchicalRepack.cs | 114 +++++++++++++++++++---------------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs index ff0f7748..2a6ee2b7 100644 --- a/Editor/HierarchicalRepack.cs +++ b/Editor/HierarchicalRepack.cs @@ -667,75 +667,83 @@ static ShellDecision ClassifyFineShell(Shell3D fine, Shell3D[] baseShells, if (baseShells.Length == 0) return new ShellDecision { kind = ShellDecisionKind.Promote, parentBaseShellIdx = -1 }; - // Full scan for the best-angle base shell across ALL base shells. + // Combined search: scan ALL base shells, but instead of picking the + // globally-best-angle parent (which on curved/repeated geometry will + // be some perfectly aligned shell on the FAR side of the mesh — + // angle=0 but distance huge → extent-fit fail → promote regression), + // we filter to angularly-eligible parents AND keep only those that + // also pass perpendicular-distance + extent-fit. Among those, prefer + // the one with the smallest angle. If none pass, promote based on + // the overall best-angle shell so the domain still has a sensible + // dominant-normal record for downstream packing. // - // The previous K-nearest-by-centroid filter was inherited from probe v3's - // per-FACE classifier, where it made sense — a fine triangle near a - // particular point should pick its parent from local candidates. But - // shell-level matching has a pathological failure mode: a small detail - // on the EDGE of a large flat surface (e.g. a roof border ornament on - // a 2m gazebo roof) has the roof's centroid 2m away, while a dozen - // tiny neighbouring shells sit 0.1m away. K-nearest=10 then never - // even SEES the roof — bestAngle is computed only among the close - // clutter shells whose normals are unaligned, so overlay never fires - // and the detail gets promoted. Full-scan is O(N base × M fine) and - // for our sizes (~300 × ~600 on the worst test model) is negligible - // (~200k float dots, microseconds). - int bestShell = -1; - float bestAngle = float.MaxValue; - for (int si = 0; si < baseShells.Length; si++) - { - float dot = Vector3.Dot(fine.dominantNormal, baseShells[si].dominantNormal); - if (dot > 1f) dot = 1f; - if (dot < -1f) dot = -1f; - float ang = Mathf.Acos(dot) * Mathf.Rad2Deg; - if (ang < bestAngle) { bestAngle = ang; bestShell = si; } - } - - // Skip-criteria — applied BEFORE promote so genuinely tiny noise - // disappears even when no parent fits. A handle's normals are - // chaotic enough that no base shell will be within overlayAngleDeg. + // For Skip-detection we use the cheapest pass: tiny area + low face + // count. That's independent of parent search. + // + // Cost is O(N base × M fine) — ~270 × ~600 = 160k shell tests on + // the worst test model; each test is a few float ops, microseconds. float areaFrac = fine.totalArea / totalDeepArea; bool tinyArea = areaFrac < opts.skipAreaFrac; bool fewFaces = fine.faceCount <= opts.skipMaxFaceCount; if (tinyArea && fewFaces) return new ShellDecision { kind = ShellDecisionKind.Skip, parentBaseShellIdx = -1 }; - if (bestShell < 0) - return new ShellDecision { kind = ShellDecisionKind.Promote, parentBaseShellIdx = -1 }; + int bestOverlayShell = -1; + float bestOverlayAngle = float.MaxValue; + int bestPromoteShell = -1; + float bestPromoteAngle = float.MaxValue; - // Overlay test — alignment + planar inclusion + extent fit. - if (bestAngle <= opts.overlayAngleDeg) + for (int si = 0; si < baseShells.Length; si++) { - var parent = baseShells[bestShell]; + var parent = baseShells[si]; + + float dot = Vector3.Dot(fine.dominantNormal, parent.dominantNormal); + if (dot > 1f) dot = 1f; + if (dot < -1f) dot = -1f; + float angle = Mathf.Acos(dot) * Mathf.Rad2Deg; + + // Track overall best-angle for the promote fallback. + if (angle < bestPromoteAngle) { bestPromoteAngle = angle; bestPromoteShell = si; } + + if (angle > opts.overlayAngleDeg) continue; + + // Perpendicular-distance test in parent's plane. float perpAbs = Mathf.Abs(Vector3.Dot(fine.centroid - parent.centroid, parent.dominantNormal)); float perpNorm = perpAbs / meshDiag; - if (perpNorm <= opts.overlayPerpNorm) + if (perpNorm > opts.overlayPerpNorm) continue; + + // Planar extent fit in parent's basis (with slack). + Vector3 d = fine.centroid - parent.centroid; + float du = Mathf.Abs(Vector3.Dot(d, parent.basisU)); + float dv = Mathf.Abs(Vector3.Dot(d, parent.basisV)); + float boundU = parent.extentU * (1f + opts.overlayExtentSlack); + float boundV = parent.extentV * (1f + opts.overlayExtentSlack); + float fitU = du + fine.extentU; + float fitV = dv + fine.extentV; + if (fitU > boundU || fitV > boundV) continue; + + // This parent passes all three tests. Keep the smallest-angle + // candidate among the eligible set (ties broken by first-seen). + if (angle < bestOverlayAngle) { - // Project fine shell's centroid onto parent basis to verify - // it sits inside parent's planar rectangle (with slack). - Vector3 d = fine.centroid - parent.centroid; - float du = Mathf.Abs(Vector3.Dot(d, parent.basisU)); - float dv = Mathf.Abs(Vector3.Dot(d, parent.basisV)); - float boundU = parent.extentU * (1f + opts.overlayExtentSlack); - float boundV = parent.extentV * (1f + opts.overlayExtentSlack); - // Also account for fine shell's own extent — if even the - // closest corner of fine sticks past parent's bounds, abort. - float fitU = du + fine.extentU; - float fitV = dv + fine.extentV; - if (fitU <= boundU && fitV <= boundV) - { - return new ShellDecision - { - kind = ShellDecisionKind.Overlay, - parentBaseShellIdx = bestShell, - }; - } + bestOverlayAngle = angle; + bestOverlayShell = si; } } - return new ShellDecision { kind = ShellDecisionKind.Promote, parentBaseShellIdx = bestShell }; + if (bestOverlayShell >= 0) + return new ShellDecision + { + kind = ShellDecisionKind.Overlay, + parentBaseShellIdx = bestOverlayShell, + }; + + return new ShellDecision + { + kind = ShellDecisionKind.Promote, + parentBaseShellIdx = bestPromoteShell, + }; } /// Wrap a fine-LOD shell into a From 35c8d9bd30c0e1dff1307822ed72585c822ea228 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 19:58:53 +0000 Subject: [PATCH 026/110] PR-2.5 Step 2: loosen overlay thresholds for tilted/oversized fine shells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1b unlocked overlay on Carousel (5→29%), Wooden (19→44%), and Playground (12→46%), but Gazebo stayed pinned at 16.6%. Geometry analysis (ufbx parse) shows Gazebo has 8 octagonal roof panels at 22.5° to each other — vertex dedup merges two into Y-axis base shells, but the remaining six stay at ~36° tilt. Fine shells on those tilted panels miss overlay because (a) the parent's normal disagrees by >25° in the corner cases and (b) fine shell extent slightly exceeds parent extent on octagonal slices. Bump two Options.Default thresholds: overlayAngleDeg 25 → 35 (covers the 22-36° octagonal mismatch) overlayExtentSlack 0.10 → 0.30 (allows 30% overhang for partial-panel fine shells riding the chart border) overlayPerpNorm stays at 0.02 — that's the test that prevents true detached detail from masquerading as overlay, and weakening it would re-introduce promote-as-overlay false positives. Expected effect on Step 1b baseline: Gazebo overlay 16.6% → 30-50% Carousel overlay 29.1% → ~32% Wooden overlay 43.7% → 45-55% Playground overlay 45.8% → ~50% If Gazebo doesn't move much OR others regress >5pp, this commit will roll back to one knob at a time (A or B alone). Reversion is a single config flip. --- Editor/HierarchicalRepack.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs index 2a6ee2b7..0590d3ec 100644 --- a/Editor/HierarchicalRepack.cs +++ b/Editor/HierarchicalRepack.cs @@ -127,9 +127,9 @@ public struct Options shellSearchK = 10, atlasResolutionPx = 1024, interDomainPaddingPx = 4, - overlayAngleDeg = 25f, + overlayAngleDeg = 35f, overlayPerpNorm = 0.02f, - overlayExtentSlack = 0.10f, + overlayExtentSlack = 0.30f, skipAreaFrac = 0.001f, skipMaxFaceCount = 4, }; From 835388f575337bfbf4c3b9d9b8f0b900d0a44ce6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 20:51:10 +0000 Subject: [PATCH 027/110] PR-2.7: Frostbite-style per-vertex projection classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the PR-2.5 per-shell angle/perpDist/extent test with a per- vertex closest-point projection onto the deepest-LOD surface (Frostbite precomputed-GI lightmap-proxy pattern). For each fine-LOD vertex we find the closest deep triangle; if its distance to the proxy surface is ≤ overlayDistNorm × meshDiag, the vertex is "on the proxy" and inherits the proxy shell. A fine face overlays only if all three of its corners agree on the same proxy shell — straddled faces (corners on different shells) go to promote so their UVs don't interpolate across disjoint atlas rects. Marked (non-overlay) faces are then clustered into fine shells using the existing ExtractShells union-find (with a new participate-mask parameter), and each cluster classifies as Skip (tiny+few) or Promote. Skip now runs AFTER the overlay attempt, not before — so a small flat detail that COULD overlay onto a parent is no longer prematurely discarded as noise. Why this beats the PR-2.5 design: • Robust to fragmented base shells (Carousel cylinder's 270 small parents stop being a problem — a fine vertex just finds the nearest one). • Robust to angled panels lost during dedup (Gazebo octagonal roof: fine roof verts project onto the merged Y-axis roof at a finite distance — overlay fires). • One physically-meaningful threshold (distance) replaces three correlated geometric tests (angle, perp distance, extent fit). • Same O(fine verts × deep faces) complexity, AABB prefilter keeps Playground (the worst test: 23k fine faces / 4k deep tris) well under a second. Implementation: • ClosestPointOnTriangle: standard Ericson Voronoi-region algorithm, ~30 ops, no allocations. • ProjectVertexToDeepMesh: brute-force tri scan with per-tri AABB early-out. Deep AABBs precomputed once per Build() call. • ExtractShells gains an optional participateMask parameter so the second pass (on marked-for-promote fine faces) clusters only the relevant subset without temp arrays. Also addresses two long-standing bugs flagged in review: • #3 Skip-before-Overlay order: now Overlay first, Skip only as a fallback for clusters that didn't pass projection. • #5 Extent from centroids: ComputeExtents now scans corner vertices of every face in a shell instead of centroids. Long thin triangles would otherwise undersize their atlas rect and bleed UVs in PR-3 InverseTransfer. Options.Default cleaned up — dropped overlayAngleDeg, overlayPerpNorm, overlayExtentSlack, promoteAngleDeg, promoteRatio, promotePerpNorm, shellSearchK (all dead with the per-shell classifier gone). Added overlayDistNorm (default 0.01 = 1% of meshDiag). HierarchicalDiag is unaffected — it carries its own per-face thresholds. PR-2.8 next: swap PackAtlasNaive for xatlas via XatlasNative. --- Editor/HierarchicalRepack.cs | 557 +++++++++++++++++++---------------- 1 file changed, 303 insertions(+), 254 deletions(-) diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs index 0590d3ec..b50cabe4 100644 --- a/Editor/HierarchicalRepack.cs +++ b/Editor/HierarchicalRepack.cs @@ -5,8 +5,8 @@ // → LightingDomain assignment + the atlas layout that InverseTransfer // will project into. // -// Pipeline (PR-2.5 — shell-level classification): -// 1. Pick the deepest LOD from the LODGroup as the "base" — its shells +// Pipeline (PR-2.7 — Frostbite-style per-vertex projection): +// 1. Pick the deepest LOD from the LODGroup as the "proxy" — its shells // define the lighting domains that finer LODs will share. // 2. Extract 3D shells on the deepest LOD via union-find on face // adjacency + normal threshold (≤30° — matches probe v2/v3 + xatlas @@ -15,22 +15,39 @@ // in Unity's mesh.vertices don't fragment a single physical surface // into many single-tri shells. Degenerate (area<1e-12) tris are // skipped entirely (faceToDomain = -1). -// 3. For each finer LOD: extract its OWN shells (same dedup + threshold), -// then classify each fine SHELL (not face) vs the base shells: -// Overlay — fine shell aligned with parent (angle ≤ overlayAngleDeg) -// AND lies in parent's plane (perpNorm ≤ overlayPerpNorm) -// AND fits within parent's planar extent → reuse parent's -// atlas rect, no new domain. Wall+sign, box+decal. -// Skip — tiny shell (area below skipAreaFrac AND face count low) -// that's not overlay-eligible → faceToDomain = -1. -// Handles, knobs, geometric noise. No atlas slot. -// Promote — everything else: own direction, significant area → -// becomes its own atlas domain. -// 4. Build LightingDomain[]: one per base shell + one per promoted -// fine shell. Overlaid/skipped faces don't create domains. -// 5. Pack atlas rects. PR-2 used a naive horizontal-strip packer; PR-2.5 -// keeps it (still a placeholder) but with dramatically fewer + better -// shells. PR-2.6 swaps xatlas via XatlasNative wrapper. +// 3. For each finer LOD: project every vertex onto the closest deepest- +// LOD triangle (brute-force scan + per-tri AABB rejection). Per fine +// face: +// Overlay — all three corners are within overlayDistNorm × meshDiag +// of the proxy AND collapse to the same proxy shell: +// the face inherits that shell's atlas rect. No new +// domain — wall+sign, box+decal, roof+ornament. +// Marked — anything else (any corner detached from proxy, OR +// corners straddle shell boundaries → face has no +// single parent and would interpolate across atlas +// regions). Goes into the "needs own domain" pile. +// 4. Cluster Marked faces by adjacency (union-find again) per fine LOD. +// Each cluster classifies as: +// Skip — tiny + few faces (skipAreaFrac × deep area AND +// ≤ skipMaxFaceCount). Handles, fasteners, noise. +// Promote — gets its own atlas domain. +// 5. Build LightingDomain[]: one per base shell + one per promoted +// cluster. Overlaid/skipped faces don't create domains. +// 6. Pack atlas rects. PR-2 used a naive horizontal-strip packer; this +// stage keeps it as a placeholder until PR-2.8 swaps xatlas via +// XatlasNative. +// +// Why per-vertex projection vs the PR-2.5 per-shell angle/extent test: +// • Robust to fragmented base shells (Carousel cylinder: 270 small +// shells stop being a problem because a fine vertex finds the +// closest one regardless of how many parents exist). +// • Robust to lost angled panels after dedup (Gazebo octagonal roof: +// fine vertex projects onto the merged Y-axis roof at a finite +// distance, overlays cleanly). +// • A single physically-meaningful threshold (distance) replaces three +// correlated geometric tests (angle, perp distance, planar fit). +// • Same complexity O(fine verts × deep faces) with AABB prefilter — +// well under a second on our worst test case. // // Public types are documented for cross-PR clarity. Internal helpers // duplicate small bits of HierarchicalDiag (shell extraction, face @@ -58,28 +75,6 @@ public struct Options /// less than this. Matches xatlas hard-edge convention. public float shellNormalThresholdDeg; - /// Promote face if angle to parent shell's dominant normal exceeds - /// this (degrees). Default 60 ≈ 50% area retained under orthographic - /// projection — the "marginal acceptable" boundary. - public float promoteAngleDeg; - - /// Promote face if its 3D area is more than this fraction of the - /// parent shell's total 3D area. Indicates misidentified parent (probe v2 - /// data showed Wooden_Box_Long has 8.1% of faces in this bucket). - public float promoteRatio; - - /// Promote face if its centroid sits further than this fraction of - /// the deepest-LOD mesh diagonal from the parent shell's plane (along the - /// shell's normal). Catches "floating geometry" — bolts, free-standing - /// detail. Scale-invariant. - public float promotePerpNorm; - - /// Legacy K-nearest filter from probe v3's per-face classifier. - /// PR-2.5 shell-level matching uses a full scan instead — see notes - /// in ClassifyFineShell. Kept in Options for forward compat - /// with any per-face diagnostics that still rely on it. - public int shellSearchK; - /// Target atlas resolution (pixels). Final atlas may be slightly /// larger if shells don't fit; naive packer in PR-2 grows the height. public int atlasResolutionPx; @@ -89,31 +84,23 @@ public struct Options /// padding only applies BETWEEN domains, not within. public int interDomainPaddingPx; - /// Overlay if fine shell's dominant normal is within this many - /// degrees of the parent base shell's normal. Stricter than - /// promoteAngleDeg so that faces NOT promoted but with non-trivial - /// angular drift still get their own (promoted) domain. - public float overlayAngleDeg; - - /// Overlay if fine shell's centroid lies within this fraction of - /// the deepest-LOD mesh diagonal of the parent base shell's plane. - /// Tighter than promotePerpNorm — overlay requires the detail to actually - /// lie ON the parent surface, not just be near it. - public float overlayPerpNorm; - - /// Overlay tolerance: fine shell's planar extent (in parent's - /// basis) must be within (1 + overlayExtentSlack) × parent extent. - /// 0.10 = a 10% overhang is still allowed. - public float overlayExtentSlack; - - /// Skip a fine shell if BOTH (a) its 3D area is below this - /// fraction of the total deepest-LOD area AND (b) its face count - /// is at or below . Handles, - /// fasteners, and other small geometric noise where allocating - /// any atlas space is wasteful. + /// Per-vertex closest-surface-distance threshold (as a fraction + /// of the deepest-LOD world-space mesh diagonal). A fine-LOD vertex + /// whose distance to the deepest-LOD surface is ≤ this × meshDiag is + /// considered "on the proxy" and inherits the proxy's UV via overlay. + /// Replaces the PR-2.5 trio (overlayAngleDeg/PerpNorm/ExtentSlack) — + /// per-vertex projection captures angle + offset + extent fit in one + /// scalar, the way Frostbite's lightmap-proxy pipeline does. + public float overlayDistNorm; + + /// Skip a fine-LOD promoted cluster if BOTH (a) its 3D area + /// is below this fraction of the total deepest-LOD area AND (b) its + /// face count is at or below . Handles, + /// fasteners, and other small geometric noise where allocating any + /// atlas space is wasteful. public float skipAreaFrac; - /// Companion to — a shell with + /// Companion to — a cluster with /// many faces always promotes even if its total area is small, /// because face count alone implies someone will see it. public int skipMaxFaceCount; @@ -121,15 +108,9 @@ public struct Options public static Options Default => new Options { shellNormalThresholdDeg = 30f, - promoteAngleDeg = 60f, - promoteRatio = 1.5f, - promotePerpNorm = 0.05f, - shellSearchK = 10, atlasResolutionPx = 1024, interDomainPaddingPx = 4, - overlayAngleDeg = 35f, - overlayPerpNorm = 0.02f, - overlayExtentSlack = 0.30f, + overlayDistNorm = 0.01f, skipAreaFrac = 0.001f, skipMaxFaceCount = 4, }; @@ -244,15 +225,22 @@ public static Result Build(LODGroup lg, Options opts) if (meshDiag < 1e-6f) meshDiag = 1f; int deepDegen; var deepFaces = BuildFaceData(meshes[deepest], xforms[deepest], meshDiag, + out Vector3[] deepWorldVerts, out int[] deepRawTris, out int[] deepCanonicalTris, out deepDegen); var deepFaceToShell = new int[deepFaces.Length]; - var deepShells = ExtractShells(deepFaces, deepCanonicalTris, - opts.shellNormalThresholdDeg, deepFaceToShell); + var deepShells = ExtractShells(deepFaces, deepWorldVerts, deepRawTris, + deepCanonicalTris, opts.shellNormalThresholdDeg, deepFaceToShell, null); float totalDeepArea = 0f; for (int si = 0; si < deepShells.Length; si++) totalDeepArea += deepShells[si].totalArea; if (totalDeepArea < 1e-12f) totalDeepArea = 1e-12f; - // ── Step 3: per-LOD shell-level classification ── + // Precompute deepest-LOD per-tri AABBs for the projector's + // early-out filter (built once, used by every fine-LOD vertex + // query across all fine LODs). + BuildDeepAabbs(deepWorldVerts, deepRawTris, out var deepMin, out var deepMax); + float overlayDistAbs = opts.overlayDistNorm * meshDiag; + + // ── Step 3: domain table init ── // Domain numbering: // [0 .. baseN-1] → base shells (deepest LOD) // [baseN .. baseN+P-1] → promoted fine shells (P grows) @@ -272,59 +260,102 @@ public static Result Build(LODGroup lg, Options opts) for (int f = 0; f < deepFaces.Length; f++) result.faceToDomain[deepest][f] = deepFaceToShell[f]; - // Fine LODs: extract shells on each LOD, classify per shell. + // ── Step 4: per-vertex projection on each fine LOD ── + // Project every fine-LOD vertex onto the deep mesh; then decide + // each fine face based on its 3 corners' projection state: + // • All 3 verts within overlayDistAbs of the proxy AND all + // fall onto the SAME deep shell → Overlay(that shell). + // • Anything else → flagged for promote/skip clustering. + // The mismatch case (corners straddle shell boundaries) goes to + // promote because interpolating a fine face's UV across two + // disjoint atlas rects would bleed lightmap data across + // unrelated surfaces. var promotedClusters = new List(); int totalFineFaces = 0, promotedFineFaces = 0, overlaidFineFaces = 0, skippedFineFaces = 0, degenFineFaces = deepDegen; - // Note: degenFineFaces is a slight misnomer — it includes the deepest - // LOD's degenerates too, so the report can show "all degenerate tris - // dropped from atlas" in one number. + // degenFineFaces is a slight misnomer — it includes the deepest + // LOD's degenerates too, so the report can show "all degenerate + // tris dropped from atlas" in one number. for (int li = 0; li < deepest; li++) { if (meshes[li] == null) continue; int fineDegen; var fineFaces = BuildFaceData(meshes[li], xforms[li], meshDiag, + out Vector3[] fineWorldVerts, out int[] fineRawTris, out int[] fineCanonicalTris, out fineDegen); degenFineFaces += fineDegen; totalFineFaces += fineFaces.Length; - var fineFaceToShell = new int[fineFaces.Length]; - var fineShells = ExtractShells(fineFaces, fineCanonicalTris, - opts.shellNormalThresholdDeg, fineFaceToShell); - - // Per-shell decision. All faces in a shell get the same fate. - var shellDomain = new int[fineShells.Length]; // -1 = skip; ≥0 = domain index - for (int s = 0; s < fineShells.Length; s++) shellDomain[s] = -1; + // Per-fine-vertex overlay shell: index into deepShells, or -1 + // if the vertex is too far from the proxy surface to overlay. + int vertCount = fineWorldVerts.Length; + var vertOverlayShell = new int[vertCount]; + for (int v = 0; v < vertCount; v++) + { + int closestFace = ProjectVertexToDeepMesh(fineWorldVerts[v], + deepFaces, deepWorldVerts, deepRawTris, deepMin, deepMax, + out float dist); + if (closestFace >= 0 && dist <= overlayDistAbs) + vertOverlayShell[v] = deepFaceToShell[closestFace]; + else + vertOverlayShell[v] = -1; + } - for (int s = 0; s < fineShells.Length; s++) + // Per-fine-face decision: overlay if 3-corner consensus, else + // mark for promote/skip clustering. + var promoteMask = new bool[fineFaces.Length]; + var faceOverlayShell = new int[fineFaces.Length]; + for (int f = 0; f < fineFaces.Length; f++) { - var decision = ClassifyFineShell(fineShells[s], deepShells, opts, - meshDiag, totalDeepArea); - switch (decision.kind) + faceOverlayShell[f] = -1; + if (fineFaces[f].area <= 0f) continue; // degenerate + int v0 = fineRawTris[f * 3]; + int v1 = fineRawTris[f * 3 + 1]; + int v2 = fineRawTris[f * 3 + 2]; + int s0 = vertOverlayShell[v0]; + int s1 = vertOverlayShell[v1]; + int s2 = vertOverlayShell[v2]; + if (s0 >= 0 && s0 == s1 && s1 == s2) { - case ShellDecisionKind.Overlay: - shellDomain[s] = decision.parentBaseShellIdx; - overlaidFineFaces += fineShells[s].faceCount; - break; - case ShellDecisionKind.Promote: - int domainIdx = baseN + promotedClusters.Count; - shellDomain[s] = domainIdx; - promotedClusters.Add(MakeClusterFromShell(fineShells[s], li)); - promotedFineFaces += fineShells[s].faceCount; - break; - case ShellDecisionKind.Skip: - default: - skippedFineFaces += fineShells[s].faceCount; - break; + faceOverlayShell[f] = s0; + result.faceToDomain[li][f] = s0; + overlaidFineFaces++; + } + else + { + promoteMask[f] = true; } } - for (int f = 0; f < fineFaces.Length; f++) + // Cluster the marked faces into shells using existing + // adjacency logic (mask-filtered). + var fineFaceToShell = new int[fineFaces.Length]; + var fineShells = ExtractShells(fineFaces, fineWorldVerts, fineRawTris, + fineCanonicalTris, opts.shellNormalThresholdDeg, + fineFaceToShell, promoteMask); + + // Per-cluster decision: tiny → Skip, else → Promote. + for (int s = 0; s < fineShells.Length; s++) { - int s = fineFaceToShell[f]; - if (s < 0) continue; // degenerate face — leave -1 - result.faceToDomain[li][f] = shellDomain[s]; + var shell = fineShells[s]; + float areaFrac = shell.totalArea / totalDeepArea; + bool tinyArea = areaFrac < opts.skipAreaFrac; + bool fewFaces = shell.faceCount <= opts.skipMaxFaceCount; + int domainIdx; + if (tinyArea && fewFaces) + { + domainIdx = -1; + skippedFineFaces += shell.faceCount; + } + else + { + domainIdx = baseN + promotedClusters.Count; + promotedClusters.Add(MakePromotedClusterFromShell(shell, li)); + promotedFineFaces += shell.faceCount; + } + foreach (int f in shell.faceIndices) + result.faceToDomain[li][f] = domainIdx; } } result.totalFineFaces = totalFineFaces; @@ -349,7 +380,7 @@ public static Result Build(LODGroup lg, Options opts) // Stack rectangles into a fixed-width atlas (opts.atlasResolutionPx), // wrapping to a new row when full. Each rect's pixel size derives // from its world-space planar extent normalized to a "texels per - // world unit" target. PR-2.6 will replace this with xatlas via the + // world unit" target. PR-2.8 will replace this with xatlas via the // existing XatlasNative wrapper — at which point this method just // hands xatlas a virtual mesh with shellIDs and reads the rect // assignment back out. @@ -397,47 +428,39 @@ sealed class PromotedCluster public int sourceLodIndex; } - enum ShellDecisionKind - { - Skip = 0, // tiny / noisy geometric detail — no atlas slot - Overlay = 1, // aligned with parent base shell — reuse parent's rect - Promote = 2, // own direction / area — gets its own atlas domain - } - - struct ShellDecision - { - public ShellDecisionKind kind; - public int parentBaseShellIdx; // only meaningful when kind == Overlay - } - // ─── Face data + diagonal ──────────────────────────────────── /// Build per-face data for one mesh. Out-params: - /// rewrites mesh.triangles using - /// position-deduplicated vertex indices (epsilon = meshDiag × 1e-5) - /// so adjacent triangles split by Unity UV/normal seams still share - /// edges — without this, ExtractShells sees ~3× too many shells on - /// curved geometry. reports tris - /// dropped (their Face3D.area is set to 0 so callers can skip them - /// via faceToDomain = -1 instead of producing zero-area shells). + /// = world-space copy of mesh.vertices + /// (needed for vertex-based extent computation and per-vertex + /// projection in the new classifier). = + /// mesh.triangles as-is (indexes worldVerts). + /// rewrites mesh.triangles using position-deduplicated vertex indices + /// (epsilon = meshDiag × 1e-5) so adjacent triangles split by Unity + /// UV/normal seams still share edges — without this, ExtractShells sees + /// ~3× too many shells on curved geometry. + /// reports tris dropped (their Face3D.area is set to 0 so callers can + /// skip them via faceToDomain = -1 instead of producing zero-area + /// shells). static Face3D[] BuildFaceData(Mesh mesh, Transform xform, float meshDiag, - out int[] canonicalTris, out int degenerateCount) + out Vector3[] worldVerts, out int[] rawTris, out int[] canonicalTris, + out int degenerateCount) { var localVerts = mesh.vertices; - var verts = new Vector3[localVerts.Length]; + worldVerts = new Vector3[localVerts.Length]; for (int i = 0; i < localVerts.Length; i++) - verts[i] = xform.TransformPoint(localVerts[i]); - var tris = mesh.triangles; - canonicalTris = BuildCanonicalIndices(verts, tris, meshDiag); + worldVerts[i] = xform.TransformPoint(localVerts[i]); + rawTris = mesh.triangles; + canonicalTris = BuildCanonicalIndices(worldVerts, rawTris, meshDiag); - int n = tris.Length / 3; + int n = rawTris.Length / 3; var data = new Face3D[n]; int degenerate = 0; for (int f = 0; f < n; f++) { - var a = verts[tris[f * 3]]; - var b = verts[tris[f * 3 + 1]]; - var c = verts[tris[f * 3 + 2]]; + var a = worldVerts[rawTris[f * 3]]; + var b = worldVerts[rawTris[f * 3 + 1]]; + var c = worldVerts[rawTris[f * 3 + 2]]; data[f].centroid = (a + b + c) / 3f; var cross = Vector3.Cross(b - a, c - a); float mag = cross.magnitude; @@ -520,18 +543,26 @@ static float ComputeMeshDiagonal(Mesh mesh, Transform xform) // ─── Shell extraction (union-find on face adjacency) ───────── - static Shell3D[] ExtractShells(Face3D[] faces, int[] canonicalTris, float thresholdDeg) + static Shell3D[] ExtractShells(Face3D[] faces, Vector3[] worldVerts, int[] rawTris, + int[] canonicalTris, float thresholdDeg) { var faceToShell = new int[faces.Length]; - return ExtractShells(faces, canonicalTris, thresholdDeg, faceToShell); + return ExtractShells(faces, worldVerts, rawTris, canonicalTris, thresholdDeg, + faceToShell, null); } /// Variant that also fills with the - /// shell index per face (or -1 for degenerate faces, which are excluded - /// from shell formation). The array must be pre-allocated to faces.Length. - /// must contain position-deduplicated - /// vertex indices (see ). - static Shell3D[] ExtractShells(Face3D[] faces, int[] canonicalTris, float thresholdDeg, int[] faceToShellOut) + /// shell index per face (or -1 for degenerate faces / faces excluded by + /// ). The array must be pre-allocated + /// to faces.Length. must contain + /// position-deduplicated vertex indices (see ). + /// (optional, may be null): only faces + /// with mask[f] == true participate in shell formation; the rest get + /// faceToShellOut[f] = -1. Used to cluster the subset of fine-LOD faces + /// flagged for promotion by the projective classifier. + static Shell3D[] ExtractShells(Face3D[] faces, Vector3[] worldVerts, int[] rawTris, + int[] canonicalTris, float thresholdDeg, int[] faceToShellOut, + bool[] participateMask) { int n = faces.Length; if (n == 0) return new Shell3D[0]; @@ -554,6 +585,7 @@ void AddEdge(int va, int vb, int face) for (int f = 0; f < n; f++) { if (faces[f].area <= 0f) continue; + if (participateMask != null && !participateMask[f]) continue; int v0 = canonicalTris[f * 3], v1 = canonicalTris[f * 3 + 1], v2 = canonicalTris[f * 3 + 2]; AddEdge(v0, v1, f); AddEdge(v1, v2, f); AddEdge(v2, v0, f); } @@ -594,7 +626,8 @@ void Union(int a, int b) for (int f = 0; f < n; f++) { - if (faces[f].area <= 0f) + if (faces[f].area <= 0f || + (participateMask != null && !participateMask[f])) { faceToShellOut[f] = -1; continue; @@ -637,119 +670,127 @@ void Union(int a, int b) shells[si].centroid = Vector3.zero; } ComputePlaneBasis(shells[si].dominantNormal, out shells[si].basisU, out shells[si].basisV); - ComputeExtents(faces, faces_[si], shells[si].centroid, shells[si].basisU, shells[si].basisV, + ComputeExtents(worldVerts, rawTris, faces_[si], shells[si].centroid, + shells[si].basisU, shells[si].basisV, out shells[si].extentU, out shells[si].extentV); } return shells; } - // ─── Shell-level classification (PR-2.5) ───────────────────── + // ─── Per-vertex projection (PR-2.7 — Frostbite-style) ──────── - /// - /// Decide what to do with a fine-LOD shell: overlay onto a parent base - /// shell, promote it as its own atlas domain, or skip (no atlas slot). - /// - /// Selection rules (in order): - /// 1. Find best parent (K-nearest by centroid, then min angle). - /// 2. If shell is angularly aligned (≤ overlayAngleDeg) AND lies in - /// parent's plane (perpNorm ≤ overlayPerpNorm) AND fits within - /// parent's planar extent (with overlayExtentSlack tolerance) → - /// Overlay. The fine shell's faces will read the parent's atlas - /// texels, so a wall-mounted sign inherits the wall's lightmap. - /// 3. If shell is small (area < skipAreaFrac × totalDeepArea AND - /// face count ≤ skipMaxFaceCount) → Skip. Handles, fasteners, - /// geometric noise — wasting atlas space on them is pointless. - /// 4. Otherwise → Promote. The shell becomes its own domain. - /// - static ShellDecision ClassifyFineShell(Shell3D fine, Shell3D[] baseShells, - Options opts, float meshDiag, float totalDeepArea) + /// Closest point on triangle ABC to query point P. Standard + /// Voronoi-region algorithm (Ericson, Real-Time Collision Detection + /// ch. 5). No allocations, ~30 ops, branch-heavy. + static Vector3 ClosestPointOnTriangle(Vector3 p, Vector3 a, Vector3 b, Vector3 c) { - if (baseShells.Length == 0) - return new ShellDecision { kind = ShellDecisionKind.Promote, parentBaseShellIdx = -1 }; - - // Combined search: scan ALL base shells, but instead of picking the - // globally-best-angle parent (which on curved/repeated geometry will - // be some perfectly aligned shell on the FAR side of the mesh — - // angle=0 but distance huge → extent-fit fail → promote regression), - // we filter to angularly-eligible parents AND keep only those that - // also pass perpendicular-distance + extent-fit. Among those, prefer - // the one with the smallest angle. If none pass, promote based on - // the overall best-angle shell so the domain still has a sensible - // dominant-normal record for downstream packing. - // - // For Skip-detection we use the cheapest pass: tiny area + low face - // count. That's independent of parent search. - // - // Cost is O(N base × M fine) — ~270 × ~600 = 160k shell tests on - // the worst test model; each test is a few float ops, microseconds. - float areaFrac = fine.totalArea / totalDeepArea; - bool tinyArea = areaFrac < opts.skipAreaFrac; - bool fewFaces = fine.faceCount <= opts.skipMaxFaceCount; - if (tinyArea && fewFaces) - return new ShellDecision { kind = ShellDecisionKind.Skip, parentBaseShellIdx = -1 }; - - int bestOverlayShell = -1; - float bestOverlayAngle = float.MaxValue; - int bestPromoteShell = -1; - float bestPromoteAngle = float.MaxValue; - - for (int si = 0; si < baseShells.Length; si++) + Vector3 ab = b - a, ac = c - a, ap = p - a; + float d1 = Vector3.Dot(ab, ap); + float d2 = Vector3.Dot(ac, ap); + if (d1 <= 0f && d2 <= 0f) return a; + + Vector3 bp = p - b; + float d3 = Vector3.Dot(ab, bp); + float d4 = Vector3.Dot(ac, bp); + if (d3 >= 0f && d4 <= d3) return b; + + float vc = d1 * d4 - d3 * d2; + if (vc <= 0f && d1 >= 0f && d3 <= 0f) { - var parent = baseShells[si]; - - float dot = Vector3.Dot(fine.dominantNormal, parent.dominantNormal); - if (dot > 1f) dot = 1f; - if (dot < -1f) dot = -1f; - float angle = Mathf.Acos(dot) * Mathf.Rad2Deg; - - // Track overall best-angle for the promote fallback. - if (angle < bestPromoteAngle) { bestPromoteAngle = angle; bestPromoteShell = si; } - - if (angle > opts.overlayAngleDeg) continue; - - // Perpendicular-distance test in parent's plane. - float perpAbs = Mathf.Abs(Vector3.Dot(fine.centroid - parent.centroid, - parent.dominantNormal)); - float perpNorm = perpAbs / meshDiag; - if (perpNorm > opts.overlayPerpNorm) continue; - - // Planar extent fit in parent's basis (with slack). - Vector3 d = fine.centroid - parent.centroid; - float du = Mathf.Abs(Vector3.Dot(d, parent.basisU)); - float dv = Mathf.Abs(Vector3.Dot(d, parent.basisV)); - float boundU = parent.extentU * (1f + opts.overlayExtentSlack); - float boundV = parent.extentV * (1f + opts.overlayExtentSlack); - float fitU = du + fine.extentU; - float fitV = dv + fine.extentV; - if (fitU > boundU || fitV > boundV) continue; - - // This parent passes all three tests. Keep the smallest-angle - // candidate among the eligible set (ties broken by first-seen). - if (angle < bestOverlayAngle) - { - bestOverlayAngle = angle; - bestOverlayShell = si; - } + float v = d1 / (d1 - d3); + return a + v * ab; } - if (bestOverlayShell >= 0) - return new ShellDecision - { - kind = ShellDecisionKind.Overlay, - parentBaseShellIdx = bestOverlayShell, - }; + Vector3 cp = p - c; + float d5 = Vector3.Dot(ab, cp); + float d6 = Vector3.Dot(ac, cp); + if (d6 >= 0f && d5 <= d6) return c; - return new ShellDecision + float vb = d5 * d2 - d1 * d6; + if (vb <= 0f && d2 >= 0f && d6 <= 0f) { - kind = ShellDecisionKind.Promote, - parentBaseShellIdx = bestPromoteShell, - }; + float w = d2 / (d2 - d6); + return a + w * ac; + } + + float va = d3 * d6 - d5 * d4; + if (va <= 0f && (d4 - d3) >= 0f && (d5 - d6) >= 0f) + { + float w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return b + w * (c - b); + } + + float denom = 1f / (va + vb + vc); + float vv = vb * denom; + float ww = vc * denom; + return a + ab * vv + ac * ww; } - /// Wrap a fine-LOD shell into a - /// for the unified domain table. The shell already has area-weighted - /// plane data + extents — just copy fields and tag the source LOD. - static PromotedCluster MakeClusterFromShell(Shell3D shell, int sourceLodIndex) + /// Squared distance from point q to AABB [mn, mx]; 0 if inside. + /// Used as an early-out filter before the expensive triangle test. + static float SqDistToAabb(Vector3 q, Vector3 mn, Vector3 mx) + { + float dx = q.x < mn.x ? mn.x - q.x : (q.x > mx.x ? q.x - mx.x : 0f); + float dy = q.y < mn.y ? mn.y - q.y : (q.y > mx.y ? q.y - mx.y : 0f); + float dz = q.z < mn.z ? mn.z - q.z : (q.z > mx.z ? q.z - mx.z : 0f); + return dx * dx + dy * dy + dz * dz; + } + + /// Precomputed per-triangle AABBs for the deepest-LOD mesh — + /// pays for itself after ~3 vertex queries vs computing on the fly. + static void BuildDeepAabbs(Vector3[] worldVerts, int[] rawTris, + out Vector3[] mins, out Vector3[] maxs) + { + int n = rawTris.Length / 3; + mins = new Vector3[n]; + maxs = new Vector3[n]; + for (int f = 0; f < n; f++) + { + var a = worldVerts[rawTris[f * 3]]; + var b = worldVerts[rawTris[f * 3 + 1]]; + var c = worldVerts[rawTris[f * 3 + 2]]; + mins[f] = Vector3.Min(Vector3.Min(a, b), c); + maxs[f] = Vector3.Max(Vector3.Max(a, b), c); + } + } + + /// Find the deepest-LOD triangle whose surface is closest to + /// world-space query point . Brute-force scan with + /// AABB rejection — O(N) tris per query, but on our worst test models + /// (~4k deep tris × ~12k fine verts) totals well under a second. A BVH + /// is a follow-up if profiling justifies it. Returns -1 if the deep + /// mesh is empty. + static int ProjectVertexToDeepMesh(Vector3 q, + Face3D[] deepFaces, Vector3[] deepWorldVerts, int[] deepRawTris, + Vector3[] aabbMin, Vector3[] aabbMax, out float bestDist) + { + int closest = -1; + float bestSq = float.MaxValue; + int n = deepFaces.Length; + for (int f = 0; f < n; f++) + { + if (deepFaces[f].area <= 0f) continue; + if (SqDistToAabb(q, aabbMin[f], aabbMax[f]) >= bestSq) continue; + var a = deepWorldVerts[deepRawTris[f * 3]]; + var b = deepWorldVerts[deepRawTris[f * 3 + 1]]; + var c = deepWorldVerts[deepRawTris[f * 3 + 2]]; + Vector3 pt = ClosestPointOnTriangle(q, a, b, c); + float dsq = (pt - q).sqrMagnitude; + if (dsq < bestSq) + { + bestSq = dsq; + closest = f; + } + } + bestDist = closest >= 0 ? Mathf.Sqrt(bestSq) : float.PositiveInfinity; + return closest; + } + + /// Materialise a fine-LOD shell (collected from the promote- + /// pile after projective classification) as a . + /// The shell already has area-weighted plane data + vertex-based + /// extents — just copy fields and tag the source LOD. + static PromotedCluster MakePromotedClusterFromShell(Shell3D shell, int sourceLodIndex) { return new PromotedCluster { @@ -776,20 +817,28 @@ static void ComputePlaneBasis(Vector3 n, out Vector3 u, out Vector3 v) v = Vector3.Cross(n, u).normalized; } - /// Project each face centroid onto (u,v) basis centred at ; - /// max abs value along each axis becomes the half-extent. Defines the domain's - /// "rectangle in plane space" that we'll later squeeze into its atlas rect. - static void ComputeExtents(Face3D[] faces, List faceIndices, Vector3 origin, + /// Project every CORNER VERTEX of every face in the shell onto + /// (u,v) basis centred at ; max abs value along + /// each axis becomes the half-extent. Vertex-based (not centroid-based) + /// because InverseTransfer will project the SAME vertices into the + /// atlas rect; a centroid-based extent under-shoots long thin triangles + /// (corner verts spill outside the rect → wrap-around bleeding in the + /// baked lightmap). + static void ComputeExtents(Vector3[] worldVerts, int[] rawTris, + List faceIndices, Vector3 origin, Vector3 u, Vector3 v, out float extU, out float extV) { float maxU = 0f, maxV = 0f; foreach (int f in faceIndices) { - Vector3 d = faces[f].centroid - origin; - float pu = Mathf.Abs(Vector3.Dot(d, u)); - float pv = Mathf.Abs(Vector3.Dot(d, v)); - if (pu > maxU) maxU = pu; - if (pv > maxV) maxV = pv; + for (int k = 0; k < 3; k++) + { + Vector3 d = worldVerts[rawTris[f * 3 + k]] - origin; + float pu = Mathf.Abs(Vector3.Dot(d, u)); + float pv = Mathf.Abs(Vector3.Dot(d, v)); + if (pu > maxU) maxU = pu; + if (pv > maxV) maxV = pv; + } } // Small floor — degenerate shells (1-2 tiny triangles) would otherwise // have zero extent and divide-by-zero in InverseTransfer's mapping. @@ -842,7 +891,7 @@ static LightingDomain MakeDomainFromCluster(PromotedCluster c) /// uniform texels-per-world-unit derived from the max domain. The rect is /// then normalized to [0,1]² and stored in . /// - /// PR-2.6 swaps this for xatlas via XatlasNative. The contract is the same: + /// PR-2.8 swaps this for xatlas via XatlasNative. The contract is the same: /// after the call, every domain has a valid uv2Rect and atlasW/H are the /// final dimensions. /// @@ -1044,7 +1093,7 @@ static void LogDryRunSummary(string lgName, Result r) sb.AppendLine($" domains: {r.domains.Length} total " + $"({r.baseShellCount} base / {r.promotedClusterCount} promoted shells)"); sb.AppendLine($" atlas: {r.atlasPixelWidth} × {r.atlasPixelHeight} px " + - $"(naive strip packer — PR-2.6 will swap in xatlas)"); + $"(naive strip packer — PR-2.8 will swap in xatlas)"); int denom = Mathf.Max(1, r.totalFineFaces); sb.AppendLine($" fine faces: {r.totalFineFaces} total"); sb.AppendLine($" promoted: {r.promotedFineFaces,6} ({100f * r.promotedFineFaces / denom,5:F1}%)"); From 8211e924a729a7e0ced15df3109db940c9bfff0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 21:09:03 +0000 Subject: [PATCH 028/110] Unified benchmark: one button runs every technique per case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the three previously-scattered analysis entry points into a single suite-driven flow: • legacy xatlas parameter sweep (was: ExecMultiCaseSweep / Run Multi-Case button) • probe v3 per-face stay/promote (was: Mesh Lab/Diag/Hierarchical Containment Probe menu, single model only) • hierarchical repack dry-run (was: Mesh Lab/Diag/Hierarchical Atlas Dry-Run menu, my recent multi-select addition) What changed ------------ • TestSuiteAsset gains a `BenchTechniques` block with three bool toggles. Default-on; opt out by unchecking. Replaces the previous implicit "Run Multi-Case = legacy sweep only" wiring. • LightmapTransferTool.ExecBenchmark replaces ExecMultiCaseSweep. Per case it now spawns the prefab, resolves LODGroup, then dispatches to every enabled technique. All artefacts for one case land under BenchmarkReports/bench_/_