Skip to content

Prefab Builder: new Hierarchy view (PR-1) - #111

Open
SashaRX wants to merge 175 commits into
claude/bake-ao-single-hierarchy-27AwSfrom
claude/prefab-builder-ui-STbHM
Open

Prefab Builder: new Hierarchy view (PR-1)#111
SashaRX wants to merge 175 commits into
claude/bake-ao-single-hierarchy-27AwSfrom
claude/prefab-builder-ui-STbHM

Conversation

@SashaRX

@SashaRX SashaRX commented May 3, 2026

Copy link
Copy Markdown
Owner

Summary

First slice of the Prefab Builder UI rework. Replaces the old Build Pipeline and LOD Levels foldouts with a single Hierarchy section matching the new mockup: Root row with green Apply Names + per-Dummy collapsible blocks containing LOD rows (rename / regen / delete / channel badges / quality slider) and COL rows tinted green at the bottom. + Add LOD interleaves between rows for in-place insertion.

Scope (PR-1 of the multi-PR plan)

  • ✅ Hierarchy view with Root + Dummy blocks, per-LOD/per-COL rows, channel badges (UV0·UV1·VC·N·T)
  • ✅ Per-row regenerates from LOD0 source via MeshSimplifier, using the row's quality slider
  • + Add LOD inserts a slot mid-stack via LodGroupUtility.ApplyLods and generates the new mesh
  • removes renderer + slot in a single Undo group
  • 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 user can resync after Insert/Delete
  • ↑↓ reorder removed (kept simple per the spec)
  • ✅ Operations update buildIntent so the PR-3 save flow can pick narrow vs wide FBX re-save

Removed

  • Build Pipeline foldout (Open Prefab / Generate LODs / Validate / Save) — Save returns in PR-3 as a bottom bar with pre-flight validation
  • LOD Levels foldout — absorbed into the new Hierarchy
  • NormalizeHierarchy + GroupMeshChildrenByMaterial + NormalizeChildScales + BakeMatrixIntoMesh — can come back as explicit right-panel actions later if needed

Untouched (migrate later)

  • Collision, Split / Merge, Mesh Info, Edge / Problem sections — move to the right-side settings stack in PR-2
  • Scene preview toolbar (Off / Vert Colors / Normals / UV0-3 / Edges / Problems) — moves to viewport top-bar in PR-4

Net 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

  • Local Unity smoke test on a prefab with flat LOD structure (root + LOD0/1/2 children) — verify Hierarchy renders, Apply Names rebuilds leaf names, slider drives regen
  • Test on a multi-Dummy prefab (root → DummyA/DummyB containers) — verify each Dummy gets its own block, COL rows attach to correct dummy
  • Test + Add LOD inserting between LOD0 and LOD1 — new slot appears, transition heights stay monotonic, simplified mesh produced
  • Test deletes only the targeted row (single-renderer slot drops, multi-renderer slot keeps siblings)
  • Verify Undo collapses each operation into a single step
  • CI: Unity EditMode tests pass on 6000.0.33f1

https://claude.ai/code/session_01Cm3FDtRJqnEyDT2qaJUfkT


Generated by Claude Code

claude and others added 25 commits April 18, 2026 19:52
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.
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
@SashaRX

SashaRX commented May 3, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Editor/Tools/PrefabBuilderTool.cs Outdated
Comment thread Editor/Tools/PrefabBuilderTool.cs Outdated
claude added 3 commits May 3, 2026 20:45
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
github-actions Bot and others added 29 commits May 12, 2026 23:37
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants