Files
LightlessClient/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs
2025-12-16 06:31:29 +09:00

715 lines
25 KiB
C#

using System.Collections.Concurrent;
using System.Buffers;
using System.Buffers.Binary;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using OtterTex;
using OtterImage = OtterTex.Image;
using LightlessSync.LightlessConfiguration;
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 ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
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)
{
_logger = logger;
_configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager;
}
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
if (_activeJobs.ContainsKey(hash)) return;
_activeJobs[hash] = Task.Run(async () =>
{
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, CancellationToken.None);
}
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 (_activeJobs.TryGetValue(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;
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
if (File.Exists(destination))
{
RegisterDownscaledTexture(hash, sourcePath, destination);
return;
}
var indexTexture = IsIndexMap(mapKind);
isIndexTexture = indexTexture;
if (!indexTexture)
{
if (performanceConfig.EnableNonIndexTextureMipTrim
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).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);
return;
}
if (!performanceConfig.EnableIndexTextureDownscale)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
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);
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);
return;
}
using var sourceScratch = TexFileHelper.Load(sourcePath);
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);
return;
}
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination);
}
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();
_activeJobs.TryRemove(hash, out _);
}
}
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 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);
}
_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
}
}
}