Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions Documentation~/EXPERIMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,24 @@
- Per-shell pre-pack snap — xatlas пересчитает per-chart scale от изменённой parametricArea и отменит snap (см. предыдущий эксперимент).
- `rotateChartsToAxis = true` для repack existing UVs — мутирует extents.

**Известные регрессии после a218a2b (требуют отдельной сессии):**
- Пакинг при `internalOversample = 4` сильно дольше: brute-force pack cost ~ O(N × W × H), при 1024² и bruteForce=true может быть 16× медленнее vs 256². Возможно нужен soft fallback на heuristic pack для `internalRes ≥ N`.
- Transfer стал кривее на некоторых тестовых моделях — вероятно из-за роста atlas size (1389×1360 вместо 256×256), что влияет на ε-параметры в `GroupedShellTransfer` (overlap detection thresholds в pixel space). Нужно прогнать transfer-чекер по test suite и поправить ε или нормализовать early.
## Эксперимент 2026-05-13 — Oversample heuristic pack + atlas-scaled UV2 tolerances

**Контекст:** после `a218a2b` default `internalOversample = 4` сохранил density spread, но поднял внутренний xatlas pack с 256² до 1024². Старый preflight отключал brute force только по `shellCount × internalRes² > 500M`; Carousel-кейс 149 × 1024² ≈ 156M оставался ниже budget, хотя wall-time стал ощутимо хуже. В transfer path часть UV2 tolerances оставалась в normalized-space константах (`0.005`, `0.01`), что при resolved atlas 1389×1360 превращало ~1.3px старого допуска в ~6.8px.

**Изменение 1 (repack):**
- `XatlasRepack.ResolvePackBruteForce()` теперь отключает native `bruteForce` при `internalOversample > 1`, даже если stored UI preference включён.
- Старый safety budget остаётся для `internalOversample = 1`; heuristic safety budget по-прежнему запрещает огромные packs.
- UI делает `Brute force pack` недоступным при oversample выше 1× и явно показывает effective packer = heuristic.

**Изменение 2 (transfer):**
- `RepackResult.atlasWidth/atlasHeight` сохраняются в `MeshEntry.repackedAtlasWidth/repackedAtlasHeight`.
- `GroupedShellTransfer.Transfer()` принимает resolved source atlas size и переводит UV2 pixel margins через `pixels / min(atlasW, atlasH)`.
- Legacy fallback остаётся прежним (`0.005`, `0.01`) для source meshes с existing UV2 или неизвестным atlas size.
- Full pipeline теперь явно пропускает transfer/auto-tune, если нет включённых target LOD meshes, вместо трёх source-only repack попыток с `coverage=0%`.

**Проверка:**
- EditMode red/green: `PackPreflight_DisablesBruteForce_WhenInternalOversampleIsAboveOne`.
- EditMode red/green: `BruteForceOption_IsUnavailable_WhenInternalOversampleIsAboveOne`.
- 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`.
2 changes: 1 addition & 1 deletion Editor/FbxMetricsExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public static void ExportForSceneLodGroup()
if (r == null) continue;
var mf = r.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null) continue;
var row = AnalyzeMesh(lod.name, lodIdx, r.name, mf.sharedMesh);
var row = AnalyzeMesh(lod.name, lod.name, lodIdx, r.name, mf.sharedMesh);
rows.Add(row);
UvPngWriter.Render(Path.Combine(outDir, "png",
$"{Sanitize(lod.name)}_LOD{lodIdx}_{Sanitize(r.name)}_uv0.png"),
Expand Down
6 changes: 6 additions & 0 deletions Editor/Framework/MeshEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ public class MeshEntry
/// Destroyed on pipeline reset or window close.
/// </summary>
public Mesh repackedMesh;
/// <summary>
/// Resolved xatlas dimensions used to produce <see cref="repackedMesh"/>.
/// Zero when UV2 came from an existing asset rather than this repack run.
/// </summary>
public uint repackedAtlasWidth;
public uint repackedAtlasHeight;

/// <summary>
/// UV2-transferred mesh for target LODs. Null until the Transfer step runs.
Expand Down
2 changes: 2 additions & 0 deletions Editor/Framework/UvToolHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ void RestoreWorkingMeshes()
e.meshFilter.sharedMesh = e.fbxMesh;
if (e.transferredMesh != null) { DestroyImmediate(e.transferredMesh); e.transferredMesh = null; }
if (e.repackedMesh != null) { DestroyImmediate(e.repackedMesh); e.repackedMesh = null; }
e.repackedAtlasWidth = 0;
e.repackedAtlasHeight = 0;
if (e.originalMesh != null && e.originalMesh != e.fbxMesh) { DestroyImmediate(e.originalMesh); e.originalMesh = null; }
}
}
Expand Down
40 changes: 30 additions & 10 deletions Editor/GroupedShellTransfer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -760,11 +760,29 @@ static void RescoreMergedShells(
// fewer inverted/zero-area triangles.
// ═══════════════════════════════════════════════════════════

static float ComputeUv2PixelMargin(int atlasWidth, int atlasHeight, float pixels, float fallback)
{
int w = Mathf.Max(0, atlasWidth);
int h = Mathf.Max(0, atlasHeight);
int dim = (w > 0 && h > 0) ? Mathf.Min(w, h) : Mathf.Max(w, h);
return dim > 0 ? pixels / dim : fallback;
}

public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh,
List<OverlapSourceHint> previousLodHints = null,
List<CrossLodMatchHint> previousLodMatchHints = null)
List<CrossLodMatchHint> previousLodMatchHints = null,
int sourceAtlasWidth = 0,
int sourceAtlasHeight = 0)
{
var result = new TransferResult();
float uv2OobMargin = ComputeUv2PixelMargin(sourceAtlasWidth, sourceAtlasHeight, 1.25f, 0.005f);
float uv2BoundsTolerance = ComputeUv2PixelMargin(sourceAtlasWidth, sourceAtlasHeight, 2.5f, 0.01f);
if (sourceAtlasWidth > 0 || sourceAtlasHeight > 0)
{
UvtLog.Verbose(UvtLog.Category.Match,
$"[GroupedTransfer] UV2 tolerances from atlas {sourceAtlasWidth}x{sourceAtlasHeight}: " +
$"oobMargin={uv2OobMargin:F6}, boundsTol={uv2BoundsTolerance:F6}");
}

// Source data
var srcVerts = sourceMesh.vertices;
Expand Down Expand Up @@ -2028,7 +2046,7 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh,
srcTransforms[si],
srcIsRibbon[si], srcRibbonAxis[si], srcRibbonAxis2[si], srcRibbonCentroid[si],
srcUv2Min, srcUv2Max, groupMembers,
kRayMaxDist);
kRayMaxDist, uv2BoundsTolerance);

var best = SelectBestCandidate(allCandidates, tShell.faceIndices, tgtTris, tUv0);
if (best.HasValue)
Expand Down Expand Up @@ -2753,7 +2771,7 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh,
srcIsRibbon[chosenSrc], srcRibbonAxis[chosenSrc],
srcRibbonAxis2[chosenSrc], srcRibbonCentroid[chosenSrc],
srcUv2Min, srcUv2Max, null,
kRayMaxDist);
kRayMaxDist, uv2BoundsTolerance);

var bestOverlap = SelectBestCandidate(
overlapCandidates, tShell.faceIndices, tgtTris, tUv0);
Expand Down Expand Up @@ -2848,7 +2866,7 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh,
// the outliers using the matched source's constrained UV0 lookup.
if (bestMergedUv2 != null && bestMergedUv2.Count > 1 && chosenSrc >= 0)
{
const float kUv2Margin = 0.005f;
float kUv2Margin = uv2OobMargin;
Vector2 sMin = srcUv2Min[chosenSrc];
Vector2 sMax = srcUv2Max[chosenSrc];

Expand Down Expand Up @@ -3084,7 +3102,7 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh,
// Penalize xform if it extrapolates beyond source shell's UV2 bounds.
// Extrapolation is the primary cause of cross-source UV2 overlaps,
// since interp stays within source UV2 convex hull by construction.
const float kOobMargin = 0.005f;
float kOobMargin = uv2OobMargin;
Vector2 srcBMin2 = srcUv2Min[chosenSrc];
Vector2 srcBMax2 = srcUv2Max[chosenSrc];
Vector2 xfBMin = new Vector2(float.MaxValue, float.MaxValue);
Expand Down Expand Up @@ -3494,7 +3512,8 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh,
{
var uv = result.uv2[i];
uvMin = Vector2.Min(uvMin, uv); uvMax = Vector2.Max(uvMax, uv);
if (uv.x < -0.01f || uv.x > 1.01f || uv.y < -0.01f || uv.y > 1.01f) oob++;
if (uv.x < -uv2BoundsTolerance || uv.x > 1f + uv2BoundsTolerance ||
uv.y < -uv2BoundsTolerance || uv.y > 1f + uv2BoundsTolerance) oob++;
}
if (oob > 0)
UvtLog.Warn($"[GroupedTransfer] '{targetMesh.name}': {oob} verts outside 0-1! " +
Expand Down Expand Up @@ -4099,7 +4118,7 @@ static List<OverlapCandidate> GenerateOverlapCandidates(
// Cross-source UV2 guard data
Vector2[] srcUv2Min, Vector2[] srcUv2Max, List<int> overlapGroupMembers,
// Thresholds
float kRayMaxDist)
float kRayMaxDist, float uv2BoundsTolerance)
{
var candidates = new List<OverlapCandidate>();
int[] tgtTris = null; // not needed — issues counted by caller
Expand Down Expand Up @@ -4280,7 +4299,8 @@ static List<OverlapCandidate> GenerateOverlapCandidates(
foreach (var kv in uv2Map)
{
Vector2 uv = kv.Value;
if (uv.x < -0.01f || uv.x > 1.01f || uv.y < -0.01f || uv.y > 1.01f)
if (uv.x < -uv2BoundsTolerance || uv.x > 1f + uv2BoundsTolerance ||
uv.y < -uv2BoundsTolerance || uv.y > 1f + uv2BoundsTolerance)
{
partXfRejected = true;
break;
Expand Down Expand Up @@ -4368,8 +4388,8 @@ static List<OverlapCandidate> GenerateOverlapCandidates(
}

// Reject if result goes outside 0-1 range (catches wild extrapolation)
if (xfMin.x < -0.01f || xfMax.x > 1.01f ||
xfMin.y < -0.01f || xfMax.y > 1.01f)
if (xfMin.x < -uv2BoundsTolerance || xfMax.x > 1f + uv2BoundsTolerance ||
xfMin.y < -uv2BoundsTolerance || xfMax.y > 1f + uv2BoundsTolerance)
rejected = true;

// Reject if result extends too far beyond source's UV2 AABB
Expand Down
66 changes: 59 additions & 7 deletions Editor/Tools/LightmapTransferTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ public class LightmapTransferTool : IUvTool
public int ToolOrder => 0;
public Action RequestRepaint { set => requestRepaint = value; }

static bool IsBruteForcePackAvailable(int internalOversample)
{
int oversample = internalOversample > 0 ? internalOversample : 1;
return oversample <= 1;
}

static bool HasIncludedTransferTargets(IEnumerable<MeshEntry> entries, int sourceLodIndex)
{
if (entries == null) return false;
foreach (var e in entries)
{
if (e == null) continue;
if (!e.include) continue;
if (e.lodIndex == sourceLodIndex) continue;
if (e.originalMesh == null) continue;
return true;
}
return false;
}

// ── Internal tab ──
enum Tab { Setup, Repack, Transfer }
Tab tab = Tab.Setup;
Expand Down Expand Up @@ -583,18 +603,24 @@ void DrawRepack()
+ "Oversampling makes ceil rounding fractional. UV2 still "
+ "normalized to [0,1]; Unity bakes at its own resolution.\n\n"
+ "Default 4× brings density spread from ~14× down to ~2×.\n"
+ "8-16× brings spread to ~1.1× but DISABLES brute force pack "
+ "2× and above disable brute force pack "
+ "automatically (search space becomes minutes-per-atlas).\n"
+ "1× = off, original xatlas behaviour."),
osIdx, osLabels);
ctx.InternalOversample = osValues[Mathf.Clamp(newOsIdx, 0, osValues.Length - 1)];
}
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("xatlas options", EditorStyles.miniBoldLabel);
ctx.XatlasBruteForce = EditorGUILayout.ToggleLeft(
new GUIContent("Brute force pack",
"Run xatlas's exhaustive packer (slower, tighter atlas). Off by default."),
ctx.XatlasBruteForce);
bool bruteForceAvailable = IsBruteForcePackAvailable(ctx.InternalOversample);
using (new EditorGUI.DisabledScope(!bruteForceAvailable))
{
ctx.XatlasBruteForce = EditorGUILayout.ToggleLeft(
new GUIContent("Brute force pack (1× only)",
"Run xatlas's exhaustive packer (slower, tighter atlas). Only active when Internal pack oversample is 1×; 2× and above use the heuristic packer automatically."),
ctx.XatlasBruteForce);
}
if (!bruteForceAvailable)
EditorGUILayout.LabelField("Effective packer", "Heuristic (oversample > 1)", EditorStyles.miniLabel);
ctx.XatlasRotateCharts = EditorGUILayout.ToggleLeft(
new GUIContent("Rotate charts",
"xatlas may rotate charts to fit better (recommended)."),
Expand Down Expand Up @@ -1238,6 +1264,10 @@ bool ExecFullPipelineCore()
savedMeshes[e] = UnityEngine.Object.Instantiate(e.originalMesh);

float[] separationConfigs = { 0.10f, 0.05f, 0.20f };
bool hasTransferTargets = HasIncludedTransferTargets(ctx.MeshEntries, ctx.SourceLodIndex);
if (!hasTransferTargets)
UvtLog.Warn("[Pipeline] No included target LOD meshes; running source repack only and skipping transfer/auto-tune.");

int bestRejected = int.MaxValue;
float bestCoverage = 0f;
int bestConfigIdx = 0;
Expand Down Expand Up @@ -1271,6 +1301,8 @@ bool ExecFullPipelineCore()
kv.Key.originalMesh.name = kv.Value.name;
kv.Key.wasSymmetrySplit = false;
kv.Key.repackedMesh = null;
kv.Key.repackedAtlasWidth = 0;
kv.Key.repackedAtlasHeight = 0;
kv.Key.transferredMesh = null;
kv.Key.shellTransferResult = null;
}
Expand All @@ -1293,7 +1325,11 @@ bool ExecFullPipelineCore()
else ExecRepack(src);

// 5. Transfer
if (ctx.HasRepack) ExecTransferAll();
if (ctx.HasRepack && hasTransferTargets) ExecTransferAll();
else if (ctx.HasRepack) ctx.HasTransfer = false;

if (!hasTransferTargets)
break;

// Evaluate quality
int totalRejected = 0;
Expand Down Expand Up @@ -1469,9 +1505,13 @@ void ExecRepackCore(List<MeshEntry> entries)
{
UvtLog.Error("[Repack] " + validEntries[i].renderer.name + ": " + results[i].error);
UnityEngine.Object.DestroyImmediate(meshCopies[i]);
validEntries[i].repackedAtlasWidth = 0;
validEntries[i].repackedAtlasHeight = 0;
continue;
}
validEntries[i].repackedMesh = meshCopies[i];
validEntries[i].repackedAtlasWidth = results[i].atlasWidth;
validEntries[i].repackedAtlasHeight = results[i].atlasHeight;
}

ctx.HasRepack = true;
Expand Down Expand Up @@ -1500,6 +1540,14 @@ void ExecTransferAll()
BenchmarkRecorder.Current?.StageBegin("transfer");
try
{
if (!HasIncludedTransferTargets(ctx.MeshEntries, ctx.SourceLodIndex))
{
ctx.HasTransfer = false;
UvtLog.Warn("[Transfer] No included target LOD meshes; transfer skipped.");
requestRepaint?.Invoke();
return;
}

accumulatedOverlapHints.Clear();
accumulatedMatchHints.Clear();
for (int li = 0; li < ctx.LodCount; li++)
Expand Down Expand Up @@ -1558,7 +1606,9 @@ void ExecTransferLod(int tLod)

var tr = GroupedShellTransfer.Transfer(tgtMesh, srcMesh,
accumulatedOverlapHints.Count > 0 ? accumulatedOverlapHints : null,
accumulatedMatchHints.Count > 0 ? accumulatedMatchHints : null);
accumulatedMatchHints.Count > 0 ? accumulatedMatchHints : null,
srcEntry.repackedAtlasWidth > 0 ? (int)srcEntry.repackedAtlasWidth : 0,
srcEntry.repackedAtlasHeight > 0 ? (int)srcEntry.repackedAtlasHeight : 0);
if (tr.uv2 == null) { UvtLog.Warn($"[Transfer] Failed for '{tgt.renderer.name}'"); continue; }

// Accumulate overlap hints for subsequent LODs
Expand Down Expand Up @@ -3620,6 +3670,8 @@ void ResetWorkingCopies()
e.meshFilter.sharedMesh = e.fbxMesh;
if (e.transferredMesh != null) { UnityEngine.Object.DestroyImmediate(e.transferredMesh); e.transferredMesh = null; }
if (e.repackedMesh != null) { UnityEngine.Object.DestroyImmediate(e.repackedMesh); e.repackedMesh = null; }
e.repackedAtlasWidth = 0;
e.repackedAtlasHeight = 0;
if (e.originalMesh != null && e.originalMesh != e.fbxMesh) UnityEngine.Object.DestroyImmediate(e.originalMesh);
if (e.fbxMesh != null) e.originalMesh = e.fbxMesh;
e.shellTransferResult = null;
Expand Down
Loading
Loading