From c752476c412bd945ee4501ccc2bf5ccf19a5c610 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 20 Oct 2026 22:46:58 +0200 Subject: [PATCH 1/2] Fix bounded texture cooking and preserve texture caches --- .../Packaging/PackagedFiles.cs | 3 +- .../Packaging/RuntimePackageReader.cs | 9 +- .../Packaging/VpeBc7GpuEncoder.cs | 192 ++++++++++++------ .../Packaging/VpeTextureCache.cs | 64 +++++- .../Packaging/VpeTextureCook.cs | 131 ++++++------ .../Rendering/VpeGraphics.cs | 2 + .../Resources/VpeBc7Encode.compute | 39 ++-- 7 files changed, 271 insertions(+), 169 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs index 246f192c01..022fef43da 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs @@ -166,7 +166,8 @@ public async Task UnpackMeshesRuntime(CancellationToken cancellationToken = defa var importRoot = new GameObject("__vpe_runtime_colliders"); importRoot.hideFlags = HideFlags.HideAndDontSave; try { - var gltf = new GltfImport(logger: new ConsoleLogger()); + // Colliders need placeholder materials too; use the pipeline generator included in players. + var gltf = new GltfImport(materialGenerator: RuntimeGltfMaterialGenerator.Create(), logger: new ConsoleLogger()); var loaded = await gltf.Load(colliderMeshData, cancellationToken: cancellationToken); cancellationToken.ThrowIfCancellationRequested(); if (!loaded) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs index 0fc8e391e6..d3e4b2eb15 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs @@ -858,7 +858,8 @@ private async Task RestoreMaterialProfilesAsync( var settingsHash = _cookSettingsHash; var manifest = cookResult.Manifest; var cookedData = cookResult.CookedData; - _ = Task.Run(() => VpeTextureCache.Write(cacheRoot, vpePath, settingsHash, manifest, cookedData)) + var sourceIdentity = prefetch?.SourceIdentity; + if (sourceIdentity != null) _ = Task.Run(() => VpeTextureCache.Write(cacheRoot, vpePath, settingsHash, manifest, cookedData, sourceIdentity)) .ContinueWith(t => Logger.Warn(t.Exception?.GetBaseException(), "Cannot write the texture cache for {File}; textures are cooked again next time.", Path.GetFileName(vpePath)), TaskContinuationOptions.OnlyOnFaulted); } @@ -876,6 +877,7 @@ await VpeMaterialReader.TryApplyAsync( private static MaterialPrefetchResult PrefetchMaterialData(string vpePath, string cacheRoot, long cookSettingsHash) { try { + var sourceIdentity = VpeTextureCache.ComputeTextureIdentity(vpePath); using var storage = PackageApi.StorageManager.OpenStorage(vpePath); var tableFolder = storage.GetFolder(PackageApi.TableFolder); @@ -884,8 +886,9 @@ private static MaterialPrefetchResult PrefetchMaterialData(string vpePath, strin return null; } - var result = new MaterialPrefetchResult { Payload = payload, Sources = entriesOnly }; + var result = new MaterialPrefetchResult { Payload = payload, Sources = entriesOnly, SourceIdentity = sourceIdentity }; if (VpeTextureCache.TryLoad(cacheRoot, vpePath, cookSettingsHash, out var cached)) { + if (sourceIdentity != VpeTextureCache.ComputeTextureIdentity(vpePath)) throw new IOException("Texture inputs changed while reading cache."); result.CachedTextures = cached; return result; } @@ -897,6 +900,7 @@ private static MaterialPrefetchResult PrefetchMaterialData(string vpePath, strin result.Payload = payload; result.Sources = sources; } + if (sourceIdentity != VpeTextureCache.ComputeTextureIdentity(vpePath)) throw new IOException("Texture inputs changed while reading sources."); return result; } catch (Exception ex) { @@ -907,6 +911,7 @@ private static MaterialPrefetchResult PrefetchMaterialData(string vpePath, strin private sealed class MaterialPrefetchResult { + public string SourceIdentity; public VpeMaterialsPayload Payload; public VpeTextureSources.Result Sources; public VpeTextureCache.CacheData CachedTextures; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeBc7GpuEncoder.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeBc7GpuEncoder.cs index 25697a40a0..9449adbf3c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeBc7GpuEncoder.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeBc7GpuEncoder.cs @@ -15,6 +15,9 @@ // along with this program. If not, see . using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using NLog; using Unity.Collections; using UnityEngine; @@ -35,10 +38,6 @@ public sealed class VpeBc7GpuEncoder : IDisposable private const string ShaderResourcePath = "VpeBc7Encode"; - // The mode-137 kernels run one block per thread group, and D3D caps dispatches at 65535 - // groups per dimension. The g_start_block_id constant exists exactly for this batching. - private const int MaxBlocksPerDispatch = 32768; - private static readonly int TexWidthId = Shader.PropertyToID("g_tex_width"); private static readonly int TexHeightId = Shader.PropertyToID("g_tex_height"); private static readonly int NumBlockXId = Shader.PropertyToID("g_num_block_x"); @@ -63,6 +62,15 @@ public sealed class VpeBc7GpuEncoder : IDisposable private ComputeBuffer _output; private int _capacityBlocks; private bool _initialized; + // GPU submission size and UI frame time are separate budgets. Waiting a whole + // rendered frame after every small batch needlessly throttles cooking to FPS. + private sealed class PendingReadback + { + public AsyncGPUReadbackRequest Request; + public Action Complete; + } + private bool _hasYielded; + private readonly System.Diagnostics.Stopwatch _yieldClock = System.Diagnostics.Stopwatch.StartNew(); public static bool IsSupported => SystemInfo.supportsComputeShaders && SystemInfo.SupportsTextureFormat(TextureFormat.BC7); @@ -96,74 +104,126 @@ public bool Initialize() } /// - /// Encodes one mip level of (an RGBA32 texture with the mip data - /// already uploaded) to BC7 and asynchronously copies the blocks into - /// at . The texture must - /// stay alive until the readback completes; track that via the returned request or - /// (invoked on the main thread with success=false on GPU - /// readback errors). + /// Encodes with bounded GPU submissions. A player-loop yield alone does not drain + /// the GPU queue: flush a bounded window of work, grouping small mips together. + /// The source and buffers stay alive until the last submitted request completes, + /// including when cancellation arrives during a readback. One encoder must not + /// run concurrent encodes. /// - public AsyncGPUReadbackRequest EncodeMip( - Texture source, - int sourceMip, - int mipWidth, - int mipHeight, - byte[] destination, - int destinationOffset, - Action onCompleted) + public Task EncodeMipAsync(Texture source, int sourceMip, int mipWidth, int mipHeight, + byte[] destination, int destinationOffset, CancellationToken cancellationToken) + => EncodeMipsAsync(source, sourceMip, new[] { mipWidth }, new[] { mipHeight }, + new[] { (long)destinationOffset }, destination, cancellationToken); + + public Task EncodeTextureAsync(Texture source, int[] widths, int[] heights, long[] offsets, + byte[] destination, CancellationToken cancellationToken) + => EncodeMipsAsync(source, 0, widths, heights, offsets, destination, cancellationToken); + + private async Task EncodeMipsAsync(Texture source, int firstMip, int[] widths, int[] heights, + long[] offsets, byte[] destination, CancellationToken cancellationToken) { - if (!_initialized) { - throw new InvalidOperationException("VpeBc7GpuEncoder is not initialized."); + if (!_initialized) throw new InvalidOperationException("VpeBc7GpuEncoder is not initialized."); + if (source == null || destination == null) throw new ArgumentNullException(); + if (widths.Length != heights.Length || widths.Length != offsets.Length) throw new ArgumentException("Mip arrays differ in length."); + var maximumBlocks = 0; + for (var mip = 0; mip < widths.Length; mip++) { + if (widths[mip] <= 0 || heights[mip] <= 0) throw new ArgumentOutOfRangeException(nameof(widths)); + var blocks = checked(((widths[mip] + 3) / 4) * ((heights[mip] + 3) / 4)); + var bytes = checked(blocks * 16); + if (offsets[mip] < 0 || offsets[mip] > destination.Length - bytes) throw new ArgumentOutOfRangeException(nameof(offsets)); + maximumBlocks = Mathf.Max(maximumBlocks, blocks); } - - var blocksX = Mathf.Max(1, (mipWidth + 3) / 4); - var blocksY = Mathf.Max(1, (mipHeight + 3) / 4); - var numBlocks = blocksX * blocksY; - EnsureCapacity(numBlocks); - - _shader.SetInt(TexWidthId, mipWidth); - _shader.SetInt(TexHeightId, mipHeight); - _shader.SetInt(NumBlockXId, blocksX); - _shader.SetInt(FormatId, Bc7FormatId); - _shader.SetInt(NumTotalBlocksId, numBlocks); - _shader.SetFloat(AlphaWeightId, 1f); - _shader.SetInt(SrcMipId, sourceMip); - - for (var startBlock = 0; startBlock < numBlocks; startBlock += MaxBlocksPerDispatch) { - var batch = Mathf.Min(MaxBlocksPerDispatch, numBlocks - startBlock); - _shader.SetInt(StartBlockId, startBlock); - - // Modes 4/5/6 (single subset): 4 blocks per 64-thread group, results into err1. - _shader.SetInt(ModeIdId, 0); - BindKernel(_kernelTryMode456, source, _err2, _err1); - _shader.Dispatch(_kernelTryMode456, Mathf.Max(1, (batch + 3) / 4), 1, 1); - - // Modes 1/3/7 (two subsets): one block per group, ping-ponging the error buffers - // the same way DirectXTex does (err1 -> err2 -> err1 -> err2). - for (var i = 0; i < 3; i++) { - _shader.SetInt(ModeIdId, Bc7TwoSubsetModes[i]); - var input = (i & 1) == 1 ? _err2 : _err1; - var output = (i & 1) == 1 ? _err1 : _err2; - BindKernel(_kernelTryMode137, source, input, output); - _shader.Dispatch(_kernelTryMode137, batch, 1, 1); + // Never reallocate buffers with readbacks in flight. GPU copies are queued + // before subsequent dispatches reuse these buffers for another mip. + EnsureCapacity(maximumBlocks); + const int batchBlocks = 16384; + var pending = 0; + var requests = new List(); + var queuedBlocks = 0; + var success = true; + async Task Drain() + { + // Do not free GPU resources on cancellation/timeout while a job owns them. + // The independent player watchdog diagnoses a genuinely hung device. + if (pending == 0) { queuedBlocks = 0; return; } + // Flush this bounded submission explicitly. A menu with no scene cameras + // may not otherwise submit compute/readback commands while awaiting them. + // Unlike the old end-of-cook wait, at most 16384 blocks are queued here. + AsyncGPUReadback.WaitAllRequests(); + // Some players defer callbacks until the next rendered frame even though + // the request is complete. Consume completed data now, exactly once, so + // readback callback scheduling does not throttle every batch to frame rate. + foreach (var work in requests) if (work.Request.done) work.Complete(work.Request); + while (pending > 0) await Task.Yield(); + requests.Clear(); + queuedBlocks = 0; + if (!_hasYielded || _yieldClock.ElapsedMilliseconds >= 8) { + _hasYielded = true; + await Task.Yield(); + _yieldClock.Restart(); } - - // Final packing of the best candidate per block; err2 holds the last results. - BindKernel(_kernelEncodeBlock, source, _err2, _output); - _shader.Dispatch(_kernelEncodeBlock, Mathf.Max(1, (batch + 3) / 4), 1, 1); } - - var byteCount = numBlocks * 16; - return AsyncGPUReadback.Request(_output, byteCount, 0, request => { - if (request.hasError) { - Logger.Warn("VpeBc7GpuEncoder: GPU readback failed."); - onCompleted?.Invoke(false); - return; + try { + for (var mip = 0; mip < widths.Length; mip++) { + var blocksX = (widths[mip] + 3) / 4; + var numBlocks = blocksX * ((heights[mip] + 3) / 4); + for (var start = 0; start < numBlocks; start += batchBlocks) { + if (cancellationToken.IsCancellationRequested) { + await Drain(); cancellationToken.ThrowIfCancellationRequested(); + } + var count = Mathf.Min(batchBlocks, numBlocks - start); + if (queuedBlocks + count > batchBlocks) await Drain(); + if (!success) return false; + if (start % (batchBlocks * 64) == 0) + Logger.Debug("BC7: {Texture} mip {Mip}, blocks {Start}/{Total}", source.name, firstMip + mip, start, numBlocks); + _shader.SetInt(TexWidthId, widths[mip]); + _shader.SetInt(TexHeightId, heights[mip]); + _shader.SetInt(NumBlockXId, blocksX); + _shader.SetInt(FormatId, Bc7FormatId); + _shader.SetInt(NumTotalBlocksId, numBlocks); + _shader.SetFloat(AlphaWeightId, 1f); + _shader.SetInt(SrcMipId, firstMip + mip); + DispatchBatch(source, start, count); + var byteOffset = start * 16; + var destinationOffset = (int)offsets[mip] + byteOffset; + pending++; + var work = new PendingReadback(); + var completed = false; + work.Complete = request => { + if (completed) return; + completed = true; + try { + if (request.hasError) success = false; + else NativeArray.Copy(request.GetData(), 0, destination, destinationOffset, count * 16); + } catch (Exception ex) { + success = false; Logger.Warn(ex, "VpeBc7GpuEncoder: batch readback failed."); + } finally { pending--; } + }; + work.Request = AsyncGPUReadback.Request(_output, count * 16, byteOffset, work.Complete); + work.Request.forcePlayerLoopUpdate = true; + requests.Add(work); + queuedBlocks += count; + if (queuedBlocks >= batchBlocks) await Drain(); + } } - var data = request.GetData(); - NativeArray.Copy(data, 0, destination, destinationOffset, byteCount); - onCompleted?.Invoke(true); - }); + } finally { await Drain(); } + cancellationToken.ThrowIfCancellationRequested(); + return success; + } + + private void DispatchBatch(Texture source, int startBlock, int batch) + { + _shader.SetInt(StartBlockId, startBlock); + _shader.SetInt(ModeIdId, 0); + BindKernel(_kernelTryMode456, source, _err2, _err1); + _shader.Dispatch(_kernelTryMode456, Mathf.Max(1, (batch + 3) / 4), 1, 1); + for (var i = 0; i < 3; i++) { + _shader.SetInt(ModeIdId, Bc7TwoSubsetModes[i]); + BindKernel(_kernelTryMode137, source, (i & 1) == 1 ? _err2 : _err1, (i & 1) == 1 ? _err1 : _err2); + _shader.Dispatch(_kernelTryMode137, batch, 1, 1); + } + BindKernel(_kernelEncodeBlock, source, _err2, _output); + _shader.Dispatch(_kernelEncodeBlock, Mathf.Max(1, (batch + 3) / 4), 1, 1); } private static readonly int[] Bc7TwoSubsetModes = { 1, 3, 7 }; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCache.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCache.cs index 9ba457a6fb..662c7f70c9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCache.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCache.cs @@ -16,6 +16,8 @@ using System; using System.IO; +using System.Collections.Generic; +using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; @@ -55,7 +57,7 @@ public static long ComputeHash() /// /// Per-table cache of GPU-cooked textures (see VpeTextureCook). One file per .vpe package, - /// validated against the package's size and write time plus the cook settings. The payload is + /// validated against texture/material content plus explicit cook settings. The payload is /// the exact blob the material reader uploads from, so a cache hit costs one sequential read. /// public static class VpeTextureCache @@ -63,7 +65,7 @@ public static class VpeTextureCache private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const uint Magic = 0x43545056; // "VPTC" - private const int HeaderVersion = 3; // v2 added the compression byte; v3 made zstd payloads chunked + private const int HeaderVersion = 4; // v4 uses texture content identity; v3 used package size/mtime private const string CacheFolderName = "TextureCache"; private const string CacheExtension = ".vptc"; @@ -103,6 +105,29 @@ public sealed class CacheData public byte[] CookedData; } + /// + /// Fingerprint only texture-cook inputs, from the ZIP directory (no image decode or + /// large blob read). Screenshot/metadata-only edits must not discard cooked textures. + /// Render presets, effects and upscalers are deliberately absent from this identity. + /// + public static string ComputeTextureIdentity(string vpePath) + { + var entries = new List(); + using var input = File.OpenRead(vpePath); + using (var zip = new ZipArchive(input, ZipArchiveMode.Read)) { + foreach (var entry in zip.Entries) { + if (entry.Name.Length == 0) continue; + if (entry.FullName.StartsWith("table/textures/", StringComparison.Ordinal) + || entry.FullName == "table/meta/materials.json") + entries.Add(entry.FullName + "\0" + entry.Length + "\0" + entry.Crc32); + } + } + if (entries.Count == 0) throw new InvalidDataException("Package has no texture-cook inputs."); + entries.Sort(StringComparer.Ordinal); + using var hash = SHA256.Create(); + return BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(string.Join("\n", entries)))); + } + public static string GetCachePath(string cacheRoot, string vpePath) { using var sha1 = SHA1.Create(); @@ -111,28 +136,41 @@ public static string GetCachePath(string cacheRoot, string vpePath) return Path.Combine(cacheRoot, CacheFolderName, name + CacheExtension); } + public static string GetCachePath(string cacheRoot, string vpePath, long settingsHash) + { + var legacy = GetCachePath(cacheRoot, vpePath); + return Path.Combine(Path.GetDirectoryName(legacy)!, Path.GetFileNameWithoutExtension(legacy) + + "-" + settingsHash.ToString("x", System.Globalization.CultureInfo.InvariantCulture) + CacheExtension); + } + public static bool TryLoad(string cacheRoot, string vpePath, long settingsHash, out CacheData data) { data = null; try { - var cachePath = GetCachePath(cacheRoot, vpePath); + var cachePath = GetCachePath(cacheRoot, vpePath, settingsHash); if (!File.Exists(cachePath)) { - return false; + cachePath = GetCachePath(cacheRoot, vpePath); // legacy single-variant cache + if (!File.Exists(cachePath)) return false; } var vpeInfo = new FileInfo(vpePath); using var stream = new FileStream(cachePath, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16); using var reader = new BinaryReader(stream); - if (reader.ReadUInt32() != Magic || reader.ReadInt32() != HeaderVersion) { + if (reader.ReadUInt32() != Magic) return false; + var headerVersion = reader.ReadInt32(); + if (headerVersion != 3 && headerVersion != HeaderVersion) { return false; } if (reader.ReadInt64() != settingsHash) { return false; } - if (reader.ReadInt64() != vpeInfo.Length || reader.ReadInt64() != vpeInfo.LastWriteTimeUtc.Ticks) { - return false; - } + var sourceLength = reader.ReadInt64(); + var sourceTime = reader.ReadInt64(); + if (headerVersion == 3) { + // Legacy files have no content identity; retain their strict validation. + if (sourceLength != vpeInfo.Length || sourceTime != vpeInfo.LastWriteTimeUtc.Ticks) return false; + } else if (reader.ReadString() != ComputeTextureIdentity(vpePath)) return false; var manifestLength = reader.ReadInt32(); if (manifestLength <= 0 || manifestLength > 256 * 1024 * 1024) { @@ -218,14 +256,19 @@ public static bool TryLoad(string cacheRoot, string vpePath, long settingsHash, } } - public static void Write(string cacheRoot, string vpePath, long settingsHash, Manifest manifest, byte[] cookedData) + public static void Write(string cacheRoot, string vpePath, long settingsHash, Manifest manifest, byte[] cookedData, string expectedSourceIdentity = null) { try { - var cachePath = GetCachePath(cacheRoot, vpePath); + var cachePath = GetCachePath(cacheRoot, vpePath, settingsHash); Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); var vpeInfo = new FileInfo(vpePath); var manifestBytes = PackageApi.Packer.Pack(manifest); + var sourceIdentity = expectedSourceIdentity ?? ComputeTextureIdentity(vpePath); + if (expectedSourceIdentity != null && ComputeTextureIdentity(vpePath) != expectedSourceIdentity) { + Logger.Warn("Texture inputs changed during cooking; not caching stale output for {File}", vpePath); + return; + } var compress = CompressPayloadOnWrite; @@ -251,6 +294,7 @@ public static void Write(string cacheRoot, string vpePath, long settingsHash, Ma writer.Write(settingsHash); writer.Write(vpeInfo.Length); writer.Write(vpeInfo.LastWriteTimeUtc.Ticks); + writer.Write(sourceIdentity); writer.Write(manifestBytes.Length); writer.Write(manifestBytes); writer.Write(compress ? PayloadZstd : PayloadRaw); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCook.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCook.cs index e137021a29..b60f80eb5f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCook.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCook.cs @@ -42,9 +42,6 @@ public static class VpeTextureCook private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const string RawMimeType = "application/x-vpe-raw"; - // Pixels of BC7 encode work dispatched per frame. Keeps single frames well below the - // Windows GPU watchdog threshold (the same lesson as the upload budget in the reader). - private const long EncodePixelYieldBudget = 32L * 1024 * 1024; // Bounded hand-off between decode workers and the GPU stage; also bounds peak memory of // decoded-but-not-yet-encoded RGBA data. Generous on purpose: while the main thread is busy // decoding the big files, the workers must be able to keep going without backpressure. @@ -144,14 +141,16 @@ public static async Task CookAsync( // Decode stage: parallel workers stream BC7 work to the main thread; RGBA32 and // passthrough items are written straight into the blob from the worker. - var gpuQueue = new BlockingCollection(GpuQueueCapacity); + using var decodeCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var decodeToken = decodeCancellation.Token; + using var gpuQueue = new BlockingCollection(GpuQueueCapacity); var decodeTask = Task.Run(() => { try { var options = new ParallelOptions { // The cook runs behind a loading screen; decoding is the long pole, so use // nearly every core. MaxDegreeOfParallelism = Mathf.Clamp(Environment.ProcessorCount - 1, 2, 16), - CancellationToken = cancellationToken, + CancellationToken = decodeToken, }; Parallel.ForEach(workerItems, options, item => { try { @@ -170,7 +169,7 @@ public static async Task CookAsync( } if (item.UseBc7) { - gpuQueue.Add(work, cancellationToken); + gpuQueue.Add(work, decodeToken); } else { // Raw RGBA32: build the mip chain on the CPU (rare path, small // non-block-compressible textures) and write the final payload. @@ -188,19 +187,19 @@ public static async Task CookAsync( Interlocked.Exchange(ref decodeWallMs, cookStopwatch.ElapsedMilliseconds); gpuQueue.CompleteAdding(); } - }, cancellationToken); + }, decodeToken); // GPU stage (main thread): upload base levels, build mips on the GPU, dispatch BC7 - // encoding and async-read the blocks back into the blob. Yields by dispatched-pixel - // budget to keep frames sane. - var outstandingTextures = 0; - long pixelsSinceYield = 0; + // encoding and read the blocks back into the blob. Each bounded window completes + // before another is submitted; small mips share a window to avoid frame-rate throttling. - void SubmitToGpu(CookPlanItem item, Texture2D upload) + async Task SubmitToGpu(CookPlanItem item, Texture2D upload) { if (firstGpuItemMs == 0) { firstGpuItemMs = cookStopwatch.ElapsedMilliseconds; } + Logger.Debug("Cooking texture {Texture}: {Width}x{Height}, {Mips} mips ({Done}/{Total})", + item.Source.Id, item.Cooked.Width, item.Cooked.Height, item.Cooked.MipCount, Volatile.Read(ref finishedItems), totalItems); var repackThisOnGpu = item.IsNormal && repackOnGpu; RenderTexture mipped = null; try { @@ -236,27 +235,15 @@ void SubmitToGpu(CookPlanItem item, Texture2D upload) return; } - var baseTexture = upload; - var mipSource = mipped; - var remainingMips = item.Cooked.MipCount; - Interlocked.Increment(ref outstandingTextures); - for (var mip = 0; mip < item.Cooked.MipCount; mip++) { - encoder.EncodeMip(mipSource, mip, item.MipWidths[mip], item.MipHeights[mip], cookedData, (int)item.MipOffsets[mip], ok => { - if (!ok) { - gpuFailures++; - } - if (--remainingMips == 0) { - DestroyObject(baseTexture); - mipSource.Release(); - DestroyObject(mipSource); - Interlocked.Decrement(ref outstandingTextures); - Interlocked.Increment(ref finishedItems); - } - }); + try { + if (!await encoder.EncodeTextureAsync(mipped, item.MipWidths, item.MipHeights, item.MipOffsets, + cookedData, cancellationToken)) gpuFailures++; + Interlocked.Increment(ref finishedItems); + } finally { + DestroyObject(upload); + mipped.Release(); + DestroyObject(mipped); } - - // rough: base level plus one third for the mip chain - pixelsSinceYield += (long)item.Cooked.Width * item.Cooked.Height * 4 / 3; reportProgress?.Invoke(Volatile.Read(ref finishedItems), totalItems); } @@ -333,58 +320,58 @@ Texture2D DecodeOnMainThread(CookPlanItem item) } } - while (true) { - cancellationToken.ThrowIfCancellationRequested(); - - // The big files are the long pole: decode them first while the workers fill the - // queue, then drain whatever the workers produced. - if (mainThreadItems.Count > 0) { - var item = mainThreadItems.Dequeue(); - var upload = DecodeOnMainThread(item); - if (upload) { - SubmitToGpu(item, upload); - } - // One big decode blocks long enough; give the player loop a frame either way. - await Task.Yield(); - continue; - } + try { + while (true) { + cancellationToken.ThrowIfCancellationRequested(); - GpuWork work; - try { - if (!gpuQueue.TryTake(out work, 10, cancellationToken)) { - if (gpuQueue.IsCompleted) { - break; + // The big files are the long pole: decode them first while the workers fill the + // queue, then drain whatever the workers produced. + if (mainThreadItems.Count > 0) { + var item = mainThreadItems.Dequeue(); + var upload = DecodeOnMainThread(item); + if (upload) { + await SubmitToGpu(item, upload); + if (gpuFailures > 0) return null; } + // One big decode blocks long enough; give the player loop a frame either way. await Task.Yield(); continue; } - } catch (InvalidOperationException) { - break; // completed between checks - } - var workerUpload = PrepareWorkerUpload(work); - if (workerUpload) { - // Worker-decoded normals arrive already AG-packed. - SubmitToGpu(work.Item, workerUpload); - } + GpuWork work; + try { + if (!gpuQueue.TryTake(out work, 10, cancellationToken)) { + if (gpuQueue.IsCompleted) { + break; + } + await Task.Yield(); + continue; + } + } catch (InvalidOperationException) { + break; // completed between checks + } + + var workerUpload = PrepareWorkerUpload(work); + if (workerUpload) { + // Worker-decoded normals arrive already AG-packed. + await SubmitToGpu(work.Item, workerUpload); + if (gpuFailures > 0) return null; + } - if (pixelsSinceYield > EncodePixelYieldBudget) { - pixelsSinceYield = 0; - await Task.Yield(); } - } - await decodeTask; + await decodeTask; - // Flush all pending readbacks; their callbacks run inside WaitAllRequests. - AsyncGPUReadback.WaitAllRequests(); - var guard = 0; - while (Volatile.Read(ref outstandingTextures) > 0 && guard++ < 1000) { - await Task.Yield(); - AsyncGPUReadback.WaitAllRequests(); + } finally { + // A preparation/dispatch exception can abandon a full queue. Always release + // and observe the decode workers before fallback or cancellation returns. + decodeCancellation.Cancel(); + try { await decodeTask; } + catch (OperationCanceledException) { } + catch (Exception ex) { Logger.Debug(ex, "VpeTextureCook: decode workers stopped after cook failure."); } } - if (decodeFailures > 0 || gpuFailures > 0 || Volatile.Read(ref outstandingTextures) > 0) { + if (decodeFailures > 0 || gpuFailures > 0) { Logger.Warn($"VpeTextureCook: cook aborted (decodeFailures={decodeFailures}, gpuFailures={gpuFailures})."); return null; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs index 9543e935b8..32d5084752 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs @@ -31,6 +31,8 @@ public enum VpeUpscaler Off = 0, Dlss = 1, // NVIDIA Deep Learning Super Sampling Fsr2 = 2, // AMD FidelityFX Super Resolution 2 + Fsr1 = 3, // URP spatial upscaling + Stp = 4, // URP Spatial-Temporal Post-processing } /// How far the upscaler lowers the internal resolution (each upscaler picks its own scale per mode). diff --git a/VisualPinball.Unity/VisualPinball.Unity/Resources/VpeBc7Encode.compute b/VisualPinball.Unity/VisualPinball.Unity/Resources/VpeBc7Encode.compute index eb7c6ecbf4..3cf9537610 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Resources/VpeBc7Encode.compute +++ b/VisualPinball.Unity/VisualPinball.Unity/Resources/VpeBc7Encode.compute @@ -204,7 +204,7 @@ cbuffer cbCS : register(b0) uint g_start_block_id; uint g_num_total_blocks; float g_alpha_weight; - uint g_src_mip; + uint g_src_mip; uint g_tex_height; }; @@ -301,12 +301,12 @@ void TryMode456CS(uint GI : SV_GroupIndex, uint3 groupID : SV_GroupID) // mode 4 uint threadBase = blockInGroup * MAX_USED_THREAD; // the first id of the pixel in this BC block in this thread group uint threadInBlock = GI - threadBase; // id of the pixel in this BC block -#ifndef REF_DEVICE - if (blockID >= g_num_total_blocks) - { - return; - } -#endif + // All 64 lanes must reach the group barriers, including the padding blocks + // in tiny mips. Read a valid block for inactive lanes and suppress their writes. + // Returning per block here deadlocks barriers; leaving the index unchecked + // accesses beyond the buffers when the mip contains fewer than four blocks. + bool validBlock = blockID < g_num_total_blocks; + blockID = min(blockID, g_num_total_blocks - 1); uint block_y = blockID / g_num_block_x; uint block_x = blockID - block_y * g_num_block_x; @@ -640,8 +640,9 @@ void TryMode456CS(uint GI : SV_GroupIndex, uint3 groupID : SV_GroupID) // mode 4 shared_temp[GI].rotation = shared_temp[GI + 1].rotation; } - g_OutBuff[blockID] = uint4(shared_temp[GI].error, (shared_temp[GI].index_selector << 31) | shared_temp[GI].mode, - 0, shared_temp[GI].rotation); // rotation is indeed rotation for mode 4 5. for mode 6, rotation is p bit + if (validBlock) + g_OutBuff[blockID] = uint4(shared_temp[GI].error, (shared_temp[GI].index_selector << 31) | shared_temp[GI].mode, + 0, shared_temp[GI].rotation); // rotation is indeed rotation for mode 4 5. for mode 6, rotation is p bit } } @@ -1188,12 +1189,12 @@ void EncodeBlockCS(uint GI : SV_GroupIndex, uint3 groupID : SV_GroupID) uint threadBase = blockInGroup * MAX_USED_THREAD; uint threadInBlock = GI - threadBase; -#ifndef REF_DEVICE - if (blockID >= g_num_total_blocks) - { - return; - } -#endif + // All 64 lanes must reach the group barriers, including the padding blocks + // in tiny mips. Read a valid block for inactive lanes and suppress their writes. + // Returning per block here deadlocks barriers; leaving the index unchecked + // accesses beyond the buffers when the mip contains fewer than four blocks. + bool validBlock = blockID < g_num_total_blocks; + blockID = min(blockID, g_num_total_blocks - 1); uint block_y = blockID / g_num_block_x; uint block_x = blockID - block_y * g_num_block_x; @@ -1231,8 +1232,10 @@ void EncodeBlockCS(uint GI : SV_GroupIndex, uint3 groupID : SV_GroupID) GroupMemoryBarrierWithGroupSync(); #endif - uint bits = candidateSectionBit[partition]; - uint bits2 = candidateSectionBit2[partition - 64]; + // Modes 1/3/7 use partitions 0..63; modes 0/2 use 64..127. + // Both values are evaluated even when only one table is used by the mode. + uint bits = candidateSectionBit[min(partition, 63u)]; + uint bits2 = candidateSectionBit2[partition >= 64 ? partition - 64 : 0]; uint2x4 ep; ep[0] = MAX_UINT; @@ -1539,7 +1542,7 @@ void EncodeBlockCS(uint GI : SV_GroupIndex, uint3 groupID : SV_GroupID) GroupMemoryBarrierWithGroupSync(); #endif - if (0 == threadInBlock) + if (0 == threadInBlock && validBlock) { uint4 block; if (0 == mode) From 40a8f484007f2db36f672b68b8bcefa0f7c0a72b Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 20 Oct 2026 23:24:48 +0200 Subject: [PATCH 2/2] Drop legacy texture cache formats and delete version mismatches --- .../Packaging/VpeTextureCache.cs | 40 +++++-------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCache.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCache.cs index 662c7f70c9..b923f85ab6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCache.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTextureCache.cs @@ -65,7 +65,7 @@ public static class VpeTextureCache private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const uint Magic = 0x43545056; // "VPTC" - private const int HeaderVersion = 4; // v4 uses texture content identity; v3 used package size/mtime + private const int HeaderVersion = 5; // Only the current cache format is supported. private const string CacheFolderName = "TextureCache"; private const string CacheExtension = ".vptc"; @@ -128,19 +128,13 @@ public static string ComputeTextureIdentity(string vpePath) return BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(string.Join("\n", entries)))); } - public static string GetCachePath(string cacheRoot, string vpePath) + public static string GetCachePath(string cacheRoot, string vpePath, long settingsHash) { using var sha1 = SHA1.Create(); var hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(Path.GetFullPath(vpePath).ToLowerInvariant())); var name = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); - return Path.Combine(cacheRoot, CacheFolderName, name + CacheExtension); - } - - public static string GetCachePath(string cacheRoot, string vpePath, long settingsHash) - { - var legacy = GetCachePath(cacheRoot, vpePath); - return Path.Combine(Path.GetDirectoryName(legacy)!, Path.GetFileNameWithoutExtension(legacy) - + "-" + settingsHash.ToString("x", System.Globalization.CultureInfo.InvariantCulture) + CacheExtension); + return Path.Combine(cacheRoot, CacheFolderName, name + "-" + + settingsHash.ToString("x", System.Globalization.CultureInfo.InvariantCulture) + CacheExtension); } public static bool TryLoad(string cacheRoot, string vpePath, long settingsHash, out CacheData data) @@ -148,29 +142,18 @@ public static bool TryLoad(string cacheRoot, string vpePath, long settingsHash, data = null; try { var cachePath = GetCachePath(cacheRoot, vpePath, settingsHash); - if (!File.Exists(cachePath)) { - cachePath = GetCachePath(cacheRoot, vpePath); // legacy single-variant cache - if (!File.Exists(cachePath)) return false; - } - - var vpeInfo = new FileInfo(vpePath); + if (!File.Exists(cachePath)) return false; using var stream = new FileStream(cachePath, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16); using var reader = new BinaryReader(stream); if (reader.ReadUInt32() != Magic) return false; - var headerVersion = reader.ReadInt32(); - if (headerVersion != 3 && headerVersion != HeaderVersion) { - return false; - } - if (reader.ReadInt64() != settingsHash) { + if (reader.ReadInt32() != HeaderVersion) { + reader.Dispose(); + File.Delete(cachePath); return false; } - var sourceLength = reader.ReadInt64(); - var sourceTime = reader.ReadInt64(); - if (headerVersion == 3) { - // Legacy files have no content identity; retain their strict validation. - if (sourceLength != vpeInfo.Length || sourceTime != vpeInfo.LastWriteTimeUtc.Ticks) return false; - } else if (reader.ReadString() != ComputeTextureIdentity(vpePath)) return false; + if (reader.ReadInt64() != settingsHash) return false; + if (reader.ReadString() != ComputeTextureIdentity(vpePath)) return false; var manifestLength = reader.ReadInt32(); if (manifestLength <= 0 || manifestLength > 256 * 1024 * 1024) { @@ -262,7 +245,6 @@ public static void Write(string cacheRoot, string vpePath, long settingsHash, Ma var cachePath = GetCachePath(cacheRoot, vpePath, settingsHash); Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); - var vpeInfo = new FileInfo(vpePath); var manifestBytes = PackageApi.Packer.Pack(manifest); var sourceIdentity = expectedSourceIdentity ?? ComputeTextureIdentity(vpePath); if (expectedSourceIdentity != null && ComputeTextureIdentity(vpePath) != expectedSourceIdentity) { @@ -292,8 +274,6 @@ public static void Write(string cacheRoot, string vpePath, long settingsHash, Ma writer.Write(Magic); writer.Write(HeaderVersion); writer.Write(settingsHash); - writer.Write(vpeInfo.Length); - writer.Write(vpeInfo.LastWriteTimeUtc.Ticks); writer.Write(sourceIdentity); writer.Write(manifestBytes.Length); writer.Write(manifestBytes);