All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s
# Patchnotes 2.1.0 The changes in this update are more than just "patches". With a new UI, a new feature, and a bunch of bug fixes, improvements and a new member on the dev team, we thought this was more of a minor update. We would like to introduce @tsubasahane of MareCN to the team! We’re happy to work with them to bring Lightless and its features to the CN client as well as having another talented dev bring features and ideas to us. Speaking of which: # Location Sharing (Big shout out to @tsubasahane for bringing this feature) - Are you TIRED of scrambling to find the address of the venue you're in to share with your friends? We are introducing Location Sharing! An optional feature where you can share your location with direct pairs temporarily [30 minutes, 1 hour, 3 hours] minutes or until you turn it off for them. That's up to you! [#125](<#125>) [#49](<Lightless-Sync/LightlessServer#49>) - To share your location with a pair, click the three dots beside the pair and choose a duration to share with them. [#125](<#125>) [#49](<Lightless-Sync/LightlessServer#49>) - To view the location of someone who's shared with you, simply hover over the globe icon! [#125](<#125>) [#49](<Lightless-Sync/LightlessServer#49>) [1] # Model Optimization (Mesh Decimating) - This new option can automatically “simplify” incoming character meshes to help performance by reducing triangle counts. You choose how strong the reduction is (default/recommended is 80%). [#131](<#131>) - Decimation only kicks in when a mesh is above a certain triangle threshold, and only for the items that qualify for it and you selected for. [#131](<#131>) - Hair meshes is always excluded, since simplifying hair meshes is very prone to breaking. - You can find everything under Settings → Performance → Model Optimization. [#131](<#131>) + ** IF YOU HAVE USED DECIMATION IN TESTING, PLEASE CLEAR YOUR CACHE ❗ ** [2] # Animation (PAP) Validation (Safer animations) - Lightless now checks your currently animations to see if they work with your local skeleton/bone mod. If an animation matches, it’s included in what gets sent to other players. If it doesn’t, Lightless will skip it and write a warning to your log showing how many were skipped due to skeleton changes. Its defaulted to Unsafe (off). turn it on if you experience crashes from others users. [#131](<#131>) - Lightless also does the same kind of check for incoming animation files, to make sure they match the body/skeleton they were sent with. [#131](<#131>) - Because these checks can sometimes be a little picky, you can adjust how strict they are in Settings -> General -> Animation & Bones to reduce false positives. [#131](<#131>) # UI Changes (Thanks to @kyuwu for UI Changes) - The top part of the main screen has gotten a makeover. You can adjust the colors of the gradiant in the Color settings of Lightless. [#127](<#127>) [3] - Settings have gotten some changes as well to make this change more universal, and will use the same color settings. [#127](<#127>) - The particle effects of the gradient are toggleable in 'Settings -> UI -> Behavior' [#127](<#127>) - Instead of showing download/upload on bottom of Main UI, it will show VRAM usage and triangles with their optimization options next to it [#138](<#138>) # LightFinder / ShellFinder - UI Changes that follow our new design follow the color codes for the Gradient top as the main screen does. [#127](<#127>) [4] Co-authored-by: defnotken <itsdefnotken@gmail.com> Co-authored-by: azyges <aaaaaa@aaa.aaa> Co-authored-by: cake <admin@cakeandbanana.nl> Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org> Co-authored-by: choco <choco@patat.nl> Co-authored-by: celine <aaa@aaa.aaa> Co-authored-by: celine <celine@noreply.git.lightless-sync.org> Co-authored-by: Tsubasahane <wozaiha@gmail.com> Co-authored-by: cake <cake@noreply.git.lightless-sync.org> Reviewed-on: #123
531 lines
19 KiB
C#
531 lines
19 KiB
C#
using LightlessSync.FileCache;
|
|
using LightlessSync.LightlessConfiguration;
|
|
using LightlessSync.LightlessConfiguration.Configurations;
|
|
using LightlessSync.Services;
|
|
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 ModelProcessingQueue _processingQueue;
|
|
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,
|
|
ModelProcessingQueue processingQueue)
|
|
{
|
|
_logger = logger;
|
|
_configService = configService;
|
|
_fileCacheManager = fileCacheManager;
|
|
_performanceConfigService = performanceConfigService;
|
|
_xivDataStorageService = xivDataStorageService;
|
|
_processingQueue = processingQueue;
|
|
}
|
|
|
|
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, () => _processingQueue.Enqueue(async token =>
|
|
{
|
|
await _decimationSemaphore.WaitAsync(token).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();
|
|
}
|
|
}, CancellationToken.None));
|
|
}
|
|
|
|
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, () => _processingQueue.Enqueue(async token =>
|
|
{
|
|
await _decimationSemaphore.WaitAsync(token).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();
|
|
}
|
|
}, CancellationToken.None));
|
|
}
|
|
|
|
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 = ModelDecimationFilters.NormalizePath(gamePath);
|
|
if (ModelDecimationFilters.IsHairPath(normalized))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (ModelDecimationFilters.IsClothingPath(normalized))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowClothing;
|
|
}
|
|
|
|
if (ModelDecimationFilters.IsAccessoryPath(normalized))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowAccessories;
|
|
}
|
|
|
|
if (ModelDecimationFilters.IsBodyPath(normalized))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowBody;
|
|
}
|
|
|
|
if (ModelDecimationFilters.IsFaceHeadPath(normalized))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowFaceHead;
|
|
}
|
|
|
|
if (ModelDecimationFilters.IsTailOrEarPath(normalized))
|
|
{
|
|
return _performanceConfigService.Current.ModelDecimationAllowTail;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|