2.0.0 (#92)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
2.0.0 Changes: - Reworked shell finder UI with compact or list view with profile tags showing with the listing, allowing moderators to broadcast the syncshell as well to have it be used more. - Reworked user list in syncshell admin screen to have filter visible and moved away from table to its own thing, allowing to copy uid/note/alias when clicking on the name. - Reworked download bars and download box to make it look more modern, removed the jitter around, so it shouldn't vibrate around much. - Chat has been added to the top menu, working in Zone or in Syncshells to be used there. - Paired system has been revamped to make pausing and unpausing faster, and loading people should be faster as well. - Moved to the internal object table to have faster load times for users; people should load in faster - Compactor is running on a multi-threaded level instead of single-threaded; this should increase the speed of compacting files - Nameplate Service has been reworked so it wouldn't use the nameplate handler anymore. - Files can be resized when downloading to reduce load on users if they aren't compressed. (can be toggled to resize all). - Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many syncshells in your list. - Lightfinder plates have been moved away from using Nameplates, but will use an overlay. - Main UI has been changed a bit with a gradient, and on hover will glow up now. - Reworked Profile UI for Syncshell and Users to be more user-facing with more customizable items. - Reworked Settings UI to look more modern. - Performance should be better due to new systems that would dispose of the collections and better caching of items. Co-authored-by: defnotken <itsdefnotken@gmail.com> Co-authored-by: azyges <aaaaaa@aaa.aaa> Co-authored-by: choco <choco@patat.nl> Co-authored-by: cake <admin@cakeandbanana.nl> Co-authored-by: Minmoose <KennethBohr@outlook.com> Reviewed-on: #92
This commit was merged in pull request #92.
This commit is contained in:
@@ -0,0 +1,714 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user