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
944 lines
35 KiB
C#
944 lines
35 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Buffers;
|
|
using System.Buffers.Binary;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using OtterTex;
|
|
using OtterImage = OtterTex.Image;
|
|
using LightlessSync.LightlessConfiguration;
|
|
using LightlessSync.Services;
|
|
using LightlessSync.Utils;
|
|
using LightlessSync.FileCache;
|
|
using Microsoft.Extensions.Logging;
|
|
using Lumina.Data.Files;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
|
|
/*
|
|
* OtterTex made by Ottermandias
|
|
* thank you!!
|
|
*/
|
|
|
|
namespace LightlessSync.Services.TextureCompression;
|
|
|
|
public sealed class TextureDownscaleService
|
|
{
|
|
private const int DefaultTargetMaxDimension = 2048;
|
|
private const int MaxSupportedTargetDimension = 8192;
|
|
private const int BlockMultiple = 4;
|
|
|
|
private readonly ILogger<TextureDownscaleService> _logger;
|
|
private readonly LightlessConfigService _configService;
|
|
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
|
private readonly FileCacheManager _fileCacheManager;
|
|
private readonly TextureCompressionService _textureCompressionService;
|
|
private readonly TextureProcessingQueue _processingQueue;
|
|
|
|
private readonly TaskRegistry<string> _downscaleDeduplicator = new();
|
|
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
|
|
private readonly SemaphoreSlim _compressionSemaphore = new(1);
|
|
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
|
|
new Dictionary<int, TextureCompressionTarget>
|
|
{
|
|
[70] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_TYPELESS
|
|
[71] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM
|
|
[72] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM_SRGB
|
|
|
|
[73] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_TYPELESS
|
|
[74] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM
|
|
[75] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM_SRGB
|
|
[76] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_TYPELESS
|
|
[77] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM
|
|
[78] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM_SRGB
|
|
|
|
[79] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_TYPELESS
|
|
[80] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_UNORM
|
|
[81] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_SNORM
|
|
|
|
[82] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_TYPELESS
|
|
[83] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_UNORM
|
|
[84] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_SNORM
|
|
|
|
[94] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_TYPELESS (treated as BC7 for block detection)
|
|
[95] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_UF16
|
|
[96] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_SF16
|
|
[97] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_TYPELESS
|
|
[98] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM
|
|
[99] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM_SRGB
|
|
};
|
|
|
|
public TextureDownscaleService(
|
|
ILogger<TextureDownscaleService> logger,
|
|
LightlessConfigService configService,
|
|
PlayerPerformanceConfigService playerPerformanceConfigService,
|
|
FileCacheManager fileCacheManager,
|
|
TextureCompressionService textureCompressionService,
|
|
TextureProcessingQueue processingQueue)
|
|
{
|
|
_logger = logger;
|
|
_configService = configService;
|
|
_playerPerformanceConfigService = playerPerformanceConfigService;
|
|
_fileCacheManager = fileCacheManager;
|
|
_textureCompressionService = textureCompressionService;
|
|
_processingQueue = processingQueue;
|
|
}
|
|
|
|
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
|
|
=> ScheduleDownscale(hash, filePath, () => mapKind);
|
|
|
|
public void ScheduleDownscale(string hash, string filePath, Func<TextureMapKind> mapKindFactory)
|
|
{
|
|
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
|
|
if (_downscaleDeduplicator.TryGetExisting(hash, out _)) return;
|
|
|
|
_downscaleDeduplicator.GetOrStart(hash, () => _processingQueue.Enqueue(async token =>
|
|
{
|
|
TextureMapKind mapKind;
|
|
try
|
|
{
|
|
mapKind = mapKindFactory();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to determine texture map kind for {Hash}; skipping downscale", hash);
|
|
return;
|
|
}
|
|
|
|
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
|
|
}, CancellationToken.None));
|
|
}
|
|
|
|
public bool ShouldScheduleDownscale(string filePath)
|
|
{
|
|
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
|
|
var performanceConfig = _playerPerformanceConfigService.Current;
|
|
return performanceConfig.EnableNonIndexTextureMipTrim
|
|
|| performanceConfig.EnableIndexTextureDownscale
|
|
|| performanceConfig.EnableUncompressedTextureCompression;
|
|
}
|
|
|
|
public string GetPreferredPath(string hash, string originalPath)
|
|
{
|
|
if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
var resolved = GetExistingDownscaledPath(hash);
|
|
if (!string.IsNullOrEmpty(resolved))
|
|
{
|
|
_downscaledPaths[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 (_downscaleDeduplicator.TryGetExisting(hash, out var job))
|
|
{
|
|
pending.Add(job);
|
|
}
|
|
}
|
|
|
|
if (pending.Count == 0)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
return Task.WhenAll(pending).WaitAsync(token);
|
|
}
|
|
|
|
private async Task DownscaleInternalAsync(string hash, string sourcePath, TextureMapKind mapKind)
|
|
{
|
|
TexHeaderInfo? headerInfo = null;
|
|
string? destination = null;
|
|
int targetMaxDimension = 0;
|
|
bool onlyDownscaleUncompressed = false;
|
|
bool? isIndexTexture = null;
|
|
|
|
await _downscaleSemaphore.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (!File.Exists(sourcePath))
|
|
{
|
|
_logger.LogWarning("Cannot downscale texture {Hash}; source path missing: {Path}", hash, sourcePath);
|
|
return;
|
|
}
|
|
|
|
headerInfo = TryReadTexHeader(sourcePath, out var header)
|
|
? header
|
|
: (TexHeaderInfo?)null;
|
|
var performanceConfig = _playerPerformanceConfigService.Current;
|
|
targetMaxDimension = ResolveTargetMaxDimension();
|
|
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures;
|
|
|
|
if (onlyDownscaleUncompressed && !headerInfo.HasValue)
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for texture {Hash}; format unknown and only-uncompressed enabled.", hash);
|
|
return;
|
|
}
|
|
|
|
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
|
|
if (File.Exists(destination))
|
|
{
|
|
RegisterDownscaledTexture(hash, sourcePath, destination);
|
|
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
var indexTexture = IsIndexMap(mapKind);
|
|
isIndexTexture = indexTexture;
|
|
if (!indexTexture)
|
|
{
|
|
if (performanceConfig.EnableNonIndexTextureMipTrim
|
|
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
|
|
{
|
|
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (!performanceConfig.EnableNonIndexTextureMipTrim)
|
|
{
|
|
_logger.LogTrace("Skipping mip trim for non-index texture {Hash}; feature disabled.", hash);
|
|
}
|
|
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
|
|
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (!performanceConfig.EnableIndexTextureDownscale)
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
|
|
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (headerInfo is { } headerValue &&
|
|
headerValue.Width <= targetMaxDimension &&
|
|
headerValue.Height <= targetMaxDimension)
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height);
|
|
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (onlyDownscaleUncompressed && headerInfo.HasValue && IsBlockCompressedFormat(headerInfo.Value.Format))
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format);
|
|
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
using var sourceScratch = TexFileHelper.Load(sourcePath);
|
|
var sourceFormat = sourceScratch.Meta.Format;
|
|
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
|
|
|
|
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
|
|
var width = rgbaInfo.Meta.Width;
|
|
var height = rgbaInfo.Meta.Height;
|
|
var requiredLength = width * height * bytesPerPixel;
|
|
|
|
var rgbaPixels = rgbaScratch.Pixels.Slice(0, requiredLength);
|
|
using var originalImage = SixLabors.ImageSharp.Image.LoadPixelData<Rgba32>(rgbaPixels, width, height);
|
|
|
|
var targetSize = CalculateTargetSize(originalImage.Width, originalImage.Height, targetMaxDimension);
|
|
if (targetSize.width == originalImage.Width && targetSize.height == originalImage.Height)
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
|
|
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
|
|
|
|
var canReencodeWithPenumbra = TryResolveCompressionTarget(headerInfo, sourceFormat, out var compressionTarget);
|
|
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
|
|
if (!TryConvertForSave(resizedScratch, sourceFormat, out var finalScratch, canReencodeWithPenumbra))
|
|
{
|
|
if (canReencodeWithPenumbra
|
|
&& await TryReencodeWithPenumbraAsync(hash, sourcePath, destination, resizedScratch, compressionTarget).ConfigureAwait(false))
|
|
{
|
|
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace(
|
|
"Skipping downscale for index texture {Hash}; failed to re-encode to {Format}.",
|
|
hash,
|
|
sourceFormat);
|
|
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
using (finalScratch)
|
|
{
|
|
TexFileHelper.Save(destination, finalScratch);
|
|
RegisterDownscaledTexture(hash, sourcePath, destination);
|
|
}
|
|
|
|
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
TryDelete(destination);
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Texture downscale failed for {Hash} ({MapKind}) from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, IsIndex={IsIndexTexture}, HeaderFormat={HeaderFormat}",
|
|
hash,
|
|
mapKind,
|
|
sourcePath,
|
|
destination ?? "<unresolved>",
|
|
targetMaxDimension,
|
|
onlyDownscaleUncompressed,
|
|
isIndexTexture,
|
|
headerInfo?.Format);
|
|
}
|
|
finally
|
|
{
|
|
_downscaleSemaphore.Release();
|
|
}
|
|
}
|
|
|
|
private static (int width, int height) CalculateTargetSize(int width, int height, int targetMaxDimension)
|
|
{
|
|
var resultWidth = width;
|
|
var resultHeight = height;
|
|
|
|
while (Math.Max(resultWidth, resultHeight) > targetMaxDimension)
|
|
{
|
|
resultWidth = Math.Max(BlockMultiple, resultWidth / 2);
|
|
resultHeight = Math.Max(BlockMultiple, resultHeight / 2);
|
|
}
|
|
|
|
return (resultWidth, resultHeight);
|
|
}
|
|
|
|
private static ScratchImage CreateScratchImage(Image<Rgba32> image, int width, int height)
|
|
{
|
|
const int BytesPerPixel = 4;
|
|
var requiredLength = width * height * BytesPerPixel;
|
|
|
|
static ScratchImage Create(ReadOnlySpan<byte> pixels, int width, int height)
|
|
{
|
|
var scratchResult = ScratchImage.FromRGBA(pixels, width, height, out var creationInfo);
|
|
return scratchResult.ThrowIfError(creationInfo);
|
|
}
|
|
|
|
if (image.DangerousTryGetSinglePixelMemory(out var pixelMemory))
|
|
{
|
|
var byteSpan = MemoryMarshal.AsBytes(pixelMemory.Span);
|
|
if (byteSpan.Length < requiredLength)
|
|
{
|
|
throw new InvalidOperationException($"Image buffer shorter than expected ({byteSpan.Length} < {requiredLength}).");
|
|
}
|
|
|
|
return Create(byteSpan.Slice(0, requiredLength), width, height);
|
|
}
|
|
|
|
var rented = ArrayPool<byte>.Shared.Rent(requiredLength);
|
|
try
|
|
{
|
|
var rentedSpan = rented.AsSpan(0, requiredLength);
|
|
image.CopyPixelDataTo(rentedSpan);
|
|
return Create(rentedSpan, width, height);
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.Shared.Return(rented);
|
|
}
|
|
}
|
|
|
|
private bool TryConvertForSave(
|
|
ScratchImage source,
|
|
DXGIFormat sourceFormat,
|
|
out ScratchImage result,
|
|
bool attemptPenumbraFallback)
|
|
{
|
|
var isCompressed = sourceFormat.IsCompressed();
|
|
var targetFormat = isCompressed ? sourceFormat : DXGIFormat.B8G8R8A8UNorm;
|
|
_logger.LogDebug(
|
|
"Downscale convert target {TargetFormat} (source {SourceFormat}, compressed {IsCompressed}, penumbraFallback {PenumbraFallback})",
|
|
targetFormat,
|
|
sourceFormat,
|
|
isCompressed,
|
|
attemptPenumbraFallback);
|
|
try
|
|
{
|
|
result = source.Convert(targetFormat);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var compressedFallback = attemptPenumbraFallback
|
|
? " Attempting Penumbra re-encode."
|
|
: " Skipping downscale.";
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Failed to convert downscaled texture to {Format}.{Fallback}",
|
|
targetFormat,
|
|
isCompressed ? compressedFallback : " Falling back to B8G8R8A8.");
|
|
if (isCompressed)
|
|
{
|
|
result = default!;
|
|
return false;
|
|
}
|
|
|
|
result = source.Convert(DXGIFormat.B8G8R8A8UNorm);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private bool TryResolveCompressionTarget(TexHeaderInfo? headerInfo, DXGIFormat sourceFormat, out TextureCompressionTarget target)
|
|
{
|
|
if (headerInfo is { } info && TryGetCompressionTarget(info.Format, out target))
|
|
{
|
|
return _textureCompressionService.IsTargetSelectable(target);
|
|
}
|
|
|
|
if (sourceFormat.IsCompressed() && BlockCompressedFormatMap.TryGetValue((int)sourceFormat, out target))
|
|
{
|
|
return _textureCompressionService.IsTargetSelectable(target);
|
|
}
|
|
|
|
target = default;
|
|
return false;
|
|
}
|
|
|
|
private async Task<bool> TryReencodeWithPenumbraAsync(
|
|
string hash,
|
|
string sourcePath,
|
|
string destination,
|
|
ScratchImage resizedScratch,
|
|
TextureCompressionTarget target)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogDebug("Downscale Penumbra re-encode target {Target} for {Hash}.", target, hash);
|
|
using var uncompressed = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
|
|
TexFileHelper.Save(destination, uncompressed);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to save uncompressed downscaled texture for {Hash}. Skipping downscale.", hash);
|
|
TryDelete(destination);
|
|
return false;
|
|
}
|
|
|
|
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
var request = new TextureCompressionRequest(destination, Array.Empty<string>(), target);
|
|
await _textureCompressionService
|
|
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to re-encode downscaled texture {Hash} to {Target}. Skipping downscale.", hash, target);
|
|
TryDelete(destination);
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_compressionSemaphore.Release();
|
|
}
|
|
|
|
RegisterDownscaledTexture(hash, sourcePath, destination);
|
|
_logger.LogDebug("Downscaled texture {Hash} -> {Path} (re-encoded via Penumbra).", hash, destination);
|
|
return true;
|
|
}
|
|
|
|
private async Task TryAutoCompressAsync(string hash, string texturePath, TextureMapKind mapKind, TexHeaderInfo? headerInfo)
|
|
{
|
|
var performanceConfig = _playerPerformanceConfigService.Current;
|
|
if (!performanceConfig.EnableUncompressedTextureCompression)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(texturePath) || !File.Exists(texturePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var info = headerInfo ?? (TryReadTexHeader(texturePath, out var header) ? header : (TexHeaderInfo?)null);
|
|
if (!info.HasValue)
|
|
{
|
|
_logger.LogTrace("Skipping auto-compress for texture {Hash}; unable to read header.", hash);
|
|
return;
|
|
}
|
|
|
|
if (IsBlockCompressedFormat(info.Value.Format))
|
|
{
|
|
_logger.LogTrace("Skipping auto-compress for texture {Hash}; already block-compressed.", hash);
|
|
return;
|
|
}
|
|
|
|
var suggestion = TextureMetadataHelper.GetSuggestedTarget(info.Value.Format.ToString(), mapKind, texturePath);
|
|
if (suggestion is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var target = _textureCompressionService.NormalizeTarget(suggestion.Value.Target);
|
|
if (!_textureCompressionService.IsTargetSelectable(target))
|
|
{
|
|
_logger.LogTrace("Skipping auto-compress for texture {Hash}; target {Target} not supported.", hash, target);
|
|
return;
|
|
}
|
|
|
|
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
var includeMipMaps = !performanceConfig.SkipUncompressedTextureCompressionMipMaps;
|
|
var request = new TextureCompressionRequest(texturePath, Array.Empty<string>(), target);
|
|
await _textureCompressionService
|
|
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false, includeMipMaps: includeMipMaps)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Auto-compress failed for texture {Hash} ({Path})", hash, texturePath);
|
|
}
|
|
finally
|
|
{
|
|
_compressionSemaphore.Release();
|
|
}
|
|
}
|
|
|
|
private static bool IsIndexMap(TextureMapKind kind)
|
|
=> kind is TextureMapKind.Mask
|
|
or TextureMapKind.Index;
|
|
|
|
private Task<bool> TryDropTopMipAsync(
|
|
string hash,
|
|
string sourcePath,
|
|
string destination,
|
|
int targetMaxDimension,
|
|
bool onlyDownscaleUncompressed,
|
|
TexHeaderInfo? headerInfo = null)
|
|
{
|
|
TexHeaderInfo? header = headerInfo;
|
|
int dropCount = -1;
|
|
int originalWidth = 0;
|
|
int originalHeight = 0;
|
|
int originalMipLevels = 0;
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(sourcePath))
|
|
{
|
|
_logger.LogWarning("Cannot trim mip levels for texture {Hash}; source path missing: {Path}", hash, sourcePath);
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
if (header is null && TryReadTexHeader(sourcePath, out var discoveredHeader))
|
|
{
|
|
header = discoveredHeader;
|
|
}
|
|
|
|
if (header is TexHeaderInfo info)
|
|
{
|
|
if (onlyDownscaleUncompressed && IsBlockCompressedFormat(info.Format))
|
|
{
|
|
_logger.LogTrace("Skipping mip trim for texture {Hash}; block compressed format {Format}.", hash, info.Format);
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
if (info.MipLevels <= 1)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
var headerDepth = info.Depth == 0 ? 1 : info.Depth;
|
|
if (!ShouldTrimDimensions(info.Width, info.Height, headerDepth, targetMaxDimension))
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
}
|
|
|
|
using var original = TexFileHelper.Load(sourcePath);
|
|
var meta = original.Meta;
|
|
originalWidth = meta.Width;
|
|
originalHeight = meta.Height;
|
|
originalMipLevels = meta.MipLevels;
|
|
if (meta.MipLevels <= 1)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
if (!ShouldTrim(meta, targetMaxDimension))
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
var targetSize = CalculateTargetSize(meta.Width, meta.Height, targetMaxDimension);
|
|
dropCount = CalculateDropCount(meta, targetSize.width, targetSize.height);
|
|
if (dropCount <= 0)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
using var trimmed = TrimMipChain(original, dropCount);
|
|
TexFileHelper.Save(destination, trimmed);
|
|
RegisterDownscaledTexture(hash, sourcePath, destination);
|
|
_logger.LogDebug("Trimmed {DropCount} top mip level(s) for texture {Hash} -> {Path}", dropCount, hash, destination);
|
|
return Task.FromResult(true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Failed to trim mips for texture {Hash} from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, HeaderFormat={HeaderFormat}, OriginalSize={OriginalWidth}x{OriginalHeight}, OriginalMipLevels={OriginalMipLevels}, DropAttempt={DropCount}",
|
|
hash,
|
|
sourcePath,
|
|
destination,
|
|
targetMaxDimension,
|
|
onlyDownscaleUncompressed,
|
|
header?.Format,
|
|
originalWidth,
|
|
originalHeight,
|
|
originalMipLevels,
|
|
dropCount);
|
|
TryDelete(destination);
|
|
return Task.FromResult(false);
|
|
}
|
|
}
|
|
|
|
private static int CalculateDropCount(in TexMeta meta, int targetWidth, int targetHeight)
|
|
{
|
|
var drop = 0;
|
|
var width = meta.Width;
|
|
var height = meta.Height;
|
|
|
|
while ((width > targetWidth || height > targetHeight) && drop + 1 < meta.MipLevels)
|
|
{
|
|
drop++;
|
|
width = ReduceDimension(width);
|
|
height = ReduceDimension(height);
|
|
}
|
|
|
|
return drop;
|
|
}
|
|
|
|
private static ScratchImage TrimMipChain(ScratchImage source, int dropCount)
|
|
{
|
|
var meta = source.Meta;
|
|
var newMeta = meta;
|
|
newMeta.MipLevels = meta.MipLevels - dropCount;
|
|
newMeta.Width = ReduceDimension(meta.Width, dropCount);
|
|
newMeta.Height = ReduceDimension(meta.Height, dropCount);
|
|
if (meta.Dimension == TexDimension.Tex3D)
|
|
{
|
|
newMeta.Depth = ReduceDimension(meta.Depth, dropCount);
|
|
}
|
|
|
|
var result = ScratchImage.Initialize(newMeta);
|
|
CopyMipChainData(source, result, dropCount, meta);
|
|
return result;
|
|
}
|
|
|
|
private static unsafe void CopyMipChainData(ScratchImage source, ScratchImage destination, int dropCount, in TexMeta sourceMeta)
|
|
{
|
|
var destinationMeta = destination.Meta;
|
|
var arraySize = Math.Max(1, sourceMeta.ArraySize);
|
|
var isCube = sourceMeta.IsCubeMap;
|
|
var isVolume = sourceMeta.Dimension == TexDimension.Tex3D;
|
|
|
|
for (var item = 0; item < arraySize; item++)
|
|
{
|
|
for (var mip = 0; mip < destinationMeta.MipLevels; mip++)
|
|
{
|
|
var sourceMip = mip + dropCount;
|
|
var sliceCount = GetSliceCount(sourceMeta, sourceMip, isCube, isVolume);
|
|
|
|
for (var slice = 0; slice < sliceCount; slice++)
|
|
{
|
|
var srcImage = source.GetImage(sourceMip, item, slice);
|
|
var dstImage = destination.GetImage(mip, item, slice);
|
|
CopyImage(srcImage, dstImage);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static int GetSliceCount(in TexMeta meta, int mip, bool isCube, bool isVolume)
|
|
{
|
|
if (isCube)
|
|
{
|
|
return 6;
|
|
}
|
|
|
|
if (isVolume)
|
|
{
|
|
return Math.Max(1, meta.Depth >> mip);
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
private static unsafe void CopyImage(in OtterImage source, in OtterImage destination)
|
|
{
|
|
var srcPtr = (byte*)source.Pixels;
|
|
var dstPtr = (byte*)destination.Pixels;
|
|
var bytesToCopy = Math.Min(source.SlicePitch, destination.SlicePitch);
|
|
Buffer.MemoryCopy(srcPtr, dstPtr, destination.SlicePitch, bytesToCopy);
|
|
}
|
|
|
|
private static int ReduceDimension(int value, int iterations)
|
|
{
|
|
var result = value;
|
|
for (var i = 0; i < iterations; i++)
|
|
{
|
|
result = ReduceDimension(result);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static int ReduceDimension(int value)
|
|
=> value <= 1 ? 1 : Math.Max(1, value / 2);
|
|
|
|
private static bool ShouldTrim(in TexMeta meta, int targetMaxDimension)
|
|
{
|
|
var depth = meta.Dimension == TexDimension.Tex3D ? Math.Max(1, meta.Depth) : 1;
|
|
return ShouldTrimDimensions(meta.Width, meta.Height, depth, targetMaxDimension);
|
|
}
|
|
|
|
private static bool ShouldTrimDimensions(int width, int height, int depth, int targetMaxDimension)
|
|
{
|
|
if (width <= targetMaxDimension && height <= targetMaxDimension && depth <= targetMaxDimension)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private int ResolveTargetMaxDimension()
|
|
{
|
|
var configured = _playerPerformanceConfigService.Current.TextureDownscaleMaxDimension;
|
|
if (configured <= 0)
|
|
{
|
|
return DefaultTargetMaxDimension;
|
|
}
|
|
|
|
return Math.Clamp(configured, BlockMultiple, MaxSupportedTargetDimension);
|
|
}
|
|
|
|
private readonly struct TexHeaderInfo
|
|
{
|
|
public TexHeaderInfo(ushort width, ushort height, ushort depth, ushort mipLevels, TexFile.TextureFormat format)
|
|
{
|
|
Width = width;
|
|
Height = height;
|
|
Depth = depth;
|
|
MipLevels = mipLevels;
|
|
Format = format;
|
|
}
|
|
|
|
public ushort Width { get; }
|
|
public ushort Height { get; }
|
|
public ushort Depth { get; }
|
|
public ushort MipLevels { get; }
|
|
public TexFile.TextureFormat Format { get; }
|
|
}
|
|
|
|
private static bool TryReadTexHeader(string path, out TexHeaderInfo header)
|
|
{
|
|
header = default;
|
|
|
|
try
|
|
{
|
|
Span<byte> buffer = stackalloc byte[16];
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
var read = stream.Read(buffer);
|
|
if (read < buffer.Length)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var formatValue = BinaryPrimitives.ReadInt32LittleEndian(buffer[4..8]);
|
|
var format = (TexFile.TextureFormat)formatValue;
|
|
var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]);
|
|
var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]);
|
|
var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]);
|
|
var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]);
|
|
header = new TexHeaderInfo(width, height, depth, mipLevels, format);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool IsBlockCompressedFormat(TexFile.TextureFormat format)
|
|
=> TryGetCompressionTarget(format, out _);
|
|
|
|
private static bool TryGetCompressionTarget(TexFile.TextureFormat format, out TextureCompressionTarget target)
|
|
{
|
|
if (BlockCompressedFormatMap.TryGetValue(unchecked((int)format), out var mapped))
|
|
{
|
|
target = mapped;
|
|
return true;
|
|
}
|
|
|
|
target = default;
|
|
return false;
|
|
}
|
|
|
|
private void RegisterDownscaledTexture(string hash, string sourcePath, string destination)
|
|
{
|
|
_downscaledPaths[hash] = destination;
|
|
_logger.LogDebug("Downscaled texture {Hash} -> {Path}", hash, destination);
|
|
|
|
var performanceConfig = _playerPerformanceConfigService.Current;
|
|
if (performanceConfig.KeepOriginalTextureFiles)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!TryReplaceCacheEntryWithDownscaled(hash, sourcePath, destination))
|
|
{
|
|
return;
|
|
}
|
|
|
|
TryDelete(sourcePath);
|
|
}
|
|
|
|
private bool TryReplaceCacheEntryWithDownscaled(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 texture {Hash} to downscaled path {Path}", hash, destination);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogTrace(ex, "Failed to replace cache entry for texture {Hash}", hash);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private string? GetExistingDownscaledPath(string hash)
|
|
{
|
|
var candidate = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
|
|
return File.Exists(candidate) ? candidate : null;
|
|
}
|
|
|
|
private string GetDownscaledDirectory()
|
|
{
|
|
var directory = Path.Combine(_configService.Current.CacheFolder, "downscaled");
|
|
if (!Directory.Exists(directory))
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogTrace(ex, "Failed to create downscaled 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
|
|
}
|
|
}
|
|
|
|
}
|