533 lines
19 KiB
C#
533 lines
19 KiB
C#
using LightlessSync.FileCache;
|
|
using LightlessSync.LightlessConfiguration;
|
|
using LightlessSync.LightlessConfiguration.Configurations;
|
|
using LightlessSync.Utils;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Concurrent;
|
|
using System.Globalization;
|
|
|
|
namespace LightlessSync.Services.ModelDecimation;
|
|
|
|
public sealed class ModelDecimationService
|
|
{
|
|
private const int MaxConcurrentJobs = 1;
|
|
private const double MinTargetRatio = 0.01;
|
|
private const double MaxTargetRatio = 0.99;
|
|
|
|
private readonly ILogger<ModelDecimationService> _logger;
|
|
private readonly LightlessConfigService _configService;
|
|
private readonly FileCacheManager _fileCacheManager;
|
|
private readonly PlayerPerformanceConfigService _performanceConfigService;
|
|
private readonly XivDataStorageService _xivDataStorageService;
|
|
private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs);
|
|
|
|
private readonly TaskRegistry<string> _decimationDeduplicator = new();
|
|
private readonly ConcurrentDictionary<string, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public ModelDecimationService(
|
|
ILogger<ModelDecimationService> logger,
|
|
LightlessConfigService configService,
|
|
FileCacheManager fileCacheManager,
|
|
PlayerPerformanceConfigService performanceConfigService,
|
|
XivDataStorageService xivDataStorageService)
|
|
{
|
|
_logger = logger;
|
|
_configService = configService;
|
|
_fileCacheManager = fileCacheManager;
|
|
_performanceConfigService = performanceConfigService;
|
|
_xivDataStorageService = xivDataStorageService;
|
|
}
|
|
|
|
public void ScheduleDecimation(string hash, string filePath, string? gamePath = null)
|
|
{
|
|
if (!ShouldScheduleDecimation(hash, filePath, gamePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _decimationDeduplicator.TryGetExisting(hash, out _))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_logger.LogDebug("Queued model decimation for {Hash}", hash);
|
|
|
|
_decimationDeduplicator.GetOrStart(hash, async () =>
|
|
{
|
|
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
await DecimateInternalAsync(hash, filePath).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_failedHashes[hash] = 1;
|
|
_logger.LogWarning(ex, "Model decimation failed for {Hash}", hash);
|
|
}
|
|
finally
|
|
{
|
|
_decimationSemaphore.Release();
|
|
}
|
|
});
|
|
}
|
|
|
|
public void ScheduleBatchDecimation(string hash, string filePath, ModelDecimationSettings settings)
|
|
{
|
|
if (!ShouldScheduleBatchDecimation(hash, filePath, settings))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_decimationDeduplicator.TryGetExisting(hash, out _))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_failedHashes.TryRemove(hash, out _);
|
|
_decimatedPaths.TryRemove(hash, out _);
|
|
|
|
_logger.LogInformation("Queued batch model decimation for {Hash}", hash);
|
|
|
|
_decimationDeduplicator.GetOrStart(hash, async () =>
|
|
{
|
|
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
await DecimateInternalAsync(hash, filePath, settings, allowExisting: false, destinationOverride: filePath, registerDecimatedPath: false).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_failedHashes[hash] = 1;
|
|
_logger.LogWarning(ex, "Batch model decimation failed for {Hash}", hash);
|
|
}
|
|
finally
|
|
{
|
|
_decimationSemaphore.Release();
|
|
}
|
|
});
|
|
}
|
|
|
|
public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null)
|
|
{
|
|
var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold);
|
|
return IsDecimationEnabled()
|
|
&& filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
|
|
&& IsDecimationAllowed(gamePath)
|
|
&& !ShouldSkipByTriangleCache(hash, threshold);
|
|
}
|
|
|
|
public string GetPreferredPath(string hash, string originalPath)
|
|
{
|
|
if (!IsDecimationEnabled())
|
|
{
|
|
return originalPath;
|
|
}
|
|
|
|
if (_decimatedPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
var resolved = GetExistingDecimatedPath(hash);
|
|
if (!string.IsNullOrEmpty(resolved))
|
|
{
|
|
_decimatedPaths[hash] = resolved;
|
|
return resolved;
|
|
}
|
|
|
|
return originalPath;
|
|
}
|
|
|
|
public Task WaitForPendingJobsAsync(IEnumerable<string>? hashes, CancellationToken token)
|
|
{
|
|
if (hashes is null)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var pending = new List<Task>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var hash in hashes)
|
|
{
|
|
if (string.IsNullOrEmpty(hash) || !seen.Add(hash))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (_decimationDeduplicator.TryGetExisting(hash, out var job))
|
|
{
|
|
pending.Add(job);
|
|
}
|
|
}
|
|
|
|
if (pending.Count == 0)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
return Task.WhenAll(pending).WaitAsync(token);
|
|
}
|
|
|
|
private Task DecimateInternalAsync(string hash, string sourcePath)
|
|
{
|
|
if (!TryGetDecimationSettings(out var settings))
|
|
{
|
|
_logger.LogDebug("Model decimation disabled or invalid settings for {Hash}", hash);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
return DecimateInternalAsync(hash, sourcePath, settings, allowExisting: true);
|
|
}
|
|
|
|
private Task DecimateInternalAsync(
|
|
string hash,
|
|
string sourcePath,
|
|
ModelDecimationSettings settings,
|
|
bool allowExisting,
|
|
string? destinationOverride = null,
|
|
bool registerDecimatedPath = true)
|
|
{
|
|
if (!File.Exists(sourcePath))
|
|
{
|
|
_failedHashes[hash] = 1;
|
|
_logger.LogWarning("Cannot decimate model {Hash}; source path missing: {Path}", hash, sourcePath);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
if (!TryNormalizeSettings(settings, out var normalized))
|
|
{
|
|
_logger.LogDebug("Model decimation skipped for {Hash}; invalid settings.", hash);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents}, avoid body intersection {AvoidBodyIntersection})",
|
|
hash,
|
|
normalized.TriangleThreshold,
|
|
normalized.TargetRatio,
|
|
normalized.NormalizeTangents,
|
|
normalized.AvoidBodyIntersection);
|
|
|
|
var destination = destinationOverride ?? Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
|
|
var inPlace = string.Equals(destination, sourcePath, StringComparison.OrdinalIgnoreCase);
|
|
if (!inPlace && File.Exists(destination))
|
|
{
|
|
if (allowExisting)
|
|
{
|
|
if (registerDecimatedPath)
|
|
{
|
|
RegisterDecimatedModel(hash, sourcePath, destination);
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
TryDelete(destination);
|
|
}
|
|
|
|
if (!MdlDecimator.TryDecimate(sourcePath, destination, normalized, _logger))
|
|
{
|
|
_failedHashes[hash] = 1;
|
|
_logger.LogDebug("Model decimation skipped for {Hash}", hash);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
if (registerDecimatedPath)
|
|
{
|
|
RegisterDecimatedModel(hash, sourcePath, destination);
|
|
}
|
|
_logger.LogDebug("Decimated model {Hash} -> {Path}", hash, destination);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private void RegisterDecimatedModel(string hash, string sourcePath, string destination)
|
|
{
|
|
_decimatedPaths[hash] = destination;
|
|
|
|
var performanceConfig = _performanceConfigService.Current;
|
|
if (performanceConfig.KeepOriginalModelFiles)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!TryReplaceCacheEntryWithDecimated(hash, sourcePath, destination))
|
|
{
|
|
return;
|
|
}
|
|
|
|
TryDelete(sourcePath);
|
|
}
|
|
|
|
private bool TryReplaceCacheEntryWithDecimated(string hash, string sourcePath, string destination)
|
|
{
|
|
try
|
|
{
|
|
var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash);
|
|
if (cacheEntry is null || !cacheEntry.IsCacheEntry)
|
|
{
|
|
return File.Exists(sourcePath) ? false : true;
|
|
}
|
|
|
|
var cacheFolder = _configService.Current.CacheFolder;
|
|
if (string.IsNullOrEmpty(cacheFolder))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var info = new FileInfo(destination);
|
|
if (!info.Exists)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var relative = Path.GetRelativePath(cacheFolder, destination)
|
|
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
|
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
|
|
var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
|
|
|
|
var replacement = new FileCacheEntity(
|
|
hash,
|
|
prefixed,
|
|
info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
|
|
info.Length,
|
|
cacheEntry.CompressedSize);
|
|
replacement.SetResolvedFilePath(destination);
|
|
|
|
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
|
|
}
|
|
|
|
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
|
|
_fileCacheManager.WriteOutFullCsv();
|
|
|
|
_logger.LogTrace("Replaced cache entry for model {Hash} to decimated path {Path}", hash, destination);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogTrace(ex, "Failed to replace cache entry for model {Hash}", hash);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private bool IsDecimationEnabled()
|
|
=> _performanceConfigService.Current.EnableModelDecimation;
|
|
|
|
private bool ShouldSkipByTriangleCache(string hash, int triangleThreshold)
|
|
{
|
|
if (string.IsNullOrEmpty(hash))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!_xivDataStorageService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) || cachedTris <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var threshold = Math.Max(0, triangleThreshold);
|
|
return threshold > 0 && cachedTris < threshold;
|
|
}
|
|
|
|
private bool IsDecimationAllowed(string? gamePath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(gamePath))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var normalized = NormalizeGamePath(gamePath);
|
|
if (normalized.Contains("/hair/", StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowClothing;
|
|
}
|
|
|
|
if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowAccessories;
|
|
}
|
|
|
|
if (normalized.Contains("/chara/human/", StringComparison.Ordinal))
|
|
{
|
|
if (normalized.Contains("/body/", StringComparison.Ordinal))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowBody;
|
|
}
|
|
|
|
if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowFaceHead;
|
|
}
|
|
|
|
if (normalized.Contains("/tail/", StringComparison.Ordinal))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowTail;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string NormalizeGamePath(string path)
|
|
=> path.Replace('\\', '/').ToLowerInvariant();
|
|
|
|
private bool TryGetDecimationSettings(out ModelDecimationSettings settings)
|
|
{
|
|
settings = new ModelDecimationSettings(
|
|
ModelDecimationDefaults.TriangleThreshold,
|
|
ModelDecimationDefaults.TargetRatio,
|
|
ModelDecimationDefaults.NormalizeTangents,
|
|
ModelDecimationDefaults.AvoidBodyIntersection,
|
|
new ModelDecimationAdvancedSettings());
|
|
|
|
var config = _performanceConfigService.Current;
|
|
if (!config.EnableModelDecimation)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var advanced = NormalizeAdvancedSettings(config.ModelDecimationAdvanced);
|
|
settings = new ModelDecimationSettings(
|
|
Math.Max(0, config.ModelDecimationTriangleThreshold),
|
|
config.ModelDecimationTargetRatio,
|
|
config.ModelDecimationNormalizeTangents,
|
|
config.ModelDecimationAvoidBodyIntersection,
|
|
advanced);
|
|
|
|
return TryNormalizeSettings(settings, out settings);
|
|
}
|
|
|
|
private static bool TryNormalizeSettings(ModelDecimationSettings settings, out ModelDecimationSettings normalized)
|
|
{
|
|
var ratio = settings.TargetRatio;
|
|
if (double.IsNaN(ratio) || double.IsInfinity(ratio))
|
|
{
|
|
normalized = default;
|
|
return false;
|
|
}
|
|
|
|
ratio = Math.Clamp(ratio, MinTargetRatio, MaxTargetRatio);
|
|
var advanced = NormalizeAdvancedSettings(settings.Advanced);
|
|
normalized = new ModelDecimationSettings(
|
|
Math.Max(0, settings.TriangleThreshold),
|
|
ratio,
|
|
settings.NormalizeTangents,
|
|
settings.AvoidBodyIntersection,
|
|
advanced);
|
|
return true;
|
|
}
|
|
|
|
private static ModelDecimationAdvancedSettings NormalizeAdvancedSettings(ModelDecimationAdvancedSettings? settings)
|
|
{
|
|
var source = settings ?? new ModelDecimationAdvancedSettings();
|
|
return new ModelDecimationAdvancedSettings
|
|
{
|
|
MinComponentTriangles = Math.Clamp(source.MinComponentTriangles, 0, 1000),
|
|
MaxCollapseEdgeLengthFactor = ClampFloat(source.MaxCollapseEdgeLengthFactor, 0.1f, 10f, ModelDecimationAdvancedSettings.DefaultMaxCollapseEdgeLengthFactor),
|
|
NormalSimilarityThresholdDegrees = ClampFloat(source.NormalSimilarityThresholdDegrees, 0f, 180f, ModelDecimationAdvancedSettings.DefaultNormalSimilarityThresholdDegrees),
|
|
BoneWeightSimilarityThreshold = ClampFloat(source.BoneWeightSimilarityThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBoneWeightSimilarityThreshold),
|
|
UvSimilarityThreshold = ClampFloat(source.UvSimilarityThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultUvSimilarityThreshold),
|
|
UvSeamAngleCos = ClampFloat(source.UvSeamAngleCos, -1f, 1f, ModelDecimationAdvancedSettings.DefaultUvSeamAngleCos),
|
|
BlockUvSeamVertices = source.BlockUvSeamVertices,
|
|
AllowBoundaryCollapses = source.AllowBoundaryCollapses,
|
|
BodyCollisionDistanceFactor = ClampFloat(source.BodyCollisionDistanceFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionDistanceFactor),
|
|
BodyCollisionNoOpDistanceFactor = ClampFloat(source.BodyCollisionNoOpDistanceFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpDistanceFactor),
|
|
BodyCollisionAdaptiveRelaxFactor = ClampFloat(source.BodyCollisionAdaptiveRelaxFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveRelaxFactor),
|
|
BodyCollisionAdaptiveNearRatio = ClampFloat(source.BodyCollisionAdaptiveNearRatio, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveNearRatio),
|
|
BodyCollisionAdaptiveUvThreshold = ClampFloat(source.BodyCollisionAdaptiveUvThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveUvThreshold),
|
|
BodyCollisionNoOpUvSeamAngleCos = ClampFloat(source.BodyCollisionNoOpUvSeamAngleCos, -1f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpUvSeamAngleCos),
|
|
BodyCollisionProtectionFactor = ClampFloat(source.BodyCollisionProtectionFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionProtectionFactor),
|
|
BodyProxyTargetRatioMin = ClampFloat(source.BodyProxyTargetRatioMin, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyProxyTargetRatioMin),
|
|
BodyCollisionProxyInflate = ClampFloat(source.BodyCollisionProxyInflate, 0f, 0.1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionProxyInflate),
|
|
BodyCollisionPenetrationFactor = ClampFloat(source.BodyCollisionPenetrationFactor, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionPenetrationFactor),
|
|
MinBodyCollisionDistance = ClampFloat(source.MinBodyCollisionDistance, 1e-6f, 1f, ModelDecimationAdvancedSettings.DefaultMinBodyCollisionDistance),
|
|
MinBodyCollisionCellSize = ClampFloat(source.MinBodyCollisionCellSize, 1e-6f, 1f, ModelDecimationAdvancedSettings.DefaultMinBodyCollisionCellSize),
|
|
};
|
|
}
|
|
|
|
private static float ClampFloat(float value, float min, float max, float fallback)
|
|
{
|
|
if (float.IsNaN(value) || float.IsInfinity(value))
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
return Math.Clamp(value, min, max);
|
|
}
|
|
|
|
private bool ShouldScheduleBatchDecimation(string hash, string filePath, ModelDecimationSettings settings)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filePath) || !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!TryNormalizeSettings(settings, out _))
|
|
{
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private string? GetExistingDecimatedPath(string hash)
|
|
{
|
|
var candidate = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
|
|
return File.Exists(candidate) ? candidate : null;
|
|
}
|
|
|
|
private string GetDecimatedDirectory()
|
|
{
|
|
var directory = Path.Combine(_configService.Current.CacheFolder, "decimated");
|
|
if (!Directory.Exists(directory))
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogTrace(ex, "Failed to create decimated directory {Directory}", directory);
|
|
}
|
|
}
|
|
|
|
return directory;
|
|
}
|
|
|
|
private static void TryDelete(string? path)
|
|
{
|
|
if (string.IsNullOrEmpty(path))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
}
|
|
}
|