Compare commits

...

2 Commits

Author SHA1 Message Date
kuba c284720e39 [GPU] Avoid recursive submission when occlusion readback storage wraps 2026-10-07 11:52:58 +02:00
kuba ad92a314ae [GPU] Count guest occlusion queries with Vulkan occlusion queries
A guest occlusion query is a pair of PIXEL_PIPE_STAT_DUMP events into a
256-byte block: the begin dump writes each depth block's sample counter at
block + 16 * db, the end dump at block + 8 + 16 * db, and the guest sums
end - begin over 16 depth blocks once every slot carries the ready bit.
The interpreter published a synthetic always-visible result, so UE drew
every occlusion-culled primitive.

The presenter now brackets the draws between the dumps with precise
Vulkan occlusion queries. A query is split at every rendering scope
boundary and segment k is published as depth block k's count, so the
guest's own sum adds the segments. Results are copied into the download
ring after the scope that ended the query and written to guest memory on
the command worker once the GPU retires them, the way a command-processor
WRITE_DATA would. More than 16 segments, an unmatched end or an exhausted
pool publish a conservative visible result. Devices without precise
occlusion queries or host query reset keep the synthetic result.

Opt-in with SHARPEMU_HOST_OCCLUSION_QUERIES=1 for now: on Silent Hill's title
menu the character is intermittently reported occluded and culled for seconds
at a time, a false negative that is not yet explained (a stale result from a
reused query block was ruled out). The synthetic visible result stays the
default.
2026-10-07 11:52:57 +02:00
13 changed files with 468 additions and 2 deletions

View File

@ -435,7 +435,12 @@ public sealed partial class GpuCommandInterpreter
throw _host.Fatal($"The occlusion-counter dump is invalid: index=0x{eventIndex:X8} address=0x{eventAddress:X16}.");
}
// Until real sample queries are implemented, conservatively report visibility.
if (_host.TryRecordOcclusionCounterDump(eventAddress))
{
break;
}
// Without host sample queries, conservatively report visibility.
// A one-sample delta can disappear when the guest scales or reduces the counts.
const ulong visibleCounterStep = 0x2FFFFFF;
const ulong readyBit = 1UL << 63;

View File

@ -105,6 +105,10 @@ public interface ICommandStreamHost
void FillBuffer(ulong address, ulong size, uint value, bool isGds);
// Records a PIXEL_PIPE_STAT_DUMP occlusion counter dump with real sample counts; false when
// the host cannot count samples and the interpreter must publish a conservative result.
bool TryRecordOcclusionCounterDump(ulong address) => false;
void CopyBuffer(ulong destination, ulong source, ulong size, bool destinationIsGds, bool sourceIsGds);
// A satisfied wait packet or a command-processor memory write: CPU writes released by the

View File

@ -0,0 +1,43 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Rendering;
// The 256-byte block a guest occlusion query reads: per depth block, a begin counter at
// 16 * db and an end counter at 16 * db + 8, each valid once bit 63 is set. The guest sums
// end - begin over the depth blocks, so host segment k is published as depth block k's count.
internal static class OcclusionResultBlock
{
public const int Bytes = 256;
public const int DepthBlocks = 16;
public const ulong ReadyBit = 1UL << 63;
// Reported when the samples could not be counted, so the guest keeps the object visible.
public const ulong ConservativeCount = 0x00FF_FFFF;
public static byte[] Build(int segmentCount, bool conservative, ReadOnlySpan<ulong> counts)
{
if (segmentCount is < 0 or > DepthBlocks || (!conservative && counts.Length < segmentCount))
{
throw new ArgumentOutOfRangeException(nameof(segmentCount));
}
var block = new byte[Bytes];
var slots = MemoryMarshal.Cast<byte, ulong>(block.AsSpan());
slots.Fill(ReadyBit);
if (conservative)
{
slots[1] = ReadyBit | ConservativeCount;
return block;
}
for (var segment = 0; segment < segmentCount; segment++)
{
slots[segment * 2 + 1] = ReadyBit | (counts[segment] & (ReadyBit - 1));
}
return block;
}
}

View File

@ -308,6 +308,7 @@ internal static unsafe partial class VulkanVideoPresenter
{
using var relayScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.QueueRelay);
_relay.RunPendingCommands();
DrainOcclusionWrites();
}
public void BeginSubmission(int queueId, ulong submissionId, object? geometrySnapshots)

View File

@ -841,7 +841,9 @@ internal static unsafe partial class VulkanVideoPresenter
DepthBiasClamp = supportedFeatures.DepthBiasClamp,
SampleRateShading = supportedFeatures.SampleRateShading,
SamplerAnisotropy = supportedFeatures.SamplerAnisotropy,
OcclusionQueryPrecise = supportedFeatures.OcclusionQueryPrecise,
};
_supportsPreciseOcclusion = supportedFeatures.OcclusionQueryPrecise;
if (!supportedFeatures.SampleRateShading)
{
@ -997,13 +999,19 @@ internal static unsafe partial class VulkanVideoPresenter
SType = StructureType.PhysicalDeviceShaderAtomicInt64Features,
PNext = &addressFeatures,
};
var hostQueryResetFeatures = new PhysicalDeviceHostQueryResetFeatures
{
SType = StructureType.PhysicalDeviceHostQueryResetFeatures,
PNext = &atomicInt64Features,
};
var featuresQuery = new PhysicalDeviceFeatures2
{
SType = StructureType.PhysicalDeviceFeatures2,
PNext = &atomicInt64Features,
PNext = &hostQueryResetFeatures,
};
_vk.GetPhysicalDeviceFeatures2(_physicalDevice, &featuresQuery);
var supportsTimelineSemaphore = timelineSemaphoreFeatures.TimelineSemaphore;
_supportsHostQueryReset = hostQueryResetFeatures.HostQueryReset;
var supportsBufferDeviceAddress = addressFeatures.BufferDeviceAddress;
var supportsSharedInt64Atomics = atomicInt64Features.ShaderSharedInt64Atomics;
var supportsMaintenance8 = maintenance8Features.Maintenance8;
@ -1152,6 +1160,16 @@ internal static unsafe partial class VulkanVideoPresenter
PNext = &timelineSemaphoreFeatures,
};
void* renderingChain = &addressFeatures;
if (_supportsHostQueryReset)
{
hostQueryResetFeatures = new PhysicalDeviceHostQueryResetFeatures
{
SType = StructureType.PhysicalDeviceHostQueryResetFeatures,
HostQueryReset = true,
PNext = renderingChain,
};
renderingChain = &hostQueryResetFeatures;
}
if (supportsSharedInt64Atomics)
{
atomicInt64Features = new PhysicalDeviceShaderAtomicInt64Features

View File

@ -59,6 +59,7 @@ internal static unsafe partial class VulkanVideoPresenter
if (_device.Handle != 0)
{
_scheduler.Dispose();
DestroyOcclusionPool();
_deviceInfo.Slabs.Destroy();
DestroyGuestPipelineCaches();
if (_pipelineCache.Handle != 0)

View File

@ -0,0 +1,272 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using SharpEmu.Libs.Gpu.Buffers;
using SharpEmu.Libs.Gpu.Rendering;
using SharpEmu.Libs.Gpu.Scheduling;
using SharpEmu.Libs.Gpu.Vulkan;
using Silk.NET.Vulkan;
namespace SharpEmu.Libs.VideoOut;
internal static unsafe partial class VulkanVideoPresenter
{
private sealed partial class Presenter
{
// Guest occlusion queries are a pair of PIXEL_PIPE_STAT_DUMP events into one 256-byte block:
// the begin dump writes each depth block's sample counter to block + 16 * db, the end dump to
// block + 8 + 16 * db, and the guest sums end - begin over the depth blocks once every slot
// carries the ready bit. The host counts the draws in between with Vulkan occlusion queries.
// A query is split at every rendering scope boundary (Vulkan queries cannot span scopes), and
// segment k is published as depth block k's count, so the guest's own sum adds the segments.
// Publishing is recorded after the scope that ended the query, so it never splits a scope.
private const int OcclusionBlockBytes = OcclusionResultBlock.Bytes;
private const int OcclusionDepthBlocks = OcclusionResultBlock.DepthBlocks;
private const uint OcclusionPoolSize = 4096;
private bool _supportsPreciseOcclusion;
private bool _supportsHostQueryReset;
private QueryPool _occlusionPool;
private readonly Stack<uint> _freeOcclusionQueries = new();
private OcclusionQuery? _openOcclusion;
private uint? _activeOcclusionSegment;
private readonly List<OcclusionQuery> _pendingOcclusionResults = new();
private sealed class OcclusionQuery(ulong block)
{
public ulong Block { get; } = block;
public List<uint> Segments { get; } = new();
// More scopes than depth blocks, or no free query: publish a visible result.
public bool Conservative { get; set; }
}
// Opt-in while an intermittent false "occluded" result is open: in Silent Hill: The Short
// Message the title-menu character is sometimes culled for seconds at a time. Without it the
// interpreter keeps publishing a conservative visible result.
private static readonly bool HostOcclusionQueries =
Environment.GetEnvironmentVariable("SHARPEMU_HOST_OCCLUSION_QUERIES") == "1";
// False when the device cannot count samples; the interpreter then publishes a visible result.
public bool TryRecordOcclusionCounterDump(ulong address)
{
if (!HostOcclusionQueries || !_supportsPreciseOcclusion || !_supportsHostQueryReset || (address & 7) != 0)
{
return false;
}
switch (address & 0xF)
{
case 0:
BeginOcclusionQuery(address);
return true;
case 8:
EndOcclusionQuery(address - 8);
return true;
default:
return false;
}
}
private void EnsureOcclusionPool()
{
if (_occlusionPool.Handle != 0)
{
return;
}
var create = new QueryPoolCreateInfo
{
SType = StructureType.QueryPoolCreateInfo,
QueryType = QueryType.Occlusion,
QueryCount = OcclusionPoolSize,
};
Check(_vk.CreateQueryPool(_device, &create, null, out _occlusionPool), "vkCreateQueryPool(occlusion)");
_vk.ResetQueryPool(_device, _occlusionPool, 0, OcclusionPoolSize);
for (var index = OcclusionPoolSize; index > 0; index--)
{
_freeOcclusionQueries.Push(index - 1);
}
}
private void BeginOcclusionQuery(ulong block)
{
EnsureOcclusionPool();
if (_openOcclusion is { } previous)
{
// A begin without an end: close the previous query so its slots still become ready.
EndOcclusionQuery(previous.Block);
}
_openOcclusion = new OcclusionQuery(block);
if (_renderingActive)
{
BeginOcclusionSegment(new CommandBuffer(_scheduler.Current.Handle));
}
}
private void EndOcclusionQuery(ulong block)
{
EnsureOcclusionPool();
var query = _openOcclusion;
if (query is null || query.Block != block)
{
// An end without its begin: there is nothing to count, so report the block visible.
query = new OcclusionQuery(block) { Conservative = true };
}
else
{
EndOcclusionSegment(new CommandBuffer(_scheduler.Current.Handle));
_openOcclusion = null;
}
_pendingOcclusionResults.Add(query);
if (!_renderingActive)
{
PublishOcclusionResults(BeginBatchedGuestCommands());
}
}
// Called right after a rendering scope begins.
private void BeginOcclusionSegment(CommandBuffer command)
{
if (_openOcclusion is not { } query || _activeOcclusionSegment is not null)
{
return;
}
if (query.Segments.Count >= OcclusionDepthBlocks || !_freeOcclusionQueries.TryPop(out var index))
{
query.Conservative = true;
return;
}
query.Segments.Add(index);
_vk.CmdBeginQuery(command, _occlusionPool, index, QueryControlFlags.PreciseBit);
_activeOcclusionSegment = index;
}
// Called right before a rendering scope ends.
private void EndOcclusionSegment(CommandBuffer command)
{
if (_activeOcclusionSegment is not { } index)
{
return;
}
_vk.CmdEndQuery(command, _occlusionPool, index);
_activeOcclusionSegment = null;
}
// Records the results of every ended query; must run outside a scope. The counts land in the
// download ring, and once the GPU retires them the 256-byte blocks are written to guest
// memory on this worker, the way a command-processor WRITE_DATA would, so the buffer cache
// refreshes any GPU copy itself and the blocks never become GPU-owned memory.
private void PublishOcclusionResults(CommandBuffer command)
{
if (_pendingOcclusionResults.Count == 0)
{
return;
}
var queries = _pendingOcclusionResults.ToArray();
_pendingOcclusionResults.Clear();
var bytes = (ulong)queries.Length * OcclusionDepthBlocks * sizeof(ulong);
var ring = _bufferCache.GetUtilityBuffer(GpuBufferUsage.Download);
GpuBuffer download = ring;
// This can run inside submission's EndRendering hook. A ring wrap must not
// recursively submit that command buffer, nor leave us recording into a
// submitted handle. Overflow storage survives until its completion callback.
var overflow = !ring.TryMap(bytes, out var offset, sizeof(ulong), allowWait: false);
if (overflow)
{
download = new GpuBuffer(_deviceInfo, _scheduler, GpuBufferUsage.Download,
0, BufferUsageFlags.TransferDstBit, bytes);
offset = 0;
}
else
{
ring.Commit();
}
for (var index = 0; index < queries.Length; index++)
{
var segments = queries[index].Segments;
for (var segment = 0; segment < segments.Count; segment++)
{
_vk.CmdCopyQueryPoolResults(command, _occlusionPool, segments[segment], 1, download.Handle,
offset + ((ulong)index * OcclusionDepthBlocks + (ulong)segment) * sizeof(ulong), sizeof(ulong),
QueryResultFlags.Result64Bit | QueryResultFlags.ResultWaitBit);
}
}
var barrier = new MemoryBarrier2
{
SType = StructureType.MemoryBarrier2,
SrcAccessMask = AccessFlags2.TransferWriteBit,
DstAccessMask = AccessFlags2.HostReadBit,
};
VulkanSynchronization.PipelineBarrier(_vk, command, PipelineStageFlags.TransferBit, PipelineStageFlags.HostBit, 0, 1, &barrier, 0, null, 0, null);
foreach (var query in queries)
{
foreach (var segment in query.Segments)
{
// Query commands execute in order, so the reset follows the copy above.
_vk.CmdResetQueryPool(command, _occlusionPool, segment, 1);
_freeOcclusionQueries.Push(segment);
}
}
_scheduler.QueuePriorityCompletionAction(() =>
{
try
{
download.Invalidate(offset, bytes);
var counts = MemoryMarshal.Cast<byte, ulong>(download.Mapped.Slice((int)offset, (int)bytes));
for (var index = 0; index < queries.Length; index++)
{
_occlusionWrites.Enqueue((queries[index].Block, BuildOcclusionBlock(queries[index],
counts.Slice(index * OcclusionDepthBlocks, OcclusionDepthBlocks))));
}
}
finally
{
if (overflow) download.Dispose();
}
});
}
private static byte[] BuildOcclusionBlock(OcclusionQuery query, ReadOnlySpan<ulong> counts) =>
OcclusionResultBlock.Build(query.Segments.Count, query.Conservative, counts);
private readonly System.Collections.Concurrent.ConcurrentQueue<(ulong Block, byte[] Data)> _occlusionWrites = new();
// Runs on the command worker before every packet.
private void DrainOcclusionWrites()
{
while (_occlusionWrites.TryDequeue(out var write))
{
if (!_guestMemory.TryWrite(write.Block, write.Data))
{
throw SubmissionScheduler.Fatal($"The occlusion result cannot be written: block=0x{write.Block:X16}.");
}
}
}
private void DestroyOcclusionPool()
{
if (_occlusionPool.Handle != 0)
{
_vk.DestroyQueryPool(_device, _occlusionPool, null);
_occlusionPool = default;
}
_freeOcclusionQueries.Clear();
_pendingOcclusionResults.Clear();
_occlusionWrites.Clear();
_openOcclusion = null;
_activeOcclusionSegment = null;
}
}
}

View File

@ -869,6 +869,7 @@ internal static unsafe partial class VulkanVideoPresenter
_renderingScopesBegun++;
_renderingActive = true;
_renderingState = state;
BeginOcclusionSegment(command);
}
public void EndRendering()
@ -881,6 +882,7 @@ internal static unsafe partial class VulkanVideoPresenter
_renderingActive = false;
_renderingState = default;
var command = new CommandBuffer(_scheduler.Current.Handle);
EndOcclusionSegment(command);
_vk.CmdEndRendering(command);
foreach (var (sourceStages, destinationStages, barriers) in _barriersAfterRendering)
{
@ -893,6 +895,7 @@ internal static unsafe partial class VulkanVideoPresenter
}
_barriersAfterRendering.Clear();
PublishOcclusionResults(command);
if (_globalBarrierAfterRendering)
{
_globalBarrierAfterRendering = false;

View File

@ -29,6 +29,20 @@ internal sealed class RecordingCommandStreamHost : ICommandStreamHost
public List<string> Calls { get; } = new();
// When set, the host counts occlusion samples itself and records each dump address here.
public List<ulong>? OcclusionDumps { get; set; }
public bool TryRecordOcclusionCounterDump(ulong address)
{
if (OcclusionDumps is null)
{
return false;
}
OcclusionDumps.Add(address);
return true;
}
public List<EndOfPipeWrite> EndOfPipeWrites { get; } = new();
// Satisfied waits and command-processor memory writes reported to the host.

View File

@ -337,6 +337,20 @@ public sealed class GpuCommandInterpreterLabelTests
Assert.Equal((1UL << 63) | 0x2FFFFFFUL, runner.Host.ReadQword(Label));
}
[Fact]
public void EventWrite_OcclusionDumpUsesHostSampleCountsWhenAvailable()
{
var runner = new StreamRunner();
runner.Host.OcclusionDumps = new List<ulong>();
runner.Run(StreamRunner.Packet(PacketOpcode.EventWrite, 0x39 | (1u << 8), StreamRunner.Low(Label), StreamRunner.High(Label)));
runner.Run(StreamRunner.Packet(PacketOpcode.EventWrite, 0x39 | (1u << 8), StreamRunner.Low(Label + 8), StreamRunner.High(Label + 8)));
Assert.Equal(new[] { Label, Label + 8 }, runner.Host.OcclusionDumps);
Assert.Empty(runner.Host.Calls);
Assert.Equal(0UL, runner.Host.ReadQword(Label));
}
[Fact]
public void FlipMarkers_PrepareRecordAndFlush()
{

View File

@ -0,0 +1,58 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.Libs.Gpu.Rendering;
using Xunit;
namespace SharpEmu.Libs.Tests.Gpu.Rendering;
public sealed class OcclusionResultBlockTests
{
private static ulong Slot(byte[] block, int depthBlock, bool end) =>
BinaryPrimitives.ReadUInt64LittleEndian(block.AsSpan(depthBlock * 16 + (end ? 8 : 0)));
// What the guest computes: the sum of end - begin over every ready depth block.
private static ulong GuestSum(byte[] block)
{
ulong sum = 0;
for (var depthBlock = 0; depthBlock < OcclusionResultBlock.DepthBlocks; depthBlock++)
{
var begin = Slot(block, depthBlock, end: false);
var end = Slot(block, depthBlock, end: true);
Assert.NotEqual(0UL, begin & OcclusionResultBlock.ReadyBit);
Assert.NotEqual(0UL, end & OcclusionResultBlock.ReadyBit);
sum += (end & ~OcclusionResultBlock.ReadyBit) - (begin & ~OcclusionResultBlock.ReadyBit);
}
return sum;
}
[Fact]
public void SegmentsAddUpThroughTheGuestSum()
{
var block = OcclusionResultBlock.Build(3, false, [10, 0, 32]);
Assert.Equal(42UL, GuestSum(block));
Assert.Equal(OcclusionResultBlock.ReadyBit | 10, Slot(block, 0, end: true));
Assert.Equal(OcclusionResultBlock.ReadyBit | 32, Slot(block, 2, end: true));
}
[Fact]
public void AQueryWithoutDrawsReportsZeroSamples()
{
Assert.Equal(0UL, GuestSum(OcclusionResultBlock.Build(0, false, [])));
}
[Fact]
public void AnUncountedQueryStaysVisible()
{
Assert.Equal(OcclusionResultBlock.ConservativeCount, GuestSum(OcclusionResultBlock.Build(0, true, [])));
}
[Fact]
public void MoreSegmentsThanDepthBlocksAreRejected()
{
Assert.Throws<ArgumentOutOfRangeException>(() => OcclusionResultBlock.Build(17, false, new ulong[17]));
}
}

View File

@ -79,6 +79,7 @@ internal sealed class PresenterUnderTest : IDisposable
"_deferredGuestImageVersionDestroys",
"_pipelineEntries", "_shaderModules", "_shaderModuleSpirvBytes",
"_preparedTextures", "_barriersAfterRendering", "_feedbackSnapshotPool",
"_occlusionWrites", "_pendingOcclusionResults", "_freeOcclusionQueries",
})
{
var field = PresenterType.GetField(name, InstanceMembers)!;

View File

@ -40,6 +40,38 @@ public sealed unsafe partial class RenderHostDeviceTests : IClassFixture<Headles
private static bool DefersGlobalBarriers =>
(bool)PresenterUnderTest.PresenterType.GetField("DeferGlobalBarriers", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!.GetValue(null)!;
[Theory]
[InlineData(false)]
[InlineData(true)]
public void OcclusionReadbackDoesNotSubmitWhileEndingRendering(bool exhaustRing)
{
if (!Ready()) return;
using var presenter = new PresenterUnderTest(_vulkan!);
var address = presenter.Harness.MapBacked(0x10000, ReadWrite);
presenter.Run(() =>
{
var ring = presenter.Harness.Cache.GetUtilityBuffer(GpuBufferUsage.Download);
if (exhaustRing)
{
Assert.True(ring.TryMap(ring.Size, out _));
ring.Commit();
}
var queryType = PresenterUnderTest.PresenterType.GetNestedType("OcclusionQuery",
System.Reflection.BindingFlags.NonPublic)!;
var query = Activator.CreateInstance(queryType, [address])!;
queryType.GetProperty("Conservative")!.SetValue(query, true);
presenter.GetField<System.Collections.IList>("_pendingOcclusionResults").Add(query);
var tick = presenter.Harness.Scheduler.CurrentTick;
presenter.InvokeMethod("PublishOcclusionResults", presenter.Command);
Assert.Equal(tick, presenter.Harness.Scheduler.CurrentTick);
presenter.Harness.Scheduler.Finish();
presenter.Harness.Scheduler.WaitForAllPriorityOperations();
((ICommandStreamHost)presenter.Instance).RunPendingCommands();
});
Assert.Equal(OcclusionResultBlock.Build(0, true, []), presenter.Harness.Read(address, 256));
presenter.Harness.Shutdown();
}
[Fact]
public void GlobalBarrierEndsDynamicRendering()
{