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..b923f85ab6 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 = 5; // Only the current cache format is supported.
private const string CacheFolderName = "TextureCache";
private const string CacheExtension = ".vptc";
@@ -103,36 +105,55 @@ public sealed class CacheData
public byte[] CookedData;
}
- public static string GetCachePath(string cacheRoot, string vpePath)
+ ///
+ /// 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, 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);
+ 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)
{
data = null;
try {
- var cachePath = GetCachePath(cacheRoot, vpePath);
- if (!File.Exists(cachePath)) {
- return false;
- }
-
- var vpeInfo = new FileInfo(vpePath);
+ var cachePath = GetCachePath(cacheRoot, vpePath, settingsHash);
+ 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 || reader.ReadInt32() != HeaderVersion) {
- return false;
- }
- if (reader.ReadInt64() != settingsHash) {
- return false;
- }
- if (reader.ReadInt64() != vpeInfo.Length || reader.ReadInt64() != vpeInfo.LastWriteTimeUtc.Ticks) {
+ if (reader.ReadUInt32() != Magic) return false;
+ if (reader.ReadInt32() != HeaderVersion) {
+ reader.Dispose();
+ File.Delete(cachePath);
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) {
@@ -218,14 +239,18 @@ 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;
@@ -249,8 +274,7 @@ 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);
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)