// MIT License - Copyright (c) 2026 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Editor.Sprites
{
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
using WallstopStudios.UnityHelpers.Utils;
///
/// Provides multiple algorithms for automatic sprite grid detection.
/// Each algorithm analyzes texture pixel data to determine optimal cell dimensions.
///
public static class SpriteSheetAlgorithms
{
///
/// Minimum confidence threshold for AutoBest algorithm to accept a result early.
/// Set high (90%) to ensure multiple algorithms are tried before accepting a result,
/// since BoundaryScoring can produce high-confidence wrong results on mostly-transparent sheets.
///
public const float AutoBestEarlyStopConfidence = 0.90f;
///
/// Minimum cell size in pixels for valid grid detection.
///
public const int MinimumCellSize = 4;
///
/// Transparency ratio threshold for remainder pixel handling.
/// If remainder column/row has transparency below this value (mostly opaque), adjust cell size to include it.
///
private const float RemainderTransparencyThreshold = 0.9f;
///
/// Maximum score for a fully transparent grid line in the non-linear scoring system.
///
private const float MaxTransparencyLineScore = 10f;
///
/// Hard constraint threshold for sprite-fit validation in FindBestTransparencyAlignedDivisor.
/// Divisors with sprite-fit scores below this are rejected outright.
/// Set very low (0.3) because the core zone concept already handles edge clipping -
/// we only want to reject divisors that split sprites through their centers.
///
private const float SpriteFitHardThresholdStrict = 0.3f;
///
/// Hard constraint threshold for sprite-fit validation in BoundaryScoring.
/// Set very low (0.3) to allow candidates that may touch sprite edges.
///
private const float SpriteFitHardThresholdRelaxed = 0.3f;
///
/// Maximum ratio by which a candidate cell count can differ from the expected count.
/// Candidates with cell counts outside the range [expected/ratio, expected*ratio] are rejected.
///
private const float MaxCellCountRatio = 1.5f;
///
/// Threshold for sprite-fit fallback logic in ClusterCentroid and RegionGrowing.
/// When sprite-fit score is below this, alternative cell sizes are tried.
///
private const float SpriteFitFallbackThreshold = 0.5f;
///
/// Multiplier used in CalculateGapBasedTolerance to determine grouping tolerance.
/// Positions within this fraction of the minimum significant gap are considered the same group.
/// Uses a smaller multiplier (0.25) with minimum gap (not median) for tighter grouping.
///
private const float GapToleranceMultiplier = 0.25f;
///
/// Fraction of sprite width/height that defines the "core zone" for split detection.
/// Only grid lines passing through the middle (this fraction) of a sprite are penalized.
/// Set to 0.8 (80%) - only ignore outer 10% on each side for anti-aliased edge handling.
///
private const float SpriteCoreZoneFraction = 0.8f;
///
/// Minimum gap size (in pixels) between consecutive positions to be considered a significant gap.
/// Gaps smaller than this are treated as noise (positions in the same column/row with slight variations).
///
private const float MinSignificantGapSize = 5f;
///
/// Minimum gap tolerance multiplier for DistanceTransform algorithm.
/// Uses MinimumCellSize * 2 instead of MinimumCellSize because distance transform
/// peaks can be more spread out due to chamfer distance approximation errors.
///
private const int DistanceTransformMinGapToleranceMultiplier = 2;
///
/// Common sprite cell sizes for grid detection candidate generation.
///
private static readonly int[] CommonCellSizes =
{
8,
16,
24,
32,
48,
64,
96,
128,
256,
512,
};
///
/// Result of a grid detection algorithm.
///
public readonly struct AlgorithmResult
{
///
/// The detected cell width in pixels.
///
public readonly int CellWidth;
///
/// The detected cell height in pixels.
///
public readonly int CellHeight;
///
/// Confidence score in the range [0, 1] where 1 is highest confidence.
///
public readonly float Confidence;
///
/// The algorithm that produced this result.
///
public readonly AutoDetectionAlgorithm Algorithm;
///
/// Whether this result represents a valid grid detection.
///
public bool IsValid => CellWidth >= MinimumCellSize && CellHeight >= MinimumCellSize;
public AlgorithmResult(
int cellWidth,
int cellHeight,
float confidence,
AutoDetectionAlgorithm algorithm
)
{
CellWidth = cellWidth;
CellHeight = cellHeight;
Confidence = Mathf.Clamp01(confidence);
Algorithm = algorithm;
}
public static AlgorithmResult Invalid(AutoDetectionAlgorithm algorithm)
{
return new AlgorithmResult(0, 0, 0f, algorithm);
}
}
///
/// Detects optimal grid dimensions using the specified algorithm.
///
/// The texture pixel data in Color32 format.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Alpha value (0-1) below which a pixel is considered transparent.
/// The detection algorithm to use.
/// Optional expected sprite count (required for UniformGrid).
/// When true, adjusts cell sizes to be exact divisors of texture dimensions using transparency-aware analysis.
/// Optional cancellation token for long-running operations.
/// The algorithm result containing detected cell dimensions and confidence.
public static AlgorithmResult DetectGrid(
Color32[] pixels,
int textureWidth,
int textureHeight,
float alphaThreshold,
AutoDetectionAlgorithm algorithm,
int expectedSpriteCount = -1,
bool snapToTextureDivisor = true,
CancellationToken cancellationToken = default
)
{
if (pixels == null || pixels.Length == 0)
{
return AlgorithmResult.Invalid(algorithm);
}
if (textureWidth < MinimumCellSize || textureHeight < MinimumCellSize)
{
return AlgorithmResult.Invalid(algorithm);
}
if (pixels.Length != textureWidth * textureHeight)
{
return AlgorithmResult.Invalid(algorithm);
}
if (alphaThreshold < 0f || alphaThreshold >= 1f)
{
return AlgorithmResult.Invalid(algorithm);
}
switch (algorithm)
{
case AutoDetectionAlgorithm.AutoBest:
return DetectGridAutoBest(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor,
cancellationToken
);
case AutoDetectionAlgorithm.UniformGrid:
return DetectGridUniformGrid(textureWidth, textureHeight, expectedSpriteCount);
case AutoDetectionAlgorithm.BoundaryScoring:
return DetectGridBoundaryScoring(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor
);
case AutoDetectionAlgorithm.ClusterCentroid:
return DetectGridClusterCentroid(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor,
cancellationToken
);
case AutoDetectionAlgorithm.DistanceTransform:
return DetectGridDistanceTransform(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor,
cancellationToken
);
case AutoDetectionAlgorithm.RegionGrowing:
return DetectGridRegionGrowing(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor,
cancellationToken
);
default:
return AlgorithmResult.Invalid(algorithm);
}
}
///
/// AutoBest algorithm: runs algorithms in order of speed until one exceeds 70% confidence.
/// Order: BoundaryScoring (fastest) -> ClusterCentroid -> DistanceTransform -> RegionGrowing
///
private static AlgorithmResult DetectGridAutoBest(
Color32[] pixels,
int textureWidth,
int textureHeight,
float alphaThreshold,
int expectedSpriteCount,
bool snapToTextureDivisor,
CancellationToken cancellationToken
)
{
AlgorithmResult bestResult = AlgorithmResult.Invalid(AutoDetectionAlgorithm.AutoBest);
// Try BoundaryScoring first (fastest)
if (cancellationToken.IsCancellationRequested)
{
return bestResult;
}
AlgorithmResult boundaryResult = DetectGridBoundaryScoring(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor
);
if (boundaryResult.IsValid && boundaryResult.Confidence >= AutoBestEarlyStopConfidence)
{
return new AlgorithmResult(
boundaryResult.CellWidth,
boundaryResult.CellHeight,
boundaryResult.Confidence,
AutoDetectionAlgorithm.AutoBest
);
}
if (boundaryResult.Confidence > bestResult.Confidence)
{
bestResult = boundaryResult;
}
// Try ClusterCentroid
if (cancellationToken.IsCancellationRequested)
{
return ConvertToAutoBest(bestResult);
}
AlgorithmResult clusterResult = DetectGridClusterCentroid(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor,
cancellationToken
);
if (clusterResult.IsValid && clusterResult.Confidence >= AutoBestEarlyStopConfidence)
{
return new AlgorithmResult(
clusterResult.CellWidth,
clusterResult.CellHeight,
clusterResult.Confidence,
AutoDetectionAlgorithm.AutoBest
);
}
if (clusterResult.Confidence > bestResult.Confidence)
{
bestResult = clusterResult;
}
// Try DistanceTransform
if (cancellationToken.IsCancellationRequested)
{
return ConvertToAutoBest(bestResult);
}
AlgorithmResult distanceResult = DetectGridDistanceTransform(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor,
cancellationToken
);
if (distanceResult.IsValid && distanceResult.Confidence >= AutoBestEarlyStopConfidence)
{
return new AlgorithmResult(
distanceResult.CellWidth,
distanceResult.CellHeight,
distanceResult.Confidence,
AutoDetectionAlgorithm.AutoBest
);
}
if (distanceResult.Confidence > bestResult.Confidence)
{
bestResult = distanceResult;
}
// Try RegionGrowing
if (cancellationToken.IsCancellationRequested)
{
return ConvertToAutoBest(bestResult);
}
AlgorithmResult regionResult = DetectGridRegionGrowing(
pixels,
textureWidth,
textureHeight,
alphaThreshold,
expectedSpriteCount,
snapToTextureDivisor,
cancellationToken
);
if (regionResult.Confidence > bestResult.Confidence)
{
bestResult = regionResult;
}
// Try UniformGrid as last resort if expected count is provided
if (expectedSpriteCount > 0)
{
AlgorithmResult uniformResult = DetectGridUniformGrid(
textureWidth,
textureHeight,
expectedSpriteCount
);
if (uniformResult.Confidence > bestResult.Confidence)
{
bestResult = uniformResult;
}
}
return ConvertToAutoBest(bestResult);
}
private static AlgorithmResult ConvertToAutoBest(AlgorithmResult result)
{
if (!result.IsValid)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.AutoBest);
}
return new AlgorithmResult(
result.CellWidth,
result.CellHeight,
result.Confidence,
AutoDetectionAlgorithm.AutoBest
);
}
///
/// UniformGrid algorithm: simple division based on expected sprite count.
/// Tries to find optimal rows/columns that evenly divide the texture.
///
private static AlgorithmResult DetectGridUniformGrid(
int textureWidth,
int textureHeight,
int expectedSpriteCount
)
{
if (expectedSpriteCount <= 0)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.UniformGrid);
}
// Find factor pairs of expectedSpriteCount
int bestColumns = 1;
int bestRows = expectedSpriteCount;
float bestAspectRatio = float.MaxValue;
float textureAspect = (float)textureWidth / textureHeight;
for (int cols = 1; cols <= expectedSpriteCount; ++cols)
{
if (expectedSpriteCount % cols != 0)
{
continue;
}
int rows = expectedSpriteCount / cols;
int cellWidth = textureWidth / cols;
int cellHeight = textureHeight / rows;
// Skip if cells don't divide evenly
if (textureWidth % cols != 0 || textureHeight % rows != 0)
{
continue;
}
// Skip if cells are too small
if (cellWidth < MinimumCellSize || cellHeight < MinimumCellSize)
{
continue;
}
float cellAspect = (float)cellWidth / cellHeight;
float aspectDiff = Mathf.Abs(cellAspect - 1f);
// Prefer square cells, but also consider texture aspect ratio
float gridAspect = (float)cols / rows;
float textureAspectDiff = Mathf.Abs(gridAspect - textureAspect);
float combinedScore = aspectDiff + textureAspectDiff * 0.5f;
if (combinedScore < bestAspectRatio)
{
bestAspectRatio = combinedScore;
bestColumns = cols;
bestRows = rows;
}
}
int finalCellWidth = textureWidth / bestColumns;
int finalCellHeight = textureHeight / bestRows;
// Confidence is high only if cells divide evenly
bool perfectDivision = textureWidth % bestColumns == 0 && textureHeight % bestRows == 0;
float confidence = perfectDivision ? 1.0f : 0.5f;
// Reduce confidence for extreme aspect ratios
float cellAspectRatio = (float)finalCellWidth / finalCellHeight;
if (cellAspectRatio < 0.25f || cellAspectRatio > 4.0f)
{
confidence *= 0.5f;
}
return new AlgorithmResult(
finalCellWidth,
finalCellHeight,
confidence,
AutoDetectionAlgorithm.UniformGrid
);
}
///
/// BoundaryScoring algorithm: scores grid lines by transparent pixel percentage.
/// Also performs sprite-fit validation to penalize grids that would split sprites.
///
private static AlgorithmResult DetectGridBoundaryScoring(
Color32[] pixels,
int textureWidth,
int textureHeight,
float alphaThreshold,
int expectedSpriteCount,
bool snapToTextureDivisor
)
{
byte alphaThresholdByte = (byte)(alphaThreshold * 255f);
using PooledArray columnTransparencyLease = SystemArrayPool.Get(
textureWidth,
out int[] columnTransparencyCount
);
using PooledArray rowTransparencyLease = SystemArrayPool.Get(
textureHeight,
out int[] rowTransparencyCount
);
Array.Clear(columnTransparencyCount, 0, textureWidth);
Array.Clear(rowTransparencyCount, 0, textureHeight);
for (int y = 0; y < textureHeight; ++y)
{
int rowOffset = y * textureWidth;
for (int x = 0; x < textureWidth; ++x)
{
if (pixels[rowOffset + x].a <= alphaThresholdByte)
{
++columnTransparencyCount[x];
++rowTransparencyCount[y];
}
}
}
// Detect sprite bounds for sprite-fit validation
using PooledResource> spriteBoundsLease = Buffers.List.Get(
out List spriteBounds
);
DetectSpriteBoundsByAlpha(
pixels,
textureWidth,
textureHeight,
alphaThresholdByte,
spriteBounds,
default
);
// PRIMARY METHOD: If user provided expectedSpriteCount, use it directly without fallback
if (expectedSpriteCount > 0)
{
int cellWidth;
int cellHeight;
if (
InferGridFromSpriteCount(
expectedSpriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
Debug.Log(
$"[BoundaryScoring] Using InferGridFromSpriteCount: expectedCount={expectedSpriteCount}, cellSize={cellWidth}x{cellHeight}"
);
// User explicitly set sprite count - validate confidence based on cell aspect ratio
float confidence = CalculateUserSpecifiedCountConfidence(
cellWidth,
cellHeight,
textureWidth,
textureHeight
);
return new AlgorithmResult(
cellWidth,
cellHeight,
confidence,
AutoDetectionAlgorithm.BoundaryScoring
);
}
// Fallback to approximate grid when exact division fails
if (
TryInferApproximateGrid(
expectedSpriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
Debug.Log(
$"[BoundaryScoring] Using TryInferApproximateGrid: expectedCount={expectedSpriteCount}, cellSize={cellWidth}x{cellHeight}"
);
return new AlgorithmResult(
cellWidth,
cellHeight,
0.95f, // Slightly lower confidence since not exact
AutoDetectionAlgorithm.BoundaryScoring
);
}
Debug.LogWarning(
$"[BoundaryScoring] Both InferGridFromSpriteCount and TryInferApproximateGrid failed for expectedCount={expectedSpriteCount}, texture={textureWidth}x{textureHeight}"
);
}
// Auto-detection path: use detected sprite count
int spriteCount = spriteBounds.Count;
Debug.Log(
$"[BoundaryScoring] Auto-detection path: detectedSpriteCount={spriteCount}, expectedSpriteCount={expectedSpriteCount}"
);
if (spriteCount >= 2)
{
int cellWidth;
int cellHeight;
if (
InferGridFromSpriteCount(
spriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
// Validate that sprites fit within the inferred cells
float spriteFitScore = CalculateSpriteFitScore(
spriteBounds,
cellWidth,
cellHeight,
textureWidth,
textureHeight
);
// If sprites fit reasonably well, use this result directly
if (spriteFitScore >= 0.5f)
{
return new AlgorithmResult(
cellWidth,
cellHeight,
spriteFitScore,
AutoDetectionAlgorithm.BoundaryScoring
);
}
}
}
// Fallback: use the original candidate-based approach
using PooledResource> widthCandidatesLease = Buffers.List.Get(
out List widthCandidates
);
using PooledResource> heightCandidatesLease = Buffers.List.Get(
out List heightCandidates
);
GenerateCandidateCellSizes(textureWidth, widthCandidates);
GenerateCandidateCellSizes(textureHeight, heightCandidates);
int bestWidth = 0;
int bestHeight = 0;
float bestScore = -1f;
float bestTransparencyScore = 0f; // Track transparency-only score for confidence
for (int wi = 0; wi < widthCandidates.Count; ++wi)
{
int candidateWidth = widthCandidates[wi];
float widthScore = ScoreCellSizeForDimension(
columnTransparencyCount,
textureWidth,
textureHeight,
candidateWidth
);
if (widthScore < 0.15f)
{
continue;
}
for (int hi = 0; hi < heightCandidates.Count; ++hi)
{
int candidateHeight = heightCandidates[hi];
float heightScore = ScoreCellSizeForDimension(
rowTransparencyCount,
textureHeight,
textureWidth,
candidateHeight
);
if (heightScore < 0.15f)
{
continue;
}
// Base transparency score (used for confidence)
float transparencyScore = (widthScore + heightScore) * 0.5f;
// Selection score includes bonuses (used for picking between candidates)
float selectionScore = transparencyScore;
int columns = textureWidth / candidateWidth;
int rows = textureHeight / candidateHeight;
int cellCount = columns * rows;
// HARD CONSTRAINT: Skip candidates that split sprites
if (spriteBounds.Count > 0)
{
float spriteFitScore = CalculateSpriteFitScore(
spriteBounds,
candidateWidth,
candidateHeight,
textureWidth,
textureHeight
);
// HARD CONSTRAINT: Skip candidates that split sprites
if (spriteFitScore < SpriteFitHardThresholdRelaxed)
{
continue;
}
// Bonus for excellent fit
if (spriteFitScore >= 0.95f)
{
selectionScore += 0.1f;
}
}
// Use DETECTED SPRITE COUNT as primary guide for cell count validation
// This is the most reliable signal - the grid should have about as many cells as sprites
int detectedSpriteCount = spriteBounds.Count;
float cellCountRatio =
detectedSpriteCount > 0 ? (float)cellCount / detectedSpriteCount : 1f;
// HARD CONSTRAINT: Cell count should not exceed 2x detected sprites
// (allows some margin for empty cells but prevents over-splitting)
if (detectedSpriteCount > 0 && cellCount > detectedSpriteCount * 2)
{
continue; // Skip this candidate entirely
}
// HARD CONSTRAINT: Cell count should not be less than half of detected sprites
// (prevents under-splitting / grouping multiple sprites per cell)
if (detectedSpriteCount > 0 && cellCount < detectedSpriteCount / 2)
{
continue; // Skip this candidate entirely
}
// Strong bonus for cell count CLOSE to detected sprite count
// This is the primary selection criterion
if (detectedSpriteCount > 0)
{
if (cellCountRatio >= 0.5f && cellCountRatio <= 2f)
{
// Cell count is within 0.5x to 2x of sprite count - strong bonus
float closenessBonus = 1f - Math.Abs(1f - cellCountRatio);
selectionScore += closenessBonus * 0.4f; // Up to +0.4 bonus
}
else
{
// Cell count is far from sprite count - penalty
selectionScore *= 0.5f;
}
}
// Bonus for producing multiple cells
if (columns >= 2 && rows >= 2)
{
selectionScore += 0.1f;
}
else if (columns >= 2 || rows >= 2)
{
selectionScore += 0.05f;
}
// Bonus for power of two sizes
if (IsPowerOfTwo(candidateWidth) && IsPowerOfTwo(candidateHeight))
{
selectionScore += 0.03f;
}
// Bonus for square cells
if (candidateWidth == candidateHeight)
{
selectionScore += 0.01f;
}
// Much stronger penalty for very high cell counts
if (cellCount > 100)
{
selectionScore *= 0.3f; // Was 0.5
}
else if (cellCount > 64)
{
selectionScore *= 0.5f; // Was 0.7
}
else if (cellCount > 32)
{
selectionScore *= 0.7f;
}
// Size bonus - prefer larger cells
float sizeRatio =
(float)(candidateWidth + candidateHeight) / (textureWidth + textureHeight);
float sizeBonus = sizeRatio * 0.3f; // Was 0.1f
selectionScore += sizeBonus;
if (selectionScore > bestScore)
{
bestScore = selectionScore;
bestTransparencyScore = transparencyScore;
bestWidth = candidateWidth;
bestHeight = candidateHeight;
}
}
}
if (bestScore >= 0.15f && bestWidth > 0 && bestHeight > 0)
{
int finalWidth = bestWidth;
int finalHeight = bestHeight;
if (snapToTextureDivisor)
{
Vector2Int adjusted = FindBestTransparencyAlignedDivisor(
pixels,
textureWidth,
textureHeight,
bestWidth,
bestHeight,
alphaThreshold,
spriteBounds
);
finalWidth = adjusted.x;
finalHeight = adjusted.y;
// Validate the adjusted result also fits sprites well
if (spriteBounds.Count > 0)
{
float adjustedFitScore = CalculateSpriteFitScore(
spriteBounds,
finalWidth,
finalHeight,
textureWidth,
textureHeight
);
float originalFitScore = CalculateSpriteFitScore(
spriteBounds,
bestWidth,
bestHeight,
textureWidth,
textureHeight
);
// If adjustment made sprite-fit worse AT ALL, keep original
if (adjustedFitScore < originalFitScore)
{
finalWidth = bestWidth;
finalHeight = bestHeight;
}
}
}
// Use transparency-only score for confidence (not inflated by bonuses)
float confidence = Mathf.Clamp01(bestTransparencyScore);
return new AlgorithmResult(
finalWidth,
finalHeight,
confidence,
AutoDetectionAlgorithm.BoundaryScoring
);
}
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.BoundaryScoring);
}
///
/// ClusterCentroid algorithm: detects sprites via flood-fill, computes centroids,
/// and infers grid from unique centroid positions (grouping by tolerance).
///
private static AlgorithmResult DetectGridClusterCentroid(
Color32[] pixels,
int textureWidth,
int textureHeight,
float alphaThreshold,
int expectedSpriteCount,
bool snapToTextureDivisor,
CancellationToken cancellationToken
)
{
byte alphaThresholdByte = (byte)(alphaThreshold * 255f);
using PooledResource> spriteBoundsLease = Buffers.List.Get(
out List spriteBounds
);
DetectSpriteBoundsByAlpha(
pixels,
textureWidth,
textureHeight,
alphaThresholdByte,
spriteBounds,
cancellationToken
);
if (spriteBounds.Count < 2)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.ClusterCentroid);
}
if (cancellationToken.IsCancellationRequested)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.ClusterCentroid);
}
// Compute centroids
using PooledResource> centroidsLease = Buffers.List.Get(
out List centroids
);
for (int i = 0; i < spriteBounds.Count; ++i)
{
Rect bounds = spriteBounds[i];
centroids.Add(bounds.center);
}
// Collect X and Y positions
using PooledResource> xPositionsLease = Buffers.List.Get(
out List xPositions
);
using PooledResource> yPositionsLease = Buffers.List.Get(
out List yPositions
);
for (int i = 0; i < centroids.Count; ++i)
{
xPositions.Add(centroids[i].x);
yPositions.Add(centroids[i].y);
}
xPositions.Sort();
yPositions.Sort();
// Calculate average sprite size for tolerance computation
float avgWidth = 0f;
float avgHeight = 0f;
float maxWidth = 0f;
float maxHeight = 0f;
for (int i = 0; i < spriteBounds.Count; ++i)
{
avgWidth += spriteBounds[i].width;
avgHeight += spriteBounds[i].height;
if (spriteBounds[i].width > maxWidth)
{
maxWidth = spriteBounds[i].width;
}
if (spriteBounds[i].height > maxHeight)
{
maxHeight = spriteBounds[i].height;
}
}
avgWidth /= spriteBounds.Count;
avgHeight /= spriteBounds.Count;
// PRIMARY METHOD: If user provided expectedSpriteCount, use it directly without fallback
if (expectedSpriteCount > 0)
{
int cellWidth;
int cellHeight;
if (
InferGridFromSpriteCount(
expectedSpriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
// User explicitly set sprite count - validate confidence based on cell aspect ratio
float userCountConfidence = CalculateUserSpecifiedCountConfidence(
cellWidth,
cellHeight,
textureWidth,
textureHeight
);
return new AlgorithmResult(
cellWidth,
cellHeight,
userCountConfidence,
AutoDetectionAlgorithm.ClusterCentroid
);
}
// Fallback to approximate grid when exact division fails
if (
TryInferApproximateGrid(
expectedSpriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
return new AlgorithmResult(
cellWidth,
cellHeight,
0.95f, // Slightly lower confidence since not exact
AutoDetectionAlgorithm.ClusterCentroid
);
}
}
// Auto-detection path: use detected sprite count
int spriteCount = spriteBounds.Count;
int inferredCellWidth;
int inferredCellHeight;
if (
!InferGridFromSpriteCount(
spriteCount,
textureWidth,
textureHeight,
out inferredCellWidth,
out inferredCellHeight
)
)
{
// Fallback: use tolerance-based grouping if sprite count doesn't produce valid grid
xPositions.Sort();
yPositions.Sort();
float xTolerance = CalculateGapBasedTolerance(xPositions, MinimumCellSize);
float yTolerance = CalculateGapBasedTolerance(yPositions, MinimumCellSize);
int numColumns = Math.Max(1, CountUniquePositionGroups(xPositions, xTolerance));
int numRows = Math.Max(1, CountUniquePositionGroups(yPositions, yTolerance));
inferredCellWidth = FindNearestDivisor(textureWidth, textureWidth / numColumns);
inferredCellHeight = FindNearestDivisor(textureHeight, textureHeight / numRows);
}
int cellWidth2 = inferredCellWidth;
int cellHeight2 = inferredCellHeight;
// Validate that detected sprites fit within cells
float spriteFitScore = CalculateSpriteFitScore(
spriteBounds,
cellWidth2,
cellHeight2,
textureWidth,
textureHeight
);
// If sprites don't fit well, try alternative cell sizes based on max sprite dimensions
if (spriteFitScore < SpriteFitFallbackThreshold)
{
int altCellWidth = FindNearestDivisor(
textureWidth,
Mathf.CeilToInt(maxWidth * 1.1f)
);
int altCellHeight = FindNearestDivisor(
textureHeight,
Mathf.CeilToInt(maxHeight * 1.1f)
);
float altFitScore = CalculateSpriteFitScore(
spriteBounds,
altCellWidth,
altCellHeight,
textureWidth,
textureHeight
);
if (altFitScore > spriteFitScore)
{
cellWidth2 = altCellWidth;
cellHeight2 = altCellHeight;
spriteFitScore = altFitScore;
}
}
if (cellWidth2 < MinimumCellSize || cellHeight2 < MinimumCellSize)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.ClusterCentroid);
}
// Calculate confidence based on multiple factors
float gridConsistency = CalculateGridConsistency(
xPositions,
yPositions,
cellWidth2,
cellHeight2
);
// Cell count ratio
var finalCellCount = (textureWidth / cellWidth2) * (textureHeight / cellHeight2);
float cellCountRatio =
(float)Math.Min(finalCellCount, spriteCount)
/ Math.Max(finalCellCount, spriteCount);
// Combine factors for confidence
float confidence = (
gridConsistency * 0.4f + cellCountRatio * 0.3f + spriteFitScore * 0.3f
);
return new AlgorithmResult(
cellWidth2,
cellHeight2,
confidence,
AutoDetectionAlgorithm.ClusterCentroid
);
}
///
/// Infers grid dimensions directly from detected sprite count and texture dimensions.
/// Finds the factor pair of spriteCount that best matches the texture aspect ratio.
/// This is the PRIMARY method for grid detection - simple and reliable for clean sprite sheets.
///
/// Number of detected sprites.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Output: calculated cell width.
/// Output: calculated cell height.
/// True if a valid grid was found, false otherwise.
private static bool InferGridFromSpriteCount(
int spriteCount,
int textureWidth,
int textureHeight,
out int cellWidth,
out int cellHeight
)
{
cellWidth = 0;
cellHeight = 0;
if (spriteCount < 1)
{
return false;
}
float textureAspect = (float)textureWidth / textureHeight;
// SPECIAL CASE: For strip textures (horizontal or vertical), prefer single-row/column layouts
// even if they don't evenly divide the texture. This handles cases like 256x21 with 12 sprites
// where the ideal layout (12x1) doesn't evenly divide (256/12 = 21.33).
if (textureAspect > 4f && spriteCount > 1)
{
// Horizontal strip - prefer single row layout
// Use texture height as the target cell height (sprites are likely square-ish)
int targetCellHeight = textureHeight;
// Round to nearest instead of truncating to handle imprecise divisions
int targetCellWidth = (textureWidth + spriteCount / 2) / spriteCount;
// Check if this produces reasonable cells
if (
targetCellWidth >= MinimumCellSize
&& targetCellHeight >= MinimumCellSize
&& targetCellWidth <= textureWidth
)
{
// Even if width doesn't divide evenly, prefer this for strip textures
// as long as cells are roughly square (within 3:1 aspect ratio)
float stripCellAspect = (float)targetCellWidth / targetCellHeight;
if (stripCellAspect > 0.33f && stripCellAspect < 3f)
{
cellWidth = targetCellWidth;
cellHeight = targetCellHeight;
return true;
}
}
}
else if (textureAspect < 0.25f && spriteCount > 1)
{
// Vertical strip - prefer single column layout
int targetCellWidth = textureWidth;
// Round to nearest instead of truncating to handle imprecise divisions
int targetCellHeight = (textureHeight + spriteCount / 2) / spriteCount;
if (
targetCellWidth >= MinimumCellSize
&& targetCellHeight >= MinimumCellSize
&& targetCellHeight <= textureHeight
)
{
float stripCellAspect = (float)targetCellWidth / targetCellHeight;
if (stripCellAspect > 0.33f && stripCellAspect < 3f)
{
cellWidth = targetCellWidth;
cellHeight = targetCellHeight;
return true;
}
}
}
int bestCols = 1;
int bestRows = spriteCount;
float bestAspectError = float.MaxValue;
// Find all factor pairs of spriteCount
for (int cols = 1; cols <= spriteCount; ++cols)
{
if (spriteCount % cols != 0)
{
continue;
}
int rows = spriteCount / cols;
// Calculate what cell size this would produce
int candidateCellWidth = textureWidth / cols;
int candidateCellHeight = textureHeight / rows;
// Skip if cells would be too small
if (candidateCellWidth < MinimumCellSize || candidateCellHeight < MinimumCellSize)
{
continue;
}
// Skip if cells don't evenly divide the texture
if (textureWidth % cols != 0 || textureHeight % rows != 0)
{
continue;
}
// Calculate aspect ratio of the resulting cells
float cellAspect = (float)candidateCellWidth / candidateCellHeight;
// We want cells that are roughly square, or match the texture aspect
// For most sprite sheets, cells should be square (aspect ~1)
// However, for extreme aspect ratio textures (horizontal/vertical strips),
// we should prefer layouts that match the strip orientation
float aspectError;
if (textureAspect > 4f)
{
// Wide horizontal strip (e.g., 256x21) - prefer fewer rows
// Penalize configurations with more rows
float rowPenalty = rows > 1 ? (rows - 1) * 2f : 0f;
aspectError = Math.Abs(cellAspect - 1f) + rowPenalty;
}
else if (textureAspect < 0.25f)
{
// Tall vertical strip - prefer fewer columns
float colPenalty = cols > 1 ? (cols - 1) * 2f : 0f;
aspectError = Math.Abs(cellAspect - 1f) + colPenalty;
}
else
{
// Normal sprite sheet - prefer square cells
aspectError = Math.Abs(cellAspect - 1f);
}
// Prefer this if it's a better match
if (aspectError < bestAspectError)
{
bestAspectError = aspectError;
bestCols = cols;
bestRows = rows;
}
}
cellWidth = textureWidth / bestCols;
cellHeight = textureHeight / bestRows;
return cellWidth >= MinimumCellSize && cellHeight >= MinimumCellSize;
}
///
/// Attempts to infer grid dimensions from sprite count even when exact division isn't possible.
/// Finds the closest factor pair that produces at least the expected number of cells.
///
/// The expected number of sprites.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Output: calculated cell width.
/// Output: calculated cell height.
/// True if a valid approximate grid was found, false otherwise.
private static bool TryInferApproximateGrid(
int expectedSpriteCount,
int textureWidth,
int textureHeight,
out int cellWidth,
out int cellHeight
)
{
cellWidth = 0;
cellHeight = 0;
if (expectedSpriteCount < 1)
{
return false;
}
int bestCols = 1;
int bestRows = expectedSpriteCount;
float bestScore = float.MaxValue;
for (int cols = 1; cols <= expectedSpriteCount; ++cols)
{
int rows = (expectedSpriteCount + cols - 1) / cols; // Ceiling division
int candidateCellWidth = textureWidth / cols;
int candidateCellHeight = textureHeight / rows;
if (candidateCellWidth < MinimumCellSize || candidateCellHeight < MinimumCellSize)
{
continue;
}
int actualCells = cols * rows;
// Penalize overcounting (more cells than expected) more heavily than undercounting
// Overcounting creates empty cells, which is usually worse
float cellCountError = Math.Abs(actualCells - expectedSpriteCount);
float overcountPenalty =
actualCells > expectedSpriteCount
? (actualCells - expectedSpriteCount) * 15f
: 0f;
float cellAspect = (float)candidateCellWidth / candidateCellHeight;
float aspectError = Math.Abs(cellAspect - 1f);
float score = cellCountError * 10f + overcountPenalty + aspectError;
if (score < bestScore)
{
bestScore = score;
bestCols = cols;
bestRows = rows;
}
}
cellWidth = textureWidth / bestCols;
cellHeight = textureHeight / bestRows;
return cellWidth >= MinimumCellSize && cellHeight >= MinimumCellSize;
}
///
/// Counts the number of unique position groups by grouping positions within tolerance.
/// Compares each position to the PREVIOUS position rather than the group start,
/// preventing groups from stretching across large distances.
///
/// Sorted list of position values to group.
/// Maximum distance between adjacent positions to be considered the same group.
/// Number of distinct position groups found.
private static int CountUniquePositionGroups(List sortedPositions, float tolerance)
{
if (sortedPositions.Count == 0)
{
return 0;
}
int groupCount = 1;
for (int i = 1; i < sortedPositions.Count; ++i)
{
// Compare to PREVIOUS position, not group start
if (sortedPositions[i] - sortedPositions[i - 1] > tolerance)
{
++groupCount;
}
}
return groupCount;
}
///
/// Calculates an appropriate tolerance for grouping positions based on the minimum significant gap.
/// Uses minimum gap (not median) to prevent over-grouping when sprites have varying spacing.
///
/// Sorted list of position values to analyze.
/// Minimum tolerance value to return.
/// A tolerance value based on minimum significant gap, or minTolerance if not enough data.
private static float CalculateGapBasedTolerance(
List sortedPositions,
float minTolerance
)
{
if (sortedPositions.Count < 2)
{
return minTolerance;
}
// Collect all significant gaps between consecutive positions
// A "significant" gap is one that's larger than a small threshold (likely between different columns/rows)
using PooledResource> gapsLease = Buffers.List.Get(
out List gaps
);
for (int i = 1; i < sortedPositions.Count; ++i)
{
float gap = sortedPositions[i] - sortedPositions[i - 1];
// Gaps must be larger than MinSignificantGapSize to be considered "between columns/rows"
// This filters out small variations in sprite positions within the same column
if (gap > MinSignificantGapSize)
{
gaps.Add(gap);
}
}
if (gaps.Count == 0)
{
return minTolerance;
}
// Use MINIMUM significant gap (not median) to prevent over-grouping
// This ensures tight tolerance that only groups positions that are truly close
gaps.Sort();
float minGap = gaps[0];
// Tolerance is a fraction of the minimum gap
// Using a smaller multiplier (0.25) ensures we don't accidentally merge distinct columns
return Math.Max(minTolerance, minGap * GapToleranceMultiplier);
}
///
/// Calculates how well sprites fit within the proposed grid cells.
/// Uses a "core zone" concept: only grid lines passing through the middle portion
/// of a sprite are penalized. Edge clipping is ignored to handle anti-aliased bounding boxes.
///
/// List of detected sprite bounding rectangles.
/// Proposed grid cell width in pixels.
/// Proposed grid cell height in pixels.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Score in range [0, 1] where 1 means all sprites fit within single cells without core zone splits.
private static float CalculateSpriteFitScore(
List spriteBounds,
int cellWidth,
int cellHeight,
int textureWidth,
int textureHeight
)
{
if (spriteBounds.Count == 0 || cellWidth <= 0 || cellHeight <= 0)
{
return 0f;
}
float totalScore = 0f;
for (int i = 0; i < spriteBounds.Count; ++i)
{
Rect bounds = spriteBounds[i];
float spriteScore = 1f;
// Calculate the "core zone" - the middle portion of the sprite
// Edge pixels (outside core zone) are ignored as they may be anti-aliased
float coreMarginX = bounds.width * (1f - SpriteCoreZoneFraction) * 0.5f;
float coreMarginY = bounds.height * (1f - SpriteCoreZoneFraction) * 0.5f;
float coreXMin = bounds.xMin + coreMarginX;
float coreXMax = bounds.xMax - coreMarginX;
float coreYMin = bounds.yMin + coreMarginY;
float coreYMax = bounds.yMax - coreMarginY;
// Calculate worst vertical split severity (only for core zone)
float worstVerticalSeverity = 0f;
for (int x = cellWidth; x < textureWidth; x += cellWidth)
{
// Only penalize if the grid line passes through the CORE zone
if (coreXMin < x && coreXMax > x)
{
// Calculate severity based on how centered the split is in the core zone
float coreWidth = coreXMax - coreXMin;
float distFromCoreLeft = x - coreXMin;
float distFromCoreRight = coreXMax - x;
float minDist = Math.Min(distFromCoreLeft, distFromCoreRight);
float halfCoreWidth = coreWidth * 0.5f;
float splitRatio = halfCoreWidth > 0f ? minDist / halfCoreWidth : 0f;
float severity = Mathf.Clamp01(splitRatio);
if (severity > worstVerticalSeverity)
{
worstVerticalSeverity = severity;
}
}
}
// Calculate worst horizontal split severity (only for core zone)
float worstHorizontalSeverity = 0f;
for (int y = cellHeight; y < textureHeight; y += cellHeight)
{
// Only penalize if the grid line passes through the CORE zone
if (coreYMin < y && coreYMax > y)
{
float coreHeight = coreYMax - coreYMin;
float distFromCoreBottom = y - coreYMin;
float distFromCoreTop = coreYMax - y;
float minDist = Math.Min(distFromCoreBottom, distFromCoreTop);
float halfCoreHeight = coreHeight * 0.5f;
float splitRatio = halfCoreHeight > 0f ? minDist / halfCoreHeight : 0f;
float severity = Mathf.Clamp01(splitRatio);
if (severity > worstHorizontalSeverity)
{
worstHorizontalSeverity = severity;
}
}
}
// Combine severities: worst case of either direction, but compound if both split
float combinedSeverity = Math.Max(worstVerticalSeverity, worstHorizontalSeverity);
if (worstVerticalSeverity > 0f && worstHorizontalSeverity > 0f)
{
// Both directions split - extra penalty
combinedSeverity = Math.Min(
1f,
combinedSeverity + worstVerticalSeverity * worstHorizontalSeverity * 0.5f
);
}
// Apply penalty based on severity
// Severity of 1.0 (middle split) = 0.0 score for this sprite
// Severity of 0.0 (no core zone split) = 1.0 score
spriteScore = 1f - combinedSeverity;
totalScore += spriteScore;
}
return totalScore / spriteBounds.Count;
}
///
/// Calculates confidence for user-specified sprite count based on resulting cell aspect ratio.
/// Penalizes extremely non-square cells which are unlikely to be correct for most sprite sheets.
/// Returns a confidence in range [0.5, 0.95] since user explicitly specified the count.
///
/// The proposed cell width.
/// The proposed cell height.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Confidence score in range [0.5, 0.95].
private static float CalculateUserSpecifiedCountConfidence(
int cellWidth,
int cellHeight,
int textureWidth,
int textureHeight
)
{
if (cellWidth <= 0 || cellHeight <= 0)
{
return 0.5f;
}
float cellAspect = (float)cellWidth / cellHeight;
float textureAspect = (float)textureWidth / textureHeight;
// Calculate how far the cell aspect ratio is from square (1:1)
// Use log scale so 2:1 and 1:2 have equal deviation
float cellAspectDeviation = Math.Abs((float)Math.Log(cellAspect));
// Base confidence starts high since user specified the count
float confidence = 0.9f;
// Penalize non-square cells more heavily for extreme deviations
if (cellAspectDeviation > 2f) // > 7.4:1 or < 1:7.4
{
// Extreme deviation - significantly reduce confidence
confidence = 0.5f;
}
else if (cellAspectDeviation > 1.4f) // > 4:1 or < 1:4
{
// Large deviation - reduce confidence
confidence = 0.6f;
}
else if (cellAspectDeviation > 0.7f) // > 2:1 or < 1:2
{
// Moderate deviation - slight reduction
confidence = 0.75f;
}
else
{
// Close to square - high confidence
// But still cap at 0.9 since we haven't validated sprite boundaries
confidence = 0.9f;
}
// For texture strips (extreme aspect ratios), matching cell orientation gets bonus
// E.g., 256x21 texture (12:1) should prefer horizontal layout with square-ish cells
if (textureAspect > 4f || textureAspect < 0.25f)
{
// Check if cells match texture orientation
bool textureIsHorizontal = textureAspect > 1f;
bool cellsAreHorizontal = cellAspect > 1f;
if (textureIsHorizontal == cellsAreHorizontal && cellAspectDeviation < 0.7f)
{
// Cells roughly match texture strip orientation and are reasonably square
confidence = Math.Min(0.95f, confidence + 0.1f);
}
}
return confidence;
}
///
/// Calculates grid consistency: how well centroid positions align to a regular grid.
/// Combines horizontal and vertical alignment scores for an overall consistency measure.
///
/// List of X centroid positions.
/// List of Y centroid positions.
/// Expected grid cell width in pixels.
/// Expected grid cell height in pixels.
/// Consistency score in range [0, 1] where 1 means perfect grid alignment.
private static float CalculateGridConsistency(
List xPositions,
List yPositions,
int cellWidth,
int cellHeight
)
{
float xConsistency = CalculatePositionGridAlignment(xPositions, cellWidth);
float yConsistency = CalculatePositionGridAlignment(yPositions, cellHeight);
return (xConsistency + yConsistency) * 0.5f;
}
///
/// Calculates how well positions align to cell centers in a grid.
/// Measures the average deviation of each position from its expected cell center.
///
/// List of positions to check for grid alignment.
/// Expected cell size in pixels.
/// Alignment score in range [0, 1] where 1 means all positions are at cell centers.
private static float CalculatePositionGridAlignment(List positions, int cellSize)
{
if (positions.Count == 0 || cellSize <= 0)
{
return 0f;
}
float halfCell = cellSize * 0.5f;
float totalAlignment = 0f;
for (int i = 0; i < positions.Count; ++i)
{
// Find expected cell center
int cellIndex = Mathf.FloorToInt(positions[i] / cellSize);
float expectedCenter = cellIndex * cellSize + halfCell;
float deviation = Mathf.Abs(positions[i] - expectedCenter);
// Normalize deviation to [0, 1] where 0 = perfect alignment
float normalizedDeviation = Mathf.Clamp01(deviation / halfCell);
totalAlignment += 1f - normalizedDeviation;
}
return totalAlignment / positions.Count;
}
///
/// DistanceTransform algorithm: computes chamfer distance from alpha boundaries
/// and finds local maxima to identify sprite centers.
///
private static AlgorithmResult DetectGridDistanceTransform(
Color32[] pixels,
int textureWidth,
int textureHeight,
float alphaThreshold,
int expectedSpriteCount,
bool snapToTextureDivisor,
CancellationToken cancellationToken
)
{
byte alphaThresholdByte = (byte)(alphaThreshold * 255f);
// Detect sprite bounds for sprite-fit validation in divisor selection
using PooledResource> spriteBoundsLease = Buffers.List.Get(
out List spriteBounds
);
DetectSpriteBoundsByAlpha(
pixels,
textureWidth,
textureHeight,
alphaThresholdByte,
spriteBounds,
cancellationToken
);
using PooledArray distanceLease = SystemArrayPool.Get(
pixels.Length,
out int[] distance
);
// Initialize: 0 for transparent, large value for opaque
const int maxDistance = int.MaxValue / 2;
for (int i = 0; i < pixels.Length; ++i)
{
distance[i] = pixels[i].a <= alphaThresholdByte ? 0 : maxDistance;
}
if (cancellationToken.IsCancellationRequested)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.DistanceTransform);
}
// Forward pass (3-4 chamfer distance)
for (int y = 1; y < textureHeight; ++y)
{
for (int x = 0; x < textureWidth; ++x)
{
int idx = y * textureWidth + x;
if (distance[idx] == 0)
{
continue;
}
int top = distance[idx - textureWidth];
int current = distance[idx];
if (x > 0)
{
int topLeft = distance[idx - textureWidth - 1];
int left = distance[idx - 1];
current = Math.Min(current, Math.Min(topLeft + 4, left + 3));
}
current = Math.Min(current, top + 3);
if (x < textureWidth - 1)
{
int topRight = distance[idx - textureWidth + 1];
current = Math.Min(current, topRight + 4);
}
distance[idx] = current;
}
}
if (cancellationToken.IsCancellationRequested)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.DistanceTransform);
}
// Backward pass
for (int y = textureHeight - 2; y >= 0; --y)
{
for (int x = textureWidth - 1; x >= 0; --x)
{
int idx = y * textureWidth + x;
if (distance[idx] == 0)
{
continue;
}
int bottom = distance[idx + textureWidth];
int current = distance[idx];
if (x < textureWidth - 1)
{
int bottomRight = distance[idx + textureWidth + 1];
int right = distance[idx + 1];
current = Math.Min(current, Math.Min(bottomRight + 4, right + 3));
}
current = Math.Min(current, bottom + 3);
if (x > 0)
{
int bottomLeft = distance[idx + textureWidth - 1];
current = Math.Min(current, bottomLeft + 4);
}
distance[idx] = current;
}
}
if (cancellationToken.IsCancellationRequested)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.DistanceTransform);
}
// Find local maxima (raw candidates)
using PooledResource> rawMaximaLease = Buffers.List.Get(
out List rawMaxima
);
using PooledResource> rawMaximaValuesLease = Buffers.List.Get(
out List rawMaximaValues
);
int minPeakDistance = MinimumCellSize / 2;
for (int y = minPeakDistance; y < textureHeight - minPeakDistance; ++y)
{
for (int x = minPeakDistance; x < textureWidth - minPeakDistance; ++x)
{
int idx = y * textureWidth + x;
int val = distance[idx];
if (val < minPeakDistance)
{
continue;
}
bool isMaximum = true;
for (int dy = -1; dy <= 1 && isMaximum; ++dy)
{
for (int dx = -1; dx <= 1 && isMaximum; ++dx)
{
if (dx == 0 && dy == 0)
{
continue;
}
int neighborIdx = (y + dy) * textureWidth + (x + dx);
if (distance[neighborIdx] > val)
{
isMaximum = false;
}
}
}
if (isMaximum)
{
rawMaxima.Add(new Vector2Int(x, y));
rawMaximaValues.Add(val);
}
}
}
if (rawMaxima.Count < 2)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.DistanceTransform);
}
// Non-maximum suppression: filter out peaks that are too close to stronger peaks
// Estimate minimum separation based on texture dimensions and peak count
float estimatedCellSize =
(float)Math.Min(textureWidth, textureHeight)
/ Math.Max(1, (int)Math.Sqrt(rawMaxima.Count));
float minSeparation = Math.Max(MinimumCellSize, estimatedCellSize * 0.4f);
using PooledResource> maximaLease = Buffers.List.Get(
out List localMaxima
);
// Sort by peak strength (descending) for non-maximum suppression
using PooledResource> sortedIndicesLease = Buffers.List.Get(
out List sortedIndices
);
for (int i = 0; i < rawMaxima.Count; ++i)
{
sortedIndices.Add(i);
}
sortedIndices.Sort((a, b) => rawMaximaValues[b].CompareTo(rawMaximaValues[a]));
// Keep only peaks that are not suppressed by a stronger nearby peak
using PooledArray suppressedLease = SystemArrayPool.Get(
rawMaxima.Count,
out bool[] suppressed
);
Array.Clear(suppressed, 0, suppressed.Length);
for (int i = 0; i < sortedIndices.Count; ++i)
{
int idx = sortedIndices[i];
if (suppressed[idx])
{
continue;
}
Vector2Int peak = rawMaxima[idx];
localMaxima.Add(peak);
// Suppress weaker peaks within minimum separation distance
for (int j = i + 1; j < sortedIndices.Count; ++j)
{
int otherIdx = sortedIndices[j];
if (suppressed[otherIdx])
{
continue;
}
Vector2Int other = rawMaxima[otherIdx];
float dx = peak.x - other.x;
float dy = peak.y - other.y;
float dist = Mathf.Sqrt(dx * dx + dy * dy);
if (dist < minSeparation)
{
suppressed[otherIdx] = true;
}
}
}
if (localMaxima.Count < 2)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.DistanceTransform);
}
// Analyze peak spacing
using PooledResource> xPositionsLease = Buffers.List.Get(
out List xPositions
);
using PooledResource> yPositionsLease = Buffers.List.Get(
out List yPositions
);
for (int i = 0; i < localMaxima.Count; ++i)
{
xPositions.Add(localMaxima[i].x);
yPositions.Add(localMaxima[i].y);
}
// PRIMARY METHOD: If user provided expectedSpriteCount, use it directly without fallback
if (expectedSpriteCount > 0)
{
int cellWidth;
int cellHeight;
if (
InferGridFromSpriteCount(
expectedSpriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
// User explicitly set sprite count - validate confidence based on cell aspect ratio
float confidence = CalculateUserSpecifiedCountConfidence(
cellWidth,
cellHeight,
textureWidth,
textureHeight
);
return new AlgorithmResult(
cellWidth,
cellHeight,
confidence,
AutoDetectionAlgorithm.DistanceTransform
);
}
// Fallback to approximate grid when exact division fails
if (
TryInferApproximateGrid(
expectedSpriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
return new AlgorithmResult(
cellWidth,
cellHeight,
0.95f, // Slightly lower confidence since not exact
AutoDetectionAlgorithm.DistanceTransform
);
}
}
// Auto-detection path: use detected sprite count
int spriteCount = spriteBounds.Count;
int inferredCellWidth;
int inferredCellHeight;
if (
spriteCount > 0
&& InferGridFromSpriteCount(
spriteCount,
textureWidth,
textureHeight,
out inferredCellWidth,
out inferredCellHeight
)
)
{
// Successfully inferred grid from sprite count - use it directly
}
else
{
// Fallback: use peak-based grouping
xPositions.Sort();
yPositions.Sort();
int minGapTolerance = MinimumCellSize * DistanceTransformMinGapToleranceMultiplier;
float xTolerance = CalculateGapBasedTolerance(xPositions, minGapTolerance);
float yTolerance = CalculateGapBasedTolerance(yPositions, minGapTolerance);
int numColumns = Math.Max(1, CountUniquePositionGroups(xPositions, xTolerance));
int numRows = Math.Max(1, CountUniquePositionGroups(yPositions, yTolerance));
inferredCellWidth = FindNearestDivisor(textureWidth, textureWidth / numColumns);
inferredCellHeight = FindNearestDivisor(textureHeight, textureHeight / numRows);
}
if (inferredCellWidth < MinimumCellSize || inferredCellHeight < MinimumCellSize)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.DistanceTransform);
}
xPositions.Sort();
yPositions.Sort();
float gridConsistency = CalculateGridConsistency(
xPositions,
yPositions,
inferredCellWidth,
inferredCellHeight
);
return new AlgorithmResult(
inferredCellWidth,
inferredCellHeight,
gridConsistency,
AutoDetectionAlgorithm.DistanceTransform
);
}
///
/// RegionGrowing algorithm: seeds from local intensity maxima and grows regions
/// via 4-connected flood-fill to alpha boundaries.
///
private static AlgorithmResult DetectGridRegionGrowing(
Color32[] pixels,
int textureWidth,
int textureHeight,
float alphaThreshold,
int expectedSpriteCount,
bool snapToTextureDivisor,
CancellationToken cancellationToken
)
{
byte alphaThresholdByte = (byte)(alphaThreshold * 255f);
// Find seed points based on intensity (sum of RGB)
using PooledArray intensityLease = SystemArrayPool.Get(
pixels.Length,
out int[] intensity
);
for (int i = 0; i < pixels.Length; ++i)
{
Color32 c = pixels[i];
intensity[i] = c.a > alphaThresholdByte ? c.r + c.g + c.b : 0;
}
if (cancellationToken.IsCancellationRequested)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.RegionGrowing);
}
// Find local intensity maxima as seeds
using PooledResource> seedsLease = Buffers.List.Get(
out List seeds
);
int windowSize = Math.Max(8, Math.Min(textureWidth, textureHeight) / 16);
for (int y = windowSize; y < textureHeight - windowSize; y += windowSize / 2)
{
for (int x = windowSize; x < textureWidth - windowSize; x += windowSize / 2)
{
int idx = y * textureWidth + x;
int val = intensity[idx];
if (val <= 0)
{
continue;
}
bool isMaximum = true;
for (int dy = -windowSize / 4; dy <= windowSize / 4 && isMaximum; dy += 2)
{
for (int dx = -windowSize / 4; dx <= windowSize / 4 && isMaximum; dx += 2)
{
if (dx == 0 && dy == 0)
{
continue;
}
int neighborIdx = (y + dy) * textureWidth + (x + dx);
if (
neighborIdx >= 0
&& neighborIdx < pixels.Length
&& intensity[neighborIdx] > val
)
{
isMaximum = false;
}
}
}
if (isMaximum)
{
seeds.Add(new Vector2Int(x, y));
}
}
}
if (cancellationToken.IsCancellationRequested)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.RegionGrowing);
}
if (seeds.Count < 2)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.RegionGrowing);
}
// Grow regions from seeds and measure sizes
using PooledArray regionLease = SystemArrayPool.Get(
pixels.Length,
out int[] regionId
);
Array.Clear(regionId, 0, regionId.Length);
using PooledResource> regionSizesLease = Buffers.List.Get(
out List regionSizes
);
using PooledResource> regionBoundsLease = Buffers.List.Get(
out List regionBounds
);
int currentRegion = 0;
using PooledResource> stackLease = Buffers.List.Get(out List stack);
for (int seedIdx = 0; seedIdx < seeds.Count && currentRegion < 256; ++seedIdx)
{
Vector2Int seed = seeds[seedIdx];
int startIdx = seed.y * textureWidth + seed.x;
if (regionId[startIdx] != 0 || pixels[startIdx].a <= alphaThresholdByte)
{
continue;
}
++currentRegion;
stack.Clear();
stack.Add(startIdx);
regionId[startIdx] = currentRegion;
int size = 0;
int minX = seed.x;
int maxX = seed.x;
int minY = seed.y;
int maxY = seed.y;
while (stack.Count > 0)
{
int lastIndex = stack.Count - 1;
int idx = stack[lastIndex];
stack.RemoveAt(lastIndex);
++size;
int px = idx % textureWidth;
int py = idx / textureWidth;
if (px < minX)
{
minX = px;
}
if (px > maxX)
{
maxX = px;
}
if (py < minY)
{
minY = py;
}
if (py > maxY)
{
maxY = py;
}
// 4-connected neighbors
if (px > 0)
{
int left = idx - 1;
if (regionId[left] == 0 && pixels[left].a > alphaThresholdByte)
{
regionId[left] = currentRegion;
stack.Add(left);
}
}
if (px < textureWidth - 1)
{
int right = idx + 1;
if (regionId[right] == 0 && pixels[right].a > alphaThresholdByte)
{
regionId[right] = currentRegion;
stack.Add(right);
}
}
if (py > 0)
{
int bottom = idx - textureWidth;
if (regionId[bottom] == 0 && pixels[bottom].a > alphaThresholdByte)
{
regionId[bottom] = currentRegion;
stack.Add(bottom);
}
}
if (py < textureHeight - 1)
{
int top = idx + textureWidth;
if (regionId[top] == 0 && pixels[top].a > alphaThresholdByte)
{
regionId[top] = currentRegion;
stack.Add(top);
}
}
}
if (size > MinimumCellSize * MinimumCellSize / 4)
{
regionSizes.Add(size);
regionBounds.Add(new Rect(minX, minY, maxX - minX + 1, maxY - minY + 1));
}
}
if (cancellationToken.IsCancellationRequested)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.RegionGrowing);
}
if (regionBounds.Count < 2)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.RegionGrowing);
}
// Use unique positions approach based on region centroids
using PooledResource> xPositionsLease = Buffers.List.Get(
out List xPositions
);
using PooledResource> yPositionsLease = Buffers.List.Get(
out List yPositions
);
// Compute average region size and collect centroid positions
float avgWidth = 0f;
float avgHeight = 0f;
float maxWidth = 0f;
float maxHeight = 0f;
for (int i = 0; i < regionBounds.Count; ++i)
{
Rect bounds = regionBounds[i];
avgWidth += bounds.width;
avgHeight += bounds.height;
xPositions.Add(bounds.center.x);
yPositions.Add(bounds.center.y);
if (bounds.width > maxWidth)
{
maxWidth = bounds.width;
}
if (bounds.height > maxHeight)
{
maxHeight = bounds.height;
}
}
avgWidth /= regionBounds.Count;
avgHeight /= regionBounds.Count;
// PRIMARY METHOD: If user provided expectedSpriteCount, use it directly without fallback
if (expectedSpriteCount > 0)
{
int cellWidth;
int cellHeight;
if (
InferGridFromSpriteCount(
expectedSpriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
// User explicitly set sprite count - validate confidence based on cell aspect ratio
float userCountConfidence = CalculateUserSpecifiedCountConfidence(
cellWidth,
cellHeight,
textureWidth,
textureHeight
);
return new AlgorithmResult(
cellWidth,
cellHeight,
userCountConfidence,
AutoDetectionAlgorithm.RegionGrowing
);
}
// Fallback to approximate grid when exact division fails
if (
TryInferApproximateGrid(
expectedSpriteCount,
textureWidth,
textureHeight,
out cellWidth,
out cellHeight
)
)
{
return new AlgorithmResult(
cellWidth,
cellHeight,
0.95f, // Slightly lower confidence since not exact
AutoDetectionAlgorithm.RegionGrowing
);
}
}
// Auto-detection path: use detected sprite count
int spriteCount = regionBounds.Count;
int inferredCellWidth;
int inferredCellHeight;
if (
!InferGridFromSpriteCount(
spriteCount,
textureWidth,
textureHeight,
out inferredCellWidth,
out inferredCellHeight
)
)
{
// Fallback: use tolerance-based grouping
xPositions.Sort();
yPositions.Sort();
float xTolerance = CalculateGapBasedTolerance(xPositions, MinimumCellSize);
float yTolerance = CalculateGapBasedTolerance(yPositions, MinimumCellSize);
int numColumns = Math.Max(1, CountUniquePositionGroups(xPositions, xTolerance));
int numRows = Math.Max(1, CountUniquePositionGroups(yPositions, yTolerance));
inferredCellWidth = FindNearestDivisor(textureWidth, textureWidth / numColumns);
inferredCellHeight = FindNearestDivisor(textureHeight, textureHeight / numRows);
}
int cellWidth2 = inferredCellWidth;
int cellHeight2 = inferredCellHeight;
// Ensure cells are at least as large as the max region
if (cellWidth2 < maxWidth)
{
cellWidth2 = FindNearestDivisor(textureWidth, Mathf.CeilToInt(maxWidth));
}
if (cellHeight2 < maxHeight)
{
cellHeight2 = FindNearestDivisor(textureHeight, Mathf.CeilToInt(maxHeight));
}
// Sprite-fit validation
float spriteFitScore = CalculateSpriteFitScore(
regionBounds,
cellWidth2,
cellHeight2,
textureWidth,
textureHeight
);
// If sprites don't fit well, try larger cell sizes
if (spriteFitScore < SpriteFitFallbackThreshold)
{
int altCellWidth = FindNearestDivisor(
textureWidth,
Mathf.CeilToInt(maxWidth * 1.1f)
);
int altCellHeight = FindNearestDivisor(
textureHeight,
Mathf.CeilToInt(maxHeight * 1.1f)
);
float altFitScore = CalculateSpriteFitScore(
regionBounds,
altCellWidth,
altCellHeight,
textureWidth,
textureHeight
);
if (altFitScore > spriteFitScore)
{
cellWidth2 = altCellWidth;
cellHeight2 = altCellHeight;
spriteFitScore = altFitScore;
}
}
if (cellWidth2 < MinimumCellSize || cellHeight2 < MinimumCellSize)
{
return AlgorithmResult.Invalid(AutoDetectionAlgorithm.RegionGrowing);
}
// Calculate confidence based on region size uniformity
float widthVariance = 0f;
float heightVariance = 0f;
for (int i = 0; i < regionBounds.Count; ++i)
{
float wDiff = regionBounds[i].width - avgWidth;
float hDiff = regionBounds[i].height - avgHeight;
widthVariance += wDiff * wDiff;
heightVariance += hDiff * hDiff;
}
widthVariance /= regionBounds.Count;
heightVariance /= regionBounds.Count;
float widthStdDev = Mathf.Sqrt(widthVariance);
float heightStdDev = Mathf.Sqrt(heightVariance);
float widthCoeffVar = avgWidth > 0 ? widthStdDev / avgWidth : 1f;
float heightCoeffVar = avgHeight > 0 ? heightStdDev / avgHeight : 1f;
float uniformityScore = Mathf.Clamp01(1f - (widthCoeffVar + heightCoeffVar) * 0.5f);
// Combine uniformity and sprite-fit for confidence
float confidence = (uniformityScore * 0.5f + spriteFitScore * 0.5f);
return new AlgorithmResult(
cellWidth2,
cellHeight2,
confidence,
AutoDetectionAlgorithm.RegionGrowing
);
}
///
/// Detects individual sprite bounds by flood-filling opaque regions.
///
private static void DetectSpriteBoundsByAlpha(
Color32[] pixels,
int textureWidth,
int textureHeight,
byte alphaThresholdByte,
List result,
CancellationToken cancellationToken
)
{
result.Clear();
using PooledArray visitedLease = SystemArrayPool.Get(
pixels.Length,
out bool[] visited
);
Array.Clear(visited, 0, visited.Length);
using PooledResource> stackLease = Buffers.List.Get(out List stack);
for (int y = 0; y < textureHeight; ++y)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
for (int x = 0; x < textureWidth; ++x)
{
int index = y * textureWidth + x;
if (visited[index])
{
continue;
}
if (pixels[index].a <= alphaThresholdByte)
{
visited[index] = true;
continue;
}
int minX = x;
int maxX = x;
int minY = y;
int maxY = y;
stack.Clear();
stack.Add(index);
visited[index] = true;
while (stack.Count > 0)
{
int lastIndex = stack.Count - 1;
int current = stack[lastIndex];
stack.RemoveAt(lastIndex);
int currentX = current % textureWidth;
int currentY = current / textureWidth;
if (currentX < minX)
{
minX = currentX;
}
if (currentX > maxX)
{
maxX = currentX;
}
if (currentY < minY)
{
minY = currentY;
}
if (currentY > maxY)
{
maxY = currentY;
}
// 4-connected neighbors (cardinal directions)
if (currentX > 0)
{
int leftIndex = current - 1;
if (!visited[leftIndex] && pixels[leftIndex].a > alphaThresholdByte)
{
visited[leftIndex] = true;
stack.Add(leftIndex);
}
}
if (currentX < textureWidth - 1)
{
int rightIndex = current + 1;
if (!visited[rightIndex] && pixels[rightIndex].a > alphaThresholdByte)
{
visited[rightIndex] = true;
stack.Add(rightIndex);
}
}
if (currentY > 0)
{
int bottomIndex = current - textureWidth;
if (!visited[bottomIndex] && pixels[bottomIndex].a > alphaThresholdByte)
{
visited[bottomIndex] = true;
stack.Add(bottomIndex);
}
}
if (currentY < textureHeight - 1)
{
int topIndex = current + textureWidth;
if (!visited[topIndex] && pixels[topIndex].a > alphaThresholdByte)
{
visited[topIndex] = true;
stack.Add(topIndex);
}
}
// Add diagonal neighbors for 8-connectivity
// Top-left
if (currentX > 0 && currentY < textureHeight - 1)
{
int neighborIndex = (currentY + 1) * textureWidth + (currentX - 1);
if (
!visited[neighborIndex]
&& pixels[neighborIndex].a > alphaThresholdByte
)
{
visited[neighborIndex] = true;
stack.Add(neighborIndex);
}
}
// Top-right
if (currentX < textureWidth - 1 && currentY < textureHeight - 1)
{
int neighborIndex = (currentY + 1) * textureWidth + (currentX + 1);
if (
!visited[neighborIndex]
&& pixels[neighborIndex].a > alphaThresholdByte
)
{
visited[neighborIndex] = true;
stack.Add(neighborIndex);
}
}
// Bottom-left
if (currentX > 0 && currentY > 0)
{
int neighborIndex = (currentY - 1) * textureWidth + (currentX - 1);
if (
!visited[neighborIndex]
&& pixels[neighborIndex].a > alphaThresholdByte
)
{
visited[neighborIndex] = true;
stack.Add(neighborIndex);
}
}
// Bottom-right
if (currentX < textureWidth - 1 && currentY > 0)
{
int neighborIndex = (currentY - 1) * textureWidth + (currentX + 1);
if (
!visited[neighborIndex]
&& pixels[neighborIndex].a > alphaThresholdByte
)
{
visited[neighborIndex] = true;
stack.Add(neighborIndex);
}
}
}
int width = maxX - minX + 1;
int height = maxY - minY + 1;
// Filter out anti-aliasing artifacts while preserving small sprites
// Cap minArea at 256 to ensure 16x16 sprites are always preserved regardless of texture size
// Cap minDimension at 16 to ensure small sprites aren't filtered on large textures
int minArea = Math.Max(4, Math.Min(256, (textureWidth * textureHeight) / 1000));
int minDimension = Math.Max(
2,
Math.Min(16, Math.Min(textureWidth, textureHeight) / 64)
);
if (
width >= minDimension
&& height >= minDimension
&& width * height >= minArea
)
{
result.Add(new Rect(minX, minY, width, height));
}
}
}
}
///
/// Generates candidate cell sizes for a given dimension.
/// Includes common sprite sizes (8, 16, 32, etc.) and all divisors of the dimension.
///
/// The texture dimension (width or height) to generate candidates for.
/// Output list to populate with candidate cell sizes.
private static void GenerateCandidateCellSizes(int dimension, List candidates)
{
candidates.Clear();
// Add common sizes that divide evenly
for (int i = 0; i < CommonCellSizes.Length; ++i)
{
int size = CommonCellSizes[i];
if (size >= MinimumCellSize && size <= dimension && dimension % size == 0)
{
candidates.Add(size);
}
}
// Add all divisors >= MinimumCellSize using sqrt optimization
// Iterate only to sqrt(dimension) and add both divisor pairs
for (int div = MinimumCellSize; div * div <= dimension; ++div)
{
if (dimension % div == 0)
{
if (!candidates.Contains(div))
{
candidates.Add(div);
}
int complement = dimension / div;
if (
complement != div
&& complement >= MinimumCellSize
&& !candidates.Contains(complement)
)
{
candidates.Add(complement);
}
}
}
// Add the full dimension itself
if (!candidates.Contains(dimension))
{
candidates.Add(dimension);
}
}
///
/// Scores a cell size by measuring boundary transparency.
/// Uses EXACT grid line positions only (no offset checking) to prevent
/// smaller cell sizes from getting artificially high scores by finding
/// nearby transparent pixels.
///
/// Array of transparent pixel counts per column or row.
/// The texture dimension being scored (width or height).
/// The perpendicular dimension (height for width scoring, width for height).
/// Candidate cell size to evaluate.
/// Normalized score in range [0, 1] where 1 means all boundaries are fully transparent.
private static float ScoreCellSizeForDimension(
int[] transparencyCount,
int dimension,
int orthogonalDimension,
int cellSize
)
{
if (cellSize <= 0 || dimension % cellSize != 0)
{
return 0f;
}
int numBoundaries = dimension / cellSize - 1;
if (numBoundaries <= 0)
{
return 0f;
}
float totalScore = 0f;
for (int i = 1; i <= numBoundaries; ++i)
{
int boundaryPos = i * cellSize;
if (boundaryPos < dimension)
{
// Check EXACT position only - no offset checking
// This prevents smaller cell sizes from gaming the scoring
// by finding adjacent transparent pixels
float transparency =
(float)transparencyCount[boundaryPos] / orthogonalDimension;
float lineScore = ComputeTransparencyLineScore(transparency);
totalScore += lineScore;
}
}
float avgScore = totalScore / numBoundaries;
return avgScore / MaxTransparencyLineScore;
}
///
/// Finds the nearest divisor of dimension to the target value.
/// Returns the target if it already evenly divides the dimension.
///
/// The texture dimension that must be evenly divisible.
/// The desired cell size to match as closely as possible.
/// The divisor of dimension closest to target, or target if it divides evenly.
private static int FindNearestDivisor(int dimension, int target)
{
if (dimension % target == 0)
{
return target;
}
int bestDivisor = target;
int bestDiff = int.MaxValue;
for (int div = MinimumCellSize; div <= dimension; ++div)
{
if (dimension % div == 0)
{
int diff = Mathf.Abs(div - target);
if (diff < bestDiff)
{
bestDiff = diff;
bestDivisor = div;
}
}
}
return bestDivisor;
}
///
/// Checks if a value is a power of two.
///
/// The value to check.
/// True if the value is a positive power of two (1, 2, 4, 8, ...).
private static bool IsPowerOfTwo(int value)
{
return value > 0 && (value & (value - 1)) == 0;
}
///
/// Finds the best cell divisor by scoring how well grid lines align with transparent regions.
/// Searches ALL valid divisors (not just those near base size), but prefers divisors close
/// to the base size when scores are similar.
/// When texture dimensions aren't cleanly divisible, analyzes remainder pixels:
/// - If greater than 90% transparent, discards remainder
/// - Otherwise adjusts to include content
///
/// Source texture pixel data.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Initial computed cell width.
/// Initial computed cell height.
/// Alpha threshold (0-1) below which pixels are considered transparent.
/// Optional list of detected sprite bounds to penalize divisors that would split sprites.
/// Adjusted cell size that best aligns with transparency boundaries.
internal static Vector2Int FindBestTransparencyAlignedDivisor(
Color32[] pixels,
int textureWidth,
int textureHeight,
int baseCellWidth,
int baseCellHeight,
float transparencyThreshold = 0.1f,
List spriteBounds = null
)
{
int fallbackWidth =
textureWidth % baseCellWidth == 0
? baseCellWidth
: FindNearestDivisor(textureWidth, baseCellWidth);
int fallbackHeight =
textureHeight % baseCellHeight == 0
? baseCellHeight
: FindNearestDivisor(textureHeight, baseCellHeight);
int bestWidth = fallbackWidth;
int bestHeight = fallbackHeight;
// Collect ALL valid divisors for width and height
using PooledResource> widthDivisorsLease = Buffers.List.Get(
out List widthDivisors
);
using PooledResource> heightDivisorsLease = Buffers.List.Get(
out List heightDivisors
);
// Find all divisors >= MinimumCellSize using sqrt optimization
// Iterate only to sqrt(dimension) and add both divisor pairs
for (int div = MinimumCellSize; div * div <= textureWidth; ++div)
{
if (textureWidth % div == 0)
{
widthDivisors.Add(div);
int complement = textureWidth / div;
if (complement != div && complement >= MinimumCellSize)
{
widthDivisors.Add(complement);
}
}
}
for (int div = MinimumCellSize; div * div <= textureHeight; ++div)
{
if (textureHeight % div == 0)
{
heightDivisors.Add(div);
int complement = textureHeight / div;
if (complement != div && complement >= MinimumCellSize)
{
heightDivisors.Add(complement);
}
}
}
float bestScore = float.MinValue;
float bestCombinedBonus = float.MinValue;
bool foundCandidate = false;
// Calculate expected cell count from base dimensions
// This is the PRIMARY constraint - we should not deviate significantly from this
int baseCols = textureWidth / baseCellWidth;
int baseRows = textureHeight / baseCellHeight;
int expectedCellCount = baseCols * baseRows;
int minCellCount = Math.Max(1, (int)(expectedCellCount / MaxCellCountRatio));
int maxCellCount = (int)(expectedCellCount * MaxCellCountRatio);
// Score the fallback first to establish baseline
float fallbackScore = ScoreDivisorByTransparency(
pixels,
textureWidth,
textureHeight,
fallbackWidth,
fallbackHeight,
transparencyThreshold
);
bestScore = fallbackScore;
bestCombinedBonus = 1.0f; // Fallback has maximum proximity bonus
for (int wi = 0; wi < widthDivisors.Count; ++wi)
{
int candidateWidth = widthDivisors[wi];
for (int hi = 0; hi < heightDivisors.Count; ++hi)
{
int candidateHeight = heightDivisors[hi];
// HARD CONSTRAINT: Cell count must be close to expected count
// This prevents the function from choosing a completely different grid
int candidateCols = textureWidth / candidateWidth;
int candidateRows = textureHeight / candidateHeight;
int candidateCellCount = candidateCols * candidateRows;
if (candidateCellCount < minCellCount || candidateCellCount > maxCellCount)
{
continue; // Skip divisors that produce wrong cell counts
}
float score = ScoreDivisorByTransparency(
pixels,
textureWidth,
textureHeight,
candidateWidth,
candidateHeight,
transparencyThreshold
);
// HARD CONSTRAINT: Reject any divisor that would split sprites through their center
if (spriteBounds != null && spriteBounds.Count > 0)
{
float spriteFitScore = CalculateSpriteFitScore(
spriteBounds,
candidateWidth,
candidateHeight,
textureWidth,
textureHeight
);
// HARD CONSTRAINT: Reject divisors that split sprites
if (spriteFitScore < SpriteFitHardThresholdStrict)
{
continue; // Skip this divisor entirely
}
// Mild bonus for excellent sprite fit
if (spriteFitScore >= 0.95f)
{
score *= 1.1f;
}
}
// Proximity bonus: how close is this to the base cell size
float widthProximity =
1f
- Mathf.Clamp01(
(float)Math.Abs(candidateWidth - baseCellWidth) / baseCellWidth
);
float heightProximity =
1f
- Mathf.Clamp01(
(float)Math.Abs(candidateHeight - baseCellHeight) / baseCellHeight
);
float proximityBonus = (widthProximity + heightProximity) * 0.5f;
// Size bonus: prefer larger cell sizes (fewer cells = less likely to split sprites)
float sizeBonus =
(float)(candidateWidth + candidateHeight) / (textureWidth + textureHeight);
// Combined bonus: proximity matters most, but also prefer larger sizes
float combinedBonus = proximityBonus * 0.6f + sizeBonus * 0.4f;
// A candidate is better if:
// 1. It has significantly better transparency score (> 15% better), OR
// 2. It has similar transparency but better combined bonus (proximity + size)
const float scoreDifferenceThreshold = 0.15f;
bool isMuchBetterScore = score > bestScore + scoreDifferenceThreshold;
bool isSimilarScoreButBetter =
Math.Abs(score - bestScore) <= scoreDifferenceThreshold
&& combinedBonus > bestCombinedBonus;
if (isMuchBetterScore || isSimilarScoreButBetter)
{
bestScore = score;
bestCombinedBonus = combinedBonus;
bestWidth = candidateWidth;
bestHeight = candidateHeight;
foundCandidate = true;
}
}
}
if (!foundCandidate)
{
bestWidth = fallbackWidth;
bestHeight = fallbackHeight;
}
int remainderX = textureWidth % bestWidth;
int remainderY = textureHeight % bestHeight;
if (remainderX > 0)
{
float remainderTransparency = CalculateColumnTransparency(
pixels,
textureWidth,
textureHeight,
textureWidth - remainderX,
remainderX,
transparencyThreshold
);
if (remainderTransparency < RemainderTransparencyThreshold)
{
int cols = textureWidth / bestWidth;
if (cols > 0)
{
bestWidth = textureWidth / cols;
}
}
}
if (remainderY > 0)
{
float remainderTransparency = CalculateRowTransparency(
pixels,
textureWidth,
textureHeight,
textureHeight - remainderY,
remainderY,
transparencyThreshold
);
if (remainderTransparency < RemainderTransparencyThreshold)
{
int rows = textureHeight / bestHeight;
if (rows > 0)
{
bestHeight = textureHeight / rows;
}
}
}
return new Vector2Int(bestWidth, bestHeight);
}
///
/// Scores a candidate divisor by how well vertical/horizontal grid lines align with transparent regions.
/// Uses contrast scoring: compares boundary transparency to interior opacity.
/// A good grid has HIGH boundary transparency and LOW interior transparency.
/// Also checks grid line continuity by sampling multiple positions around each grid line.
///
/// Source texture pixel data.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Candidate cell width to score.
/// Candidate cell height to score.
/// Alpha threshold (0-1) below which pixels are considered transparent.
/// Score in range [0, 1] where 1 means all grid lines pass through fully transparent pixels with good contrast.
internal static float ScoreDivisorByTransparency(
Color32[] pixels,
int textureWidth,
int textureHeight,
int cellWidth,
int cellHeight,
float transparencyThreshold
)
{
byte alphaThreshold = (byte)(transparencyThreshold * 255);
int numVerticalLines = (textureWidth / cellWidth) - 1;
int numHorizontalLines = (textureHeight / cellHeight) - 1;
if (numVerticalLines <= 0 && numHorizontalLines <= 0)
{
return 0f;
}
// Calculate boundary transparency at EXACT grid line positions only
// (no adjacent pixel checking to avoid false positives)
float totalBoundaryTransparency = 0f;
int boundaryLineCount = 0;
// Check vertical grid lines at exact position only
for (int baseX = cellWidth; baseX < textureWidth; baseX += cellWidth)
{
int transparentCount = 0;
for (int y = 0; y < textureHeight; ++y)
{
int index = y * textureWidth + baseX;
if (pixels[index].a <= alphaThreshold)
{
++transparentCount;
}
}
float lineTransparency = (float)transparentCount / textureHeight;
totalBoundaryTransparency += lineTransparency;
++boundaryLineCount;
}
// Check horizontal grid lines at exact position only
for (int baseY = cellHeight; baseY < textureHeight; baseY += cellHeight)
{
int transparentCount = 0;
for (int x = 0; x < textureWidth; ++x)
{
int index = baseY * textureWidth + x;
if (pixels[index].a <= alphaThreshold)
{
++transparentCount;
}
}
float lineTransparency = (float)transparentCount / textureWidth;
totalBoundaryTransparency += lineTransparency;
++boundaryLineCount;
}
if (boundaryLineCount <= 0)
{
return 0f;
}
float avgBoundaryTransparency = totalBoundaryTransparency / boundaryLineCount;
// Calculate interior opacity (center of cells, away from boundaries)
float totalInteriorOpacity = 0f;
int interiorSampleCount = 0;
int numColumns = textureWidth / cellWidth;
int numRows = textureHeight / cellHeight;
// Sample center of each cell
for (int col = 0; col < numColumns; ++col)
{
for (int row = 0; row < numRows; ++row)
{
int centerX = col * cellWidth + cellWidth / 2;
int centerY = row * cellHeight + cellHeight / 2;
if (centerX >= textureWidth || centerY >= textureHeight)
{
continue;
}
// Sample a small region around the center
int sampleRadius = Math.Min(cellWidth, cellHeight) / 4;
sampleRadius = Math.Max(1, sampleRadius);
int opaqueCount = 0;
int sampleCount = 0;
for (int dy = -sampleRadius; dy <= sampleRadius; ++dy)
{
int y = centerY + dy;
if (y < 0 || y >= textureHeight)
{
continue;
}
for (int dx = -sampleRadius; dx <= sampleRadius; ++dx)
{
int x = centerX + dx;
if (x < 0 || x >= textureWidth)
{
continue;
}
int index = y * textureWidth + x;
if (pixels[index].a > alphaThreshold)
{
++opaqueCount;
}
++sampleCount;
}
}
if (sampleCount > 0)
{
totalInteriorOpacity += (float)opaqueCount / sampleCount;
++interiorSampleCount;
}
}
}
float avgInteriorOpacity =
interiorSampleCount > 0 ? totalInteriorOpacity / interiorSampleCount : 0f;
// Contrast score: good grids have transparent boundaries and opaque interiors
// Score = avgBoundaryTransparency * (1 + avgInteriorOpacity * 0.5)
// This rewards both high boundary transparency AND high contrast with interiors
float contrastBonus = avgInteriorOpacity * 0.5f;
float score = avgBoundaryTransparency * (1f + contrastBonus);
// Normalize to [0, 1]
return Mathf.Clamp01(score);
}
///
/// Computes a linear score for a transparency ratio.
/// Uses linear scaling to ensure proportional transparency differences are preserved,
/// which is important for the proximity-based tiebreaking logic.
///
/// Transparency ratio in range [0, 1].
/// Score scaled to MaxTransparencyLineScore.
private static float ComputeTransparencyLineScore(float transparency)
{
return transparency * MaxTransparencyLineScore;
}
///
/// Calculates percentage of transparent pixels in a column region.
///
/// Source texture pixel data.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Starting X position of the column region.
/// Width of the column region to analyze.
/// Alpha threshold (0-1) below which pixels are considered transparent.
/// Percentage of transparent pixels in the region (0-1).
private static float CalculateColumnTransparency(
Color32[] pixels,
int textureWidth,
int textureHeight,
int startX,
int width,
float transparencyThreshold
)
{
int transparentCount = 0;
int totalCount = 0;
byte alphaThreshold = (byte)(transparencyThreshold * 255);
for (int x = startX; x < startX + width && x < textureWidth; ++x)
{
for (int y = 0; y < textureHeight; ++y)
{
int index = y * textureWidth + x;
if (pixels[index].a < alphaThreshold)
{
++transparentCount;
}
++totalCount;
}
}
return totalCount > 0 ? (float)transparentCount / totalCount : 1f;
}
///
/// Calculates percentage of transparent pixels in a row region.
///
/// Source texture pixel data.
/// Width of the texture in pixels.
/// Height of the texture in pixels.
/// Starting Y position of the row region.
/// Height of the row region to analyze.
/// Alpha threshold (0-1) below which pixels are considered transparent.
/// Percentage of transparent pixels in the region (0-1).
private static float CalculateRowTransparency(
Color32[] pixels,
int textureWidth,
int textureHeight,
int startY,
int height,
float transparencyThreshold
)
{
int transparentCount = 0;
int totalCount = 0;
byte alphaThreshold = (byte)(transparencyThreshold * 255);
for (int y = startY; y < startY + height && y < textureHeight; ++y)
{
for (int x = 0; x < textureWidth; ++x)
{
int index = y * textureWidth + x;
if (pixels[index].a < alphaThreshold)
{
++transparentCount;
}
++totalCount;
}
}
return totalCount > 0 ? (float)transparentCount / totalCount : 1f;
}
}
#endif
}