Prefab Builder: new Hierarchy view (PR-1) - #111
Open
SashaRX wants to merge 175 commits into
Open
Conversation
Introduces LightmapUvTool.UvtLog.Category flags (SymSplit, Repack, Match, Dedup, Overlap, Topology, Validation, Export, Benchmark) and overloads for Info/Warn/Error/Verbose that accept a category. Enabled categories are persisted to EditorPrefs as a bitmask (LightmapUvTool_LogCategoryMask) and silenced independently of the Level knob. Existing call sites continue to compile via legacy string-only overloads that default to Category.General. Prefix changes from "[LightmapUV] " to "[LightmapUV][<Category>] ". This is the foundation for the transfer-modes benchmark log/metric work; no call-site migration is done in this commit.
Introduces BenchmarkRecorder (IDisposable) that collects per-mesh pipeline
metrics (TransferResult + ValidationReport + volatile counters) and writes
CSV/JSON reports into <projectRoot>/BenchmarkReports/ on Dispose.
Adds public static counters so the recorder can read them:
- SymmetrySplitShells.LastFallbackCount / LastTotalSplitCount
- GroupedShellTransfer.LastTopologyIterations / LastTopologyFixed /
LastTopologyCapHit
The enclosing callers (e.g. LightmapTransferTool) will wrap pipeline
execution in `using (BenchmarkRecorder.NewRun(...))` in a follow-up commit;
this commit only adds the recorder + hooks and keeps existing behavior.
Also migrates the SymSplit fallback / topology enforcement log sites to
categorized UvtLog overloads (Category.SymSplit, Category.Topology).
- ExecFullPipeline now opens a recorder session around the whole run and records one row per MeshEntry once the core finishes. The original body moved to ExecFullPipelineCore. - ExecRepack / ExecTransferAll open nested sessions via BenchmarkRecorder .NewRun — when already inside an outer session the call returns a NoOpScope so the outer recorder continues to own the timing/record state. - Stage timers: "pipeline" wraps the full run, "repack" wraps the repack stage, "transfer" wraps TransferAll, "validate" wraps TransferValidator .Validate. - Standalone ExecTransferAll now writes a CSV/JSON on its own when it creates the session (ownsSession branch); standalone ExecRepack only records timings and skips file output because there is no per-mesh validation data yet. - BenchmarkRecorder.NewRun now returns IDisposable and silently no-ops on nested calls; WriteArtefacts bails when no mesh rows were captured.
UvCanvasView.ValidationFilterMask: when non-zero, GlFillValidation and GlFillValidationOverlay only draw triangles whose TriIssue intersects the mask. Default (None) keeps the previous behavior. LightmapTransferTool Pipeline Settings now has a "Log filters" foldout with the UvtLog.Level picker and a toggle per UvtLog.Category (General, SymSplit, Repack, Match, Dedup, Overlap, Topology, Validation, Export, Benchmark) so noisy subsystems can be silenced without lowering the global verbosity. Transfer tab now has a "Validation Overlay" foldout beneath the Quality Report: per-category toggles (Inverted/Stretched/ZeroArea/OutOfBounds/ Overlap/TexelDensity) flip bits in canvas.ValidationFilterMask and request a repaint, so the canvas can isolate a single defect class across the current mesh entries.
TestSuiteAsset is a ScriptableObject registry of benchmark cases — one per model (FBX reference, optional LODGroup path, ExpectedRange list for GO/STOP notes, free-form notes). A custom inspector exposes a Ping button per case. Create via Assets → Create → Lightmap UV Tool → Test Suite. TRANSFER_BENCHMARK.md documents the benchmark protocol, the metrics emitted by BenchmarkRecorder, the log-category table, the Validation Overlay workflow, a (yet-to-be-filled) matrix of runs, and suggested Go/Stop thresholds. Point readers to EXPERIMENTS.md for the regressions history.
TestSuiteAsset.SweepMatrix: atlasResolutions (default 256/512/2048),
shellPaddingPxVariants (default 2/4/8/32), borderPaddingPxVariants
(default 0), resetBetweenRuns. Stored per-suite alongside the test cases.
LightmapTransferTool:
- ExecFullPipeline now has a string-label overload; the old parameterless
form delegates to it with runLabel="FullPipeline".
- ExecSweep(SweepMatrix) iterates the cartesian product, sets
ctx.AtlasResolution/ShellPaddingPx/BorderPaddingPx per cell, calls
ResetWorkingCopies() (lightweight — no sidecar delete, no FBX reimport),
then ExecFullPipeline("sweep_res{R}_pad{S}_bdr{B}"). Each cell writes
its own CSV+JSON via BenchmarkRecorder; the cell id ends up in the
filename and the runLabel column.
- Original atlas/padding values are restored in finally; progress bar
with Cancel is shown.
- New UI row under Run Full Pipeline: Sweep suite ObjectField +
"Run Sweep (N)" button showing the cell count.
TRANSFER_BENCHMARK.md: documents the sweep workflow and a one-liner to
concat the produced CSVs with pandas.
Two MenuItems under "Mesh Lab":
- "Export FBX Metrics (Selected Assets)" — scans every LODGroup/Renderer
inside the selected .fbx assets.
- "Export FBX Metrics (Scene LODGroup)" — scans the LODGroup containing
the active Hierarchy selection.
Output: <projectRoot>/BenchmarkReports/FbxMetrics_{ts}/
- FbxMetrics_{ts}.csv — one row per mesh × LOD: vertex/triangle count,
submesh count, bounds size, avg world edge length, geometry-island
count, UV0 shell count, UV0 total coverage, max/mean shell area,
shell area stddev, UV0 AABB overlap pairs, UV0 OOB verts, estimated
mirror pair count, UV2 shell count/overlaps/OOB when present.
- png/<model>_<lodGroup>_LOD{N}_<renderer>_uv0.png (+ _uv2.png) — per-
shell coloured triangles with wire + 0–1 bounding box, range
[-0.1, 1.1] so OOB verts are visible. Rendered via a RenderTexture
driven by Hidden/Internal-Colored material.
Shares the UvShellExtractor.CountAabbOverlaps/Extract helpers used by
the transfer pipeline, so FBX baseline metrics stay consistent with
BenchmarkRecorder sweep output. Join keys: (model, lodGroup,
rendererName, lodIndex).
TRANSFER_BENCHMARK.md documents both menu items and what the PNG/CSV
pair lets you do at analysis time.
Extract the PNG-rendering helper from FbxMetricsExporter into a shared
UvPngWriter (Editor/UvPngWriter.cs): renders triangles per-shell, wire,
and the 0-1 bounding box into a RenderTexture, then writes PNG. View
covers [-0.1, 1.1] so OOB verts are visible. Used by both the source
FBX baseline exporter and the benchmark recorder.
BenchmarkRecorder.RecordMesh now snapshots the result UV2 channel +
triangles from the relevant mesh (repackedMesh on source LOD,
transferredMesh on target LODs, originalMesh as fallback). On Dispose,
after the CSV/JSON is written, it dumps one PNG per recorded mesh into
a sibling "{fileBase}_png/" folder:
<rendererName>_LOD{N}_uv2.png
So for each sweep cell the BenchmarkReports/ folder ends up with:
{ts}_{lodGroup}_sweep_res256_pad2_bdr0_LegacyFixed.csv
{ts}_{lodGroup}_sweep_res256_pad2_bdr0_LegacyFixed.json
{ts}_{lodGroup}_sweep_res256_pad2_bdr0_LegacyFixed_png/
<renderer>_LOD0_uv2.png
<renderer>_LOD1_uv2.png
...
This makes cross-cell visual comparison immediate: diff two folders,
look at the same renderer across (res, pad) combinations. UV0 stays
in FbxMetricsExporter because it doesn't change between runs — one
snapshot per FBX is enough.
FbxMetricsExporter delegates PNG writing to UvPngWriter; local copy of
the renderer helpers removed.
…enum P1 — Per-target topology metrics: GroupedShellTransfer.Transfer now snapshots LastTopologyIterations / LastTopologyFixed / LastTopologyCapHit into TransferResult immediately after EnforceShellTopologyOnUv2, so each target mesh carries its own values. BenchmarkRecorder.RunRecord reads from TransferResult instead of the global static fields; previously a multi-mesh run copied the last processed target's topology numbers into every CSV row. P2 — Sweep cancel now breaks all three loops: ExecSweep wrapped the triple foreach directly, so "break" on DisplayCancelableProgressBar only exited the innermost borderPad loop and subsequent cells still ran. Added "if (cancelled) break" guards around the outer atlasRes and shellPad loops so cancellation halts the whole sweep. P2 — Cache Log filters enum values: Log filters UI called Enum.GetValues(typeof(UvtLog.Category)) on every repaint, allocating each frame. Moved to a static readonly array (s_logCategories) built once at type init, with the composite "All" flag filtered out. OnGUI now iterates the cached array.
Summary of the 60-cell LegacyFixed sweep on Playground, Gazebo,
Carousel, Wooden_Box_Long across res∈{256,512,2048} × pad∈{2,4,6,8,32}.
Key findings:
- Pipeline health solid: shellsRejected=0, overlapShellPairs=0,
coverage=1.00 across every cell.
- defectScore = stretched+zeroArea+oob is dominated by Carousel
(N-fold rotational, 25-32% defective triangles) and by Playground's
invertedCount (which the validator documents as "winding flip is
expected"); stretched+zeroArea on Playground is tiny (~85).
- pad=32 gives modest defect-score win on res=256/512 but pays 8-10x
repack cost; at res=2048 the effect flattens.
- Doubling res ≈ halves texel density; doubling pad ≈ doubles it.
res=2048 pad=2 has the tightest UV2 (texelMedian 72).
- topologyCapHit fires ~5-10% of cells, mostly Carousel LOD3 where
Laplacian enforcement does 34 fixes; worth raising
kMaxTopologyIterations 5→8 as a follow-up.
Recommended defaults (pending Adaptive comparison):
- atlasResolution = 512
- shellPaddingPx = 4
- borderPaddingPx = 0
Follow-ups listed in the doc: Adaptive sweep, Carousel topology-cap
experiment, inspect Playground invertedCount PNGs visually, fill
res=1024 gap, borderPad sweep.
Places a second toggle under 'SymSplit target LODs' on the Setup tab. When enabled, ExecFullPipelineCore skips the ExecSymmetrySplit call in every auto-tune config iteration, so a sweep can be run without symmetry-split fragmentation to isolate whether xatlas packing issues (e.g. Wooden_Box_Long stuffing all shells into ~25% of the atlas) are caused by the shape/count of shells fed to xatlas after SymSplit or by xatlas packing heuristics on the original set. Only a diagnostic — default is false (SymSplit still runs).
Main's PR #106 renamed namespace LightmapUvTool → SashaRX.UnityMeshLab globally. My four new files (BenchmarkRecorder, FbxMetricsExporter, UvPngWriter, TestSuiteAsset) were added on this branch before the migration and still declared the old namespace; the merge commit didn't touch new-on-branch files. Update them to match.
…ashaRX/UnityMeshLab into claude/optimize-transfer-modes-fJYTm
Added a new toggle under 'SymSplit target LODs' on the Setup tab to allow users to skip the ExecSymmetrySplit call during auto-tune config iterations. This feature is intended for diagnostic purposes to help isolate issues related to xatlas packing without the influence of symmetry-split fragmentation. The default setting remains false, ensuring that SymSplit continues to run unless explicitly disabled.
Single-file Python script that scans a BenchmarkReports folder
(*_sweep_*.csv + *_png/) and emits a per-model HTML gallery with:
- res × pad table of UV2 thumbnails
- per-cell metrics overlay (inv/str/0A/tex/topFx)
- 1-5 rating buttons + tag panel (narrow_strips, empty_atlas,
rotation_wrong, stretched, good_pack, broken_shells, small_shells,
overlap_visible) + free-form note
- hotkeys: 1-5 / g/b/u/n rate, Space=next unrated, Tab/arrows nav,
t=tags, e=export
- localStorage persistence (survives reload), JSON export/import
- progress counter "X / Y rated" in sticky bottom bar
Run:
python Tools/build_gallery.py <BenchmarkReports-dir> \
--gallery-id "noSymSplit_2026-04-24"
Output goes alongside the data; open _gallery_index.html. The
gallery-id doubles as the localStorage key so votes for different
runs don't collide.
Some chat clients auto-rewrite '.py' filenames as markdown links when copying instructions. The wrapper avoids ever needing to type the extension at the command prompt — invoke 'Tools\gen.bat <args>' and it forwards everything to build_gallery.py via %~dp0.
…metric xatlas with texelsPerUnit=0 (auto) underfills the atlas on tiled-UV0 models such as Wooden_Box_Long: charts get packed into ~25% of the requested atlas extent, leaving 75% of UV space unused. Per-triangle metrics (inv/stretched/zeroArea) don't catch it because each triangle is locally valid — the whole layout is just shrunk into a corner. Three additions: 1. RepackOptions.normalizeAtlasFill (default true). New post-pack pass NormalizeAtlasFill rescales UV2 bbox uniformly to [padding, 1-padding] in both axes, preserving aspect ratio. No-op when already > ~95% filled (scale ≤ 1.05). Applied in both RepackSingle and RepackMulti, after border inset and overlap fixes. 2. xatlas pack-result logging. Added an Info-level [Repack] message right after PackCharts that prints requested vs actual atlas size and chart count, so underfill or auto-resize behaviour is visible in Console. 3. BenchmarkRecorder.RunRecord.atlasUtilization. New float column = bbox-area of the result UV2 in [0,1] space (1.0 = full, 0.25 = quarter-filled). Written to CSV and JSON. Tools/build_gallery.py renders it per cell and outlines low-util cells (< 50%) in red so they pop visually next to numeric metrics. Together these turn a bug invisible to per-triangle counters into a first-class diagnostic. Existing well-packed runs keep their layout because the rescale guard (scale ≤ 1.05) leaves them alone.
Per the new UI mockup the sidebar is now a single Hierarchy section:
Root row with green Apply Names + per-Dummy collapsible blocks. Each
Dummy lists its LOD rows (rename / regen / delete / channel badges /
quality slider) and COL rows below in a different colour. "+ Add LOD"
buttons interleave between rows for in-place insertion.
* Per-LOD slider drives MeshSimplifier; ↻ regenerates from the LOD0
source mesh, + Add LOD inserts a new slot mid-stack via
LodGroupUtility.ApplyLods, ✕ removes the renderer + slot.
* Apply Names is two-step: commits pending Root/Dummy edits, then
rebuilds child names from the canonical "<prefix>_LOD{N}" /
"<prefix>_COL[_Hull{N}]" pattern. Always enabled so the user can
resync names after structural changes.
* Channel badges show "UV0·UV1·VC·N·T" inline per LOD row by probing
Mesh.GetUVs / colors32 / normals / tangents.
* Operations update buildIntent so the PR-3 save flow can pick a
narrow vs wide FBX re-save.
Removed the Build Pipeline foldout (Open Prefab / Generate LODs /
Validate / Save) and the legacy LOD Levels foldout — the new
Hierarchy absorbs LOD edits and PR-3 will reintroduce Build & Save
as a bottom-bar with pre-flight validation. NormalizeHierarchy and
its helpers (GroupMeshChildrenByMaterial, NormalizeChildScales,
BakeMatrixIntoMesh) are dropped; their workflows can return as
explicit right-panel actions later. Collision / Split-Merge /
Mesh Info / Edge / Problem sections stay untouched in this PR and
will migrate to the right-side settings stack in PR-2.
Net change: -417 lines.
https://claude.ai/code/session_01Cm3FDtRJqnEyDT2qaJUfkT
Owner
Author
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d30eca4b53
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
ModelImporter.meshOptimizationFlags is typed MeshOptimizationFlags (enum) — newer Unity versions reject the implicit two-way conversion to/from int. Type the snapshot variable as the enum so the snapshot and restore assignments compile cleanly. https://claude.ai/code/session_01Cm3FDtRJqnEyDT2qaJUfkT
When a [MenuItem] action shares its path with a child submenu (e.g.
"Tools/Mesh Lab" alongside "Tools/Mesh Lab/Validators/..."), Unity
treats the parent path as a submenu only and silently drops the
parent action. The Open Mesh Lab entry was therefore invisible —
hovering Tools/Mesh Lab showed nothing but the Validators submenu.
Move the window opener under the same namespace ("Tools/Mesh Lab/
Open Mesh Lab") so the parent stays a submenu and both entries are
visible.
https://claude.ai/code/session_01Cm3FDtRJqnEyDT2qaJUfkT
Insert / Delete / Regenerate leave LOD slot indices out of sync with
their renderer GameObject names — e.g. inserting at slot 1 keeps the
old "_LOD1" GameObject sitting in slot 2 until Apply Names runs the
canonical rebuild. The hierarchy view now reflects that explicitly:
* IsLodRowStale / IsColRowStale compare each row's name to the
canonical pattern ("<prefix>_LOD{N}" / "<prefix>_COL[_Hull{N}]"); a
3-px orange stripe paints on the left edge of any stale row.
* When a Dummy block contains stale rows, a hint message appears at
the bottom prompting the user to click Apply Names.
* Each Dummy block now has a 3-px coloured leading bar (green for
Root, blue for non-root) so multi-Dummy hierarchies read as
separate indented containers instead of a flat row list. Inner
rows indent past the bar via HierarchyRowIndent.
* Root group label switched to boldLabel (was miniBoldLabel).
DrawColRow now takes the row's index + total so single vs multi-COL
naming convention can be evaluated.
https://claude.ai/code/session_01Cm3FDtRJqnEyDT2qaJUfkT
…nsity diagnostics
fprintf(stdout, ...) from a native DLL on Windows doesn't reach Unity's Console — Unity Editor isn't attached to the process stdout. That's why the previous marker line never showed up despite the patched DLL being loaded. Replaced with: - xatlasIsPatchedBuild() C export returning the magic 0x5A5A5A5A - C# probe call at the start of RunPackCancelable that logs via UvtLog (managed → Debug.Log → Unity Console) - Falls back to a Warn log if the symbol is missing, which lets us tell apart "patched DLL with passthrough bug" from "stock DLL still cached" Look for either of these lines per pack: [xatlas-bridge] probe=0x5A5A5A5A preserveChartScale=1 (expected …) [xatlas-bridge] STOCK DLL loaded (patched probe failed: …)
Probe confirms patched DLL is loaded and preserveChartScale=1 is being passed in, but density spread still measures ~14×. Either the if(!s_preserveChartScale) gate isn't taken at runtime (some scope or linkage issue I'm missing) or there's a second per-chart scale step downstream that the upstream Issue #18 discussion didn't cover. Adds two counters bumped from inside Stage B itself: - s_stageBSeenCount — entered the "if (extents>0)" block - s_stageBRescaledCount — actually ran the rescale loop After PackCharts returns, C# reads them via xatlasGetStageBSeenCount / xatlasGetStageBRescaledCount and logs: [xatlas-bridge] post-pack: flag=N stageB-seen=K stageB-rescaled=M Expected on patched + flag=1: seen=149 (number of charts), rescaled=0. If rescaled>0 with flag=1 → the gate isn't taking the patched branch. If rescaled=0 but density 14× → the rescale loop isn't the source; look for a second scale step.
Approach swap: instead of patching xatlas's per-chart ceil(extents) rescale (xatlas.cpp:8345-8362, upstream Issue #18 wontfix), prepare the input so that ceil() becomes a no-op. SnapShellsToIntegerPixels scales each shell per-axis around its UV centroid so the bbox extent is already an integer number of atlas texels. Uniform per-shell texel density survives the pack without forking xatlas. Native: - Drop vendored Native~/third_party/xatlas/ (~10K LOC). - CMakeLists.txt back to FetchContent_Declare(xatlas) against upstream master. - xatlas-unity-bridge.cpp: drop probe / Stage B counters / preserveChartScale. Managed: - XatlasRepack.SnapShellsToIntegerPixels — per-axis pre-pack snap, called from RepackSingle and RepackMulti after TexelDensityNormalizer.Normalize. - RepackOptions.snapShellsToIntegerPixels (default true) replaces preserveChartScale. - UvToolContext.SnapShellsToIntegerPixels + UI toggle in Pre-pack panel. Codex review fixes: - ExecSweep: force RepackResolutionMode = Manual for the sweep, restore on exit. Previously AutoFromTexelDensity overrode every cell's atlasRes and collapsed the resolution dimension of the sweep. - BenchmarkRecorder.SetResolvedAtlasResolution — ExecRepackCore stamps the recorder with the resolution xatlas actually packed at, so the atlasRes column reflects post auto-compute, not the raw ctx setting. - ExecFullPipeline / ExecTransferAll RecordMesh loops skip entries with include == false (user-deselected meshes were surfacing as failed rows in sweep aggregates). - BenchmarkSweep totalMs uses pipelineMs (already wraps inner stages); falls back to repack+transfer+validate only for standalone runs.
Previously SnapShellsToIntegerPixels aligned shell extents to integer texels at tpu = opts.resolution, but xatlasPackCharts was called with texelsPerUnit = 0 — so xatlas auto-computed its own tpu and grew the atlas (e.g. 256 requested → 401×407 packed). The snap grid and the pack grid never matched, so xatlas's Stage B ceil(extents * tpu) fired anyway and density variance stayed ~24× (only post-pack correction brought it down to 3.76×). Fix: compute tpu = internalRes × sqrt(targetUvCoverage) on the C# side (ComputeEffectiveTpu) and pass that same value to both SnapShellsToIntegerPixels and xatlasPackCharts. After TexelDensityNormalizer the total chart area equals targetUvCoverage, so total texel-space area equals internalRes² × targetUvCoverage — xatlas does not need to grow the atlas, the snap grid is preserved end-to-end, and ceil() becomes a no-op for every chart. SnapShellsToIntegerPixels signature changed from `uint atlasRes` to `float tpu` to consume the same value the pack call uses.
The previous fix (passing effectiveTpu to xatlas) made the atlas land at the requested 256×256 instead of 401×407, so the snap grid and pack grid finally agreed on resolution. But density variance only dropped a little: maxRatio stayed at 20× postAssign and 3738× in xatlasRaw chart areas. snap maxScale of 1.51 in the log shows the snap itself ran cleanly. Root cause: xatlas rotates each chart to minimise its axis-aligned bbox BEFORE computing extents. So even when the input UV bbox is integer-pixel aligned, the post-rotation bbox is fractional and Stage B ceil(extents * tpu) amplifies it again. The snap is done on the pre-rotation UVs, so it has no effect. Fix: when snap is on, force rotateCharts=0 and rotateChartsToAxis=0 so xatlas operates on the snapped input UVs directly. Pack efficiency drops slightly without rotation, but for lightmap UV2 uniform density is the priority — that's the whole point of the snap.
The snap was aligning shell extents to integer texels at tpu=221.7, but xatlas multiplies chart UVs by sqrt(surfaceArea3D/parametricAreaUV)*tpu before its Stage B ceil() rescale (xatlas.cpp:8318). For our post-Normalize density (au/a3 = 0.0728), that sqrt factor is 3.706, so the real per-vertex multiplier inside xatlas is 821.5, not 221.7. Snapping to 221.7-grid lands at fractional positions in xatlas's pack space — Stage B ceil() then amplifies sub-pixel ribbons up to ~20x in area, which matches what we saw in the log (postAssign maxRatio=23x). Fix: compute the actual snap target with ComputeXatlasSnapTpu, which multiplies effectiveTpu by sqrt(sum3D/sumUV). For uniform-density input that's the same per-chart factor xatlas will apply, so snapped extents stay integer through Stage B.
Two bugs found by reading xatlas.cpp closely: 1) For UvMesh input, xatlas hardcodes surfaceArea = parametricArea (xatlas.cpp:8255). The per-chart scale collapses from sqrt(s/p)*tpu to plain tpu. My previous commit added that sqrt factor — it was wrong and made snap target the wrong grid. Reverted ComputeXatlasSnapTpu, snap now uses effectiveTpu directly. 2) When PackOptions has texelsPerUnit>0 AND resolution>0, xatlas.cpp:8367-8385 force-clamps every chart whose post-scale extent exceeds (resolution - 2*padding) by per-chart rescale. That rescale is the actual source of the 23x postUV2 density variance — large shells get squeezed independently, breaking the uniform density set up by TexelDensityNormalizer. Fix: when snap is on, pass resolution=0 to xatlasPackCharts so xatlas only packs charts and lets the atlas grow to fit. The user-facing resolution becomes purely a downstream normalisation target. Combined with rotateCharts=0 (from previous commit), the input snap grid should now survive end-to-end: integer pixel extents in, no per-chart rescale, no rotation, integer pixel extents out.
postUV2 maxRatio stayed byte-identical at 23.11x across three very different xatlas configurations (sqrt(s/p) on/off, resolution=256 vs 0, rotateCharts on/off). That meant the variance wasn't coming from xatlas at all — it was already baked into the UVs we hand xatlas. Cause: PerturbOverlapShellsUv0 runs *after* SnapShellsToIntegerPixels and rescales each overlap-group member by 1 + g×strength around the group rep's centroid. For a 92-shell overlap group that compounds into a 1.0..(1+91×s) range of scales and translations per shell. Shells that were snapped to integer-pixel extents leave perturb with fractional extents, and xatlas's Stage B ceil() then amplifies them back into the 23x density variance. Fix: swap the order — perturb first, snap second. Snap now operates on the final pre-xatlas UVs, so the integer-pixel grid actually survives into xatlas.
Five commits of pre-pack integer-pixel snap (716f8bd..9c03555) all failed to reduce density variance. Net result on Carousel: postUV2 density spread grew from ~14x (no snap) to 20-23x with snap, and PostPackDensityCorrection went from 2.95x to 4x — i.e. the snap made things measurably worse. Math shows why: xatlas's per-chart scale = sqrt(s/p) × tpu, computed from current parametricArea. Our per-shell snap (sx, sy) around the centroid changes p by sx×sy, so xatlas's scale rebalances by 1/sqrt(sx×sy) and partially undoes the snap. For anisotropic snap (sx ≠ sy), post-xatlas pixel extent = original × sqrt(sx/sy) — still not integer, so Stage B ceil() still amplifies. For isotropic snap (sx = sy = s), xatlas's scale = 1/s fully undoes the snap. No combination of pre-pack manipulation can survive xatlas's per-chart parametricArea recompute. Only options that would work are (a) forking xatlas, (b) skipping its pack stage via a custom packer, or (c) computing density post-pack and shrinking (which is what PostPackDensityCorrection already does). Reverts: - SnapShellsToIntegerPixels + ComputeEffectiveTpu helpers - RepackOptions.snapShellsToIntegerPixels field - UvToolContext.SnapShellsToIntegerPixels field - UI toggle "Snap shells to integer atlas pixels (pre-pack)" - forced rotateCharts=0 / resolution=0 branches in pack calls - swapped perturb/snap order Now pipeline is: Normalize (uniform au/a3 in UV0) → Perturb (break xatlas dedup on overlap groups) → xatlas pack → PostPackCorrection (shrink-only fix to ~3x density spread). Same as before snap experiment. Documented findings in Documentation~/EXPERIMENTS.md so the next iteration won't repeat the same dead ends.
Defaults - internalOversample: 1 → 4. xatlas's Stage B does ceil(extent)/extent per-axis per-chart; sub-pixel shells get massive amplification at the user-facing 256 resolution. Running the pack internally at 4× resolution turns a 0.25 px shell into a 1.0 px shell, dropping amplification from 4× to 1× on typical shells. Output is normalised via /atlasW so the effective user atlas stays at opts.resolution. - rotateChartsToAxis: true → false. For UvMesh input (repack of existing UVs) PCA rotation is an extra mutation that shrinks pixel extents and worsens Stage B amplification. rotateCharts (90° pack placement) stays on. Diagnostic - LogStageBRisk predicts xatlas Stage B amplification BEFORE xatlas runs: estimates tpu the way xatlas does (sqrt(res²/(area/0.75))) and computes per-shell ceil(extent)/extent on each axis. Logs worst shell, count of sub-pixel shells, count with areaBoost>1.5× and >3×. Top-5 worst go to Verbose log. - Called twice in RepackSingle and RepackMulti: postNormalize (baseline) and postPerturb (to see if PerturbOverlapShellsUv0 introduces extra amplification risk). This lets us see whether the remaining ~3× density spread is from Stage B on real sub-pixel ribbons or from something else in the pipeline. Combined with the oversample bump, expected outcome is that DensityRisk subPixel count drops, postUV2 maxRatio drops toward ~2× without post-pack correction.
…le=4 Two issues found by the DensityRisk diagnostic: 1) PerturbOverlapShellsUv0 was scaling each overlap-group member by 1 + g×strength around the group rep's centroid. For a 92-shell group with strength=0.03 that compounds to scale=3.73 → area ×14. On the Carousel sample sumUV jumped from 0.75 to ~73 (97× growth) which collapses xatlas's auto-computed tpu from ~256 to ~104, pushing every shell back into the sub-pixel regime — exactly what we were trying to avoid. The whole purpose of Perturb was to break xatlas's UV-similarity dedup, but xatlas does NOT dedup UvMesh charts that way: at addUvMeshCharts → ComputeUvMeshChartsTask (xatlas.cpp:6228-6275) it segments faces into charts by faceMaterial (our shellID, unique per shell) plus colocal-UV walk gated by vertexToChartMap. Distinct shellIDs always land in distinct charts regardless of UV overlap. Perturb was a costly no-op. Removed the calls from RepackSingle and RepackMulti. The PerturbOverlapShellsUv0 helper itself is kept (internal) in case a non-UvMesh path ever needs UV-dedup mitigation — but if so it should be an area-preserving shear, not cumulative scale. 2) UvToolContext.InternalOversample default was 1 — it overrode the RepackOptions.Default = 4 set in the previous commit via ExecRepackCore's opts.internalOversample = ctx.InternalOversample. Bumped UvToolContext.InternalOversample to 4 so the default actually reaches xatlas. Diagnostic also simplified: one [DensityRisk:prePack] log right before xatlas, instead of pre/post-Perturb pair.
Captures the working configuration from a218a2b in EXPERIMENTS.md: - InternalOversample=4 default in both RepackOptions and UvToolContext - rotateChartsToAxis=false in RepackOptions.Default - Removal of PerturbOverlapShellsUv0 from both pack paths (xatlas does not dedup UvMesh charts by UV similarity) - [DensityRisk:prePack] diagnostic that predicts Stage B amplification with the same tpu formula xatlas uses internally Plus measured before/after on the Carousel sample (149 shells, 5 overlap groups). End-to-end density spread 14x to 1.17x, atlas utilization 28-34% to 55%, all 149 shells within +/-10% of median. Known regressions left for next session: - Pack at internalRes=1024 (4x oversample of 256) is noticeably slower under bruteForce — needs a soft fallback to heuristic pack above some cost threshold. - Transfer quality dropped on some test meshes — likely from the enlarged atlas (1389x1360) shifting epsilon thresholds in GroupedShellTransfer overlap detection. Needs a sweep over the test suite and either an epsilon fix or early normalisation.
BenchmarkSweep.cs: - Skip winner.json / index.html when every cell scored -Infinity (all hadFailure=true). Was emitting summaries[0] as winner from entirely invalid data. Now writes only summary.csv and logs a warning. (P2) - Mark a run as hadFailure=true when targetRowCount == 0. Failure detection previously only ran inside the !isSource branch, so a CSV with no target-LOD rows (single-LOD model or all targets dropped) passed scoring with zeroes. (P1) - Include atlasUtilization == 0 in the mean. Was filtering util > 0f, which dropped zero rows (failed/degenerate outputs) and inflated the mean by keeping only good rows. Now counts every successfully- parsed numeric value; only missing/unparseable values skip. (P1) LightmapTransferTool.cs: - Use yyyyMMdd_HHmmss_fff for sweep_<stamp> directory. Two sweeps in the same second used to land in the same folder and overwrite each other's summary/winner artefacts. (P2) FbxMetricsExporter.cs: - Stop concatenating "modelName:lodGroupName" into the modelName column. Pass them as separate args to AnalyzeMesh — the lodGroup is already its own column, so the concatenation duplicated dimensions and broke grouping by raw model identifier. (P2)
…JYTm Add benchmark recording & parameter sweep infrastructure
…et LOD meshes are included. Update documentation to reflect this change and add tests for target detection logic.
…gressions [codex] Fix oversample repack and transfer regressions
…-ui-STbHM # Conflicts: # CHANGELOG.md # Documentation~/EXPERIMENTS.md # Editor/Tools/VertexColorBakingTool.cs
SashaRX
pushed a commit
that referenced
this pull request
Jul 19, 2026
Consolidates the 3 bake-ao commits not yet in the prefab-builder branch (bb8df3b Codex-review batch, 68fb1cd menu submenu, dab24f8 CS0266 fix) into the #111 rework so the prefab-builder branch carries all of #103. All files auto-merged except Editor/Tools/PrefabBuilderTool.cs, which the rework rewrote. Resolved by keeping the rewrite and re-porting bb8df3b's Undo/prefab-safety fixes into the surviving FixMerge path: - Undo.RecordObject(ctx.LodGroup, "Merge") before the renderer-array rewrite (so undo restores the LODGroup's renderer list, not just the GameObjects). - RecordPrefabInstancePropertyModifications after SetLODs (so the merge persists on prefab instances across scene reload / reapply). bb8df3b's move-between-LODs hunk has no target — that path was removed in the rework ("reorder removed"), so nothing to port there. UNVERIFIED: no Unity compile in this environment. Staging branch for the #111 consolidation — compile in Unity before finalizing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First slice of the Prefab Builder UI rework. Replaces the old
Build PipelineandLOD Levelsfoldouts with a single Hierarchy section matching the new mockup: Root row with greenApply Names+ per-Dummy collapsible blocks containing LOD rows (rename / regen / delete / channel badges / quality slider) and COL rows tinted green at the bottom.+ Add LODinterleaves between rows for in-place insertion.Scope (PR-1 of the multi-PR plan)
UV0·UV1·VC·N·T)↻regenerates from LOD0 source viaMeshSimplifier, using the row's quality slider+ Add LODinserts a slot mid-stack viaLodGroupUtility.ApplyLodsand generates the new mesh✕removes renderer + slot in a single Undo groupApply Namesis two-step: commits pending Root/Dummy edits, then rebuilds child names from the canonical<prefix>_LOD{N}/<prefix>_COL[_Hull{N}]pattern. Always enabled so user can resync after Insert/Delete↑↓reorder removed (kept simple per the spec)buildIntentso the PR-3 save flow can pick narrow vs wide FBX re-saveRemoved
Build Pipelinefoldout (Open Prefab / Generate LODs / Validate / Save) — Save returns in PR-3 as a bottom bar with pre-flight validationLOD Levelsfoldout — absorbed into the new HierarchyNormalizeHierarchy+GroupMeshChildrenByMaterial+NormalizeChildScales+BakeMatrixIntoMesh— can come back as explicit right-panel actions later if neededUntouched (migrate later)
Collision,Split / Merge,Mesh Info,Edge / Problemsections — move to the right-side settings stack in PR-2Net change
−417 lines (605 added / 1021 deleted) in
Editor/Tools/PrefabBuilderTool.cs.Branching
Targets
claude/bake-ao-single-hierarchy-27AwS(#103). After #103 merges to main, this branch will be rebased onto main.Test plan
+ Add LODinserting between LOD0 and LOD1 — new slot appears, transition heights stay monotonic, simplified mesh produced✕deletes only the targeted row (single-renderer slot drops, multi-renderer slot keeps siblings)6000.0.33f1https://claude.ai/code/session_01Cm3FDtRJqnEyDT2qaJUfkT
Generated by Claude Code