mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 14:39:42 +08:00
a158960c20
* feat(gpu): GPU compute detile for guest tiled textures (Vulkan + Metal) Move RDNA2 exact-XOR deswizzle (swizzle modes 5/9/24/27, 4bpp) off the CPU onto a GPU compute pass. GnmTiling.GetDetileParams resolves the shared addressing into DetileParams; the CPU fallback and both GPU kernels consume the same params so they never disagree. Vulkan (verified bit-exact on NVIDIA): SpirvFixedShaders.CreateDetileCompute hand-emits the SPIR-V kernel; VulkanDetilePass.RecordDetile records the dispatch into the async batch command buffer (never a blocking submit on the render thread) with transients retired via fence; VulkanDetileSelfTest (SHARPEMU_DETILE_SELFTEST=1) checks both entry points against the CPU detile. Metal (Mac-untested): detile_compute.msl (detile_cs) + MetalDetilePass mirror the Vulkan pass. The active Metal path CPU-detiles via the new GnmTiling.DetileWithParams when a texture arrives packaged (empty RgbaPixels + TiledSource/Detile), keeping Metal correct under default-on with no regression; wiring MetalDetilePass live is the remaining on-device step. Flags: GPU detile is default-on (SHARPEMU_GPU_DETILE=0 disables); [GPU-DETILE] diagnostics gated behind SHARPEMU_LOG_GPU_DETILE=1. Tests: 17 detile unit tests pass, incl. DetileWithParams and GetDetileParams each matching TryDetile bit-for-bit across all supported modes/bpp, plus a SPIR-V structural-validity test. * feat(gpu): GPU compute detile for guest tiled textures (Vulkan + Metal) Move RDNA2 exact-XOR deswizzle (swizzle modes 5/9/24/27, 4bpp) off the CPU onto a GPU compute pass. GnmTiling.GetDetileParams resolves the shared addressing into DetileParams that the CPU fallback and both GPU kernels consume, so they never disagree; everything else keeps the CPU path. Vulkan (verified bit-exact on NVIDIA): SpirvFixedShaders.CreateDetileCompute hand-emits the kernel; VulkanDetilePass.RecordDetile records into the async batch command buffer (never a blocking submit on the render thread) with transients retired via fence, falling back to CPU detile on failure. VulkanDetileSelfTest (SHARPEMU_DETILE_SELFTEST=1) checks both entry points. Metal (Mac-untested): detile_compute.msl + MetalDetilePass mirror the Vulkan pass; the active Metal path CPU-detiles via GnmTiling.DetileWithParams so it stays correct under default-on. Wiring MetalDetilePass live is a follow-up. Flags: default-on (SHARPEMU_GPU_DETILE=0 disables); diagnostics behind SHARPEMU_LOG_GPU_DETILE=1. Adds 17 passing detile unit tests. * Fix: added support layered texture support for the GPU-Detiling. * Fix: Added support for BlockTable (1 / 4 / 8 (Morton/Z-order)) * feat: added support for 8 and 16 bpp (bytes per element) * Fixed a build failure specific to this branch ---------
2194 lines
84 KiB
C#
2194 lines
84 KiB
C#
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
using SharpEmu.Libs.Agc;
|
|
using SharpEmu.ShaderCompiler;
|
|
using SharpEmu.ShaderCompiler.Metal;
|
|
|
|
namespace SharpEmu.Libs.Gpu.Metal;
|
|
|
|
// Translated guest draws. Submission mirrors the Vulkan presenter (offscreen and
|
|
// depth-only draws are ordered guest work publishing into guest images; onscreen
|
|
// draws ride the presentation), while execution is idiomatic Metal: render passes
|
|
// express load/clear intent directly, the driver's hazard tracking replaces the
|
|
// explicit barrier choreography, and the binding layout follows the translation
|
|
// contract documented on Gen5MslTranslator (global buffers at their flat slot,
|
|
// SharpEmuUniforms after them, textures and samplers at the image slots, vertex
|
|
// streams at a high base that never collides with global buffers).
|
|
internal static partial class MetalVideoPresenter
|
|
{
|
|
private const nuint VertexBufferSlotBase = 26;
|
|
|
|
/// <summary>Metal's vertex stage exposes buffer indices 0..30; setting a
|
|
/// vertex-descriptor attribute to 31 is a framework assertion that aborts
|
|
/// the process.</summary>
|
|
private const nuint MaxVertexStageBufferIndex = 30;
|
|
|
|
private static int _vertexSlotOverflowTraces;
|
|
|
|
/// <summary>Assigns each vertex stream a Metal buffer slot, sharing one
|
|
/// slot between attributes that read the same guest buffer — interleaved
|
|
/// vertices arrive from AGC as one <see cref="GuestVertexBuffer"/> per
|
|
/// attribute, so without sharing a handful of attributes exhausts the
|
|
/// vertex-stage buffer range. Deterministic over the draw's buffer array;
|
|
/// the pipeline descriptor and the bind path both derive from it. Returns
|
|
/// false when the unique streams still overflow Metal's last slot.</summary>
|
|
private static bool TryAssignVertexBufferSlots(
|
|
GuestVertexBuffer[] vertexBuffers,
|
|
Span<nuint> slots)
|
|
{
|
|
var uniqueCount = 0;
|
|
var overflowed = false;
|
|
for (var index = 0; index < vertexBuffers.Length; index++)
|
|
{
|
|
var buffer = vertexBuffers[index];
|
|
var shared = false;
|
|
if (buffer.BaseAddress != 0)
|
|
{
|
|
for (var prior = 0; prior < index; prior++)
|
|
{
|
|
var candidate = vertexBuffers[prior];
|
|
if (candidate.BaseAddress == buffer.BaseAddress &&
|
|
candidate.Stride == buffer.Stride &&
|
|
candidate.Length == buffer.Length)
|
|
{
|
|
slots[index] = slots[prior];
|
|
shared = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shared)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
slots[index] = VertexBufferSlotBase + (nuint)uniqueCount;
|
|
uniqueCount++;
|
|
overflowed |= slots[index] > MaxVertexStageBufferIndex;
|
|
}
|
|
|
|
return !overflowed;
|
|
}
|
|
private const nuint UsageShaderRead = 1;
|
|
private const nuint UsageShaderWrite = 2;
|
|
private const nuint UsageRenderTarget = 4;
|
|
private static bool _tracedTriangleFan;
|
|
|
|
private sealed record TranslatedGuestDraw(
|
|
MetalCompiledGuestShader? VertexShader,
|
|
MetalCompiledGuestShader PixelShader,
|
|
GuestDrawTexture[] Textures,
|
|
GuestMemoryBuffer[] GlobalMemoryBuffers,
|
|
GuestVertexBuffer[] VertexBuffers,
|
|
uint AttributeCount,
|
|
uint VertexCount,
|
|
uint InstanceCount,
|
|
uint PrimitiveType,
|
|
GuestIndexBuffer? IndexBuffer,
|
|
GuestRenderState RenderState);
|
|
|
|
private sealed record OffscreenGuestDraw(
|
|
TranslatedGuestDraw Draw,
|
|
GuestRenderTarget[] Targets,
|
|
GuestDepthTarget? DepthTarget,
|
|
bool PublishTarget,
|
|
ulong ShaderAddress);
|
|
|
|
private sealed record PipelineKey(
|
|
MetalCompiledGuestShader? VertexShader,
|
|
MetalCompiledGuestShader PixelShader,
|
|
ulong StateHash);
|
|
|
|
private static long _perfDrawCount;
|
|
private static long _perfDrawTicks;
|
|
private static long _perfPipelineCreations;
|
|
|
|
public static (long Draws, double DrawMs, long Pipelines) ReadAndResetDrawPerfCounters()
|
|
{
|
|
var draws = Interlocked.Exchange(ref _perfDrawCount, 0);
|
|
var ticks = Interlocked.Exchange(ref _perfDrawTicks, 0);
|
|
var pipelines = Interlocked.Exchange(ref _perfPipelineCreations, 0);
|
|
return (draws, ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency, pipelines);
|
|
}
|
|
|
|
private static readonly Dictionary<PipelineKey, nint> _pipelineCache = new();
|
|
private static readonly Dictionary<GuestSampler, nint> _samplerCache = new();
|
|
private static readonly Dictionary<ulong, GuestImage> _guestDepthImages = new();
|
|
|
|
// A depth target sampled later through its read address must resolve to
|
|
// the same image the write address produced (the Vulkan presenter matches
|
|
// either address on its depth resources).
|
|
private static readonly Dictionary<ulong, ulong> _guestDepthReadAliases = new();
|
|
|
|
// Retired same-address render targets: a game that recreates a target with
|
|
// a new extent at the same address may still sample the old content later,
|
|
// so replacement retires the image here instead of releasing it, and the
|
|
// draw-texture path scores candidates like the Vulkan presenter's
|
|
// guest-image variants. Bounded FIFO so stale variants cannot accumulate.
|
|
private const int MaxGuestImageVariants = 32;
|
|
private static readonly Dictionary<(ulong Address, uint Width, uint Height, MtlPixelFormat Format), GuestImage>
|
|
_guestImageVariants = new();
|
|
private static readonly Queue<(ulong Address, uint Width, uint Height, MtlPixelFormat Format)>
|
|
_guestImageVariantOrder = new();
|
|
private static readonly Dictionary<(MtlPixelFormat Format, uint Width, uint Height), nint>
|
|
_transientTargets = new();
|
|
|
|
public static void SubmitTranslatedDraw(
|
|
MetalCompiledGuestShader pixelShader,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint width,
|
|
uint height,
|
|
uint attributeCount,
|
|
MetalCompiledGuestShader? vertexShader,
|
|
uint vertexCount,
|
|
uint instanceCount,
|
|
uint primitiveType,
|
|
GuestIndexBuffer? indexBuffer,
|
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
|
GuestRenderState? renderState)
|
|
{
|
|
if (width == 0 || height == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
|
|
_latestPresentation = new Presentation(
|
|
null,
|
|
width,
|
|
height,
|
|
sequence,
|
|
IsSplash: false,
|
|
RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(),
|
|
TranslatedDraw: new TranslatedGuestDraw(
|
|
vertexShader,
|
|
pixelShader,
|
|
ToArray(textures),
|
|
ToArray(globalMemoryBuffers),
|
|
vertexBuffers is null ? [] : ToArray(vertexBuffers),
|
|
attributeCount,
|
|
vertexCount,
|
|
instanceCount,
|
|
primitiveType,
|
|
indexBuffer,
|
|
renderState ?? GuestRenderState.Default));
|
|
if (_thread is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_windowWidth = width;
|
|
_windowHeight = height;
|
|
StartPresenterLocked();
|
|
}
|
|
}
|
|
|
|
public static void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height)
|
|
{
|
|
if (drawKind == GuestDrawKind.None || width == 0 || height == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed ||
|
|
_latestPresentation is { Pixels: null } latest &&
|
|
latest.DrawKind == drawKind &&
|
|
latest.Width == width &&
|
|
latest.Height == height)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
|
|
_latestPresentation = new Presentation(
|
|
null,
|
|
width,
|
|
height,
|
|
sequence,
|
|
IsSplash: false,
|
|
RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(),
|
|
DrawKind: drawKind);
|
|
if (_thread is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_windowWidth = width;
|
|
_windowHeight = height;
|
|
StartPresenterLocked();
|
|
}
|
|
}
|
|
|
|
public static void SubmitOffscreenTranslatedDraw(
|
|
MetalCompiledGuestShader pixelShader,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint attributeCount,
|
|
IReadOnlyList<GuestRenderTarget> targets,
|
|
MetalCompiledGuestShader? vertexShader,
|
|
uint vertexCount,
|
|
uint instanceCount,
|
|
uint primitiveType,
|
|
GuestIndexBuffer? indexBuffer,
|
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
|
GuestRenderState? renderState,
|
|
GuestDepthTarget? depthTarget,
|
|
ulong shaderAddress)
|
|
{
|
|
if (targets.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var effectiveRenderState = renderState ?? GuestRenderState.Default;
|
|
if (effectiveRenderState.Blends.Count == 1 && targets.Count > 1)
|
|
{
|
|
var blends = new GuestBlendState[targets.Count];
|
|
for (var index = 0; index < blends.Length; index++)
|
|
{
|
|
blends[index] = effectiveRenderState.Blends[0];
|
|
}
|
|
|
|
effectiveRenderState = effectiveRenderState with { Blends = blends };
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var target in targets)
|
|
{
|
|
var guestTextureFormat = GetGuestTextureFormat(target.Format, target.NumberType);
|
|
if (target.Address != 0 && guestTextureFormat != 0)
|
|
{
|
|
_availableGuestImages[target.Address] = guestTextureFormat;
|
|
}
|
|
}
|
|
|
|
var workSequence = EnqueueGuestWorkLocked(
|
|
new OffscreenGuestDraw(
|
|
new TranslatedGuestDraw(
|
|
vertexShader,
|
|
pixelShader,
|
|
ToArray(textures),
|
|
ToArray(globalMemoryBuffers),
|
|
vertexBuffers is null ? [] : ToArray(vertexBuffers),
|
|
attributeCount,
|
|
vertexCount,
|
|
instanceCount,
|
|
primitiveType,
|
|
indexBuffer,
|
|
effectiveRenderState),
|
|
ToArray(targets),
|
|
depthTarget,
|
|
PublishTarget: true,
|
|
shaderAddress));
|
|
foreach (var target in targets)
|
|
{
|
|
if (target.Address != 0)
|
|
{
|
|
_guestImageWorkSequences[target.Address] = workSequence;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void SubmitDepthOnlyTranslatedDraw(
|
|
MetalCompiledGuestShader pixelShader,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint attributeCount,
|
|
GuestDepthTarget depthTarget,
|
|
MetalCompiledGuestShader? vertexShader,
|
|
uint vertexCount,
|
|
uint instanceCount,
|
|
uint primitiveType,
|
|
GuestIndexBuffer? indexBuffer,
|
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
|
GuestRenderState? renderState,
|
|
ulong shaderAddress)
|
|
{
|
|
if (depthTarget.Address == 0 || depthTarget.Width == 0 || depthTarget.Height == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnqueueGuestWorkLocked(
|
|
new OffscreenGuestDraw(
|
|
new TranslatedGuestDraw(
|
|
vertexShader,
|
|
pixelShader,
|
|
ToArray(textures),
|
|
ToArray(globalMemoryBuffers),
|
|
vertexBuffers is null ? [] : ToArray(vertexBuffers),
|
|
attributeCount,
|
|
vertexCount,
|
|
instanceCount,
|
|
primitiveType,
|
|
indexBuffer,
|
|
renderState ?? GuestRenderState.Default),
|
|
[new GuestRenderTarget(Address: 0, depthTarget.Width, depthTarget.Height, Format: 10, NumberType: 0)],
|
|
depthTarget,
|
|
PublishTarget: false,
|
|
shaderAddress));
|
|
}
|
|
}
|
|
|
|
public static void SubmitStorageTranslatedDraw(
|
|
MetalCompiledGuestShader pixelShader,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint attributeCount,
|
|
uint width,
|
|
uint height,
|
|
ulong shaderAddress)
|
|
{
|
|
var hasStorage = false;
|
|
foreach (var texture in textures)
|
|
{
|
|
hasStorage |= texture.IsStorage;
|
|
}
|
|
|
|
if (width == 0 || height == 0 || !hasStorage)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnqueueGuestWorkLocked(
|
|
new OffscreenGuestDraw(
|
|
new TranslatedGuestDraw(
|
|
null,
|
|
pixelShader,
|
|
ToArray(textures),
|
|
ToArray(globalMemoryBuffers),
|
|
[],
|
|
attributeCount,
|
|
3,
|
|
1,
|
|
4,
|
|
null,
|
|
GuestRenderState.Default),
|
|
[new GuestRenderTarget(Address: 0, width, height, Format: 12, NumberType: 7)],
|
|
DepthTarget: null,
|
|
PublishTarget: false,
|
|
shaderAddress));
|
|
}
|
|
}
|
|
|
|
private static long CurrentSubmittingQueueTailLocked()
|
|
{
|
|
var queue = _submittingGuestQueue;
|
|
return queue is { } identity &&
|
|
_lastEnqueuedGuestWorkByQueue.TryGetValue(identity.Name, out var tail)
|
|
? tail
|
|
: 0;
|
|
}
|
|
|
|
private static T[] ToArray<T>(IReadOnlyList<T> source)
|
|
{
|
|
var result = new T[source.Count];
|
|
for (var index = 0; index < result.Length; index++)
|
|
{
|
|
result[index] = source[index];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static void ExecuteOffscreenDraw(nint device, nint queue, OffscreenGuestDraw work)
|
|
{
|
|
var perfStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
Interlocked.Increment(ref _perfDrawCount);
|
|
VideoOut.PerfOverlay.RecordDraw();
|
|
try
|
|
{
|
|
ExecuteOffscreenDrawCore(device, queue, work);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Add(
|
|
ref _perfDrawTicks,
|
|
System.Diagnostics.Stopwatch.GetTimestamp() - perfStart);
|
|
}
|
|
}
|
|
|
|
private static void ExecuteOffscreenDrawCore(nint device, nint queue, OffscreenGuestDraw work)
|
|
{
|
|
var draw = work.Draw;
|
|
var targetFormats = new MetalRenderTargetFormat[work.Targets.Length];
|
|
for (var index = 0; index < targetFormats.Length; index++)
|
|
{
|
|
var target = work.Targets[index];
|
|
if (!MetalGuestFormats.TryDecodeRenderTargetFormat(
|
|
target.Format,
|
|
target.NumberType,
|
|
out targetFormats[index]))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Metal skipped draw with unsupported color target " +
|
|
$"format={target.Format} number_type={target.NumberType}.");
|
|
ReturnPooledGuestData(draw);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (draw.RenderState.Blends.Count != targetFormats.Length)
|
|
{
|
|
ReturnPooledGuestData(draw);
|
|
return;
|
|
}
|
|
|
|
// Resolve color targets: published guest images by address, transient
|
|
// pooled textures for address-0 targets (depth-only and storage draws).
|
|
var targetTextures = new nint[work.Targets.Length];
|
|
var targetLoadActions = new nuint[work.Targets.Length];
|
|
var publishedTargets = new GuestImage?[work.Targets.Length];
|
|
var firstWidth = work.Targets[0].Width;
|
|
var firstHeight = work.Targets[0].Height;
|
|
for (var index = 0; index < work.Targets.Length; index++)
|
|
{
|
|
var target = work.Targets[index];
|
|
if (target.Address == 0)
|
|
{
|
|
var extentWidth = target.Width == 0 ? firstWidth : target.Width;
|
|
var extentHeight = target.Height == 0 ? firstHeight : target.Height;
|
|
targetTextures[index] = GetTransientTarget(
|
|
device, targetFormats[index].Format, extentWidth, extentHeight);
|
|
targetLoadActions[index] = LoadActionClear;
|
|
continue;
|
|
}
|
|
|
|
var image = EnsureGuestRenderTarget(device, target, targetFormats[index].Format);
|
|
if (image is null)
|
|
{
|
|
ReturnPooledGuestData(draw);
|
|
return;
|
|
}
|
|
|
|
publishedTargets[index] = image;
|
|
targetTextures[index] = image.Texture;
|
|
targetLoadActions[index] = image.Initialized ? LoadActionLoad : LoadActionClear;
|
|
}
|
|
|
|
if (targetTextures[0] == 0)
|
|
{
|
|
ReturnPooledGuestData(draw);
|
|
return;
|
|
}
|
|
|
|
// Depth attachment, keyed by guest DB address; read-only depth drops write.
|
|
GuestImage? depth = null;
|
|
var depthState = draw.RenderState.Depth;
|
|
if (work.DepthTarget is { } depthTarget && (depthState.TestEnable || depthState.WriteEnable))
|
|
{
|
|
if (depthTarget.ReadOnly && depthState.WriteEnable)
|
|
{
|
|
depthState = depthState with { WriteEnable = false };
|
|
}
|
|
|
|
var depthWidth = Math.Max(depthTarget.Width, firstWidth);
|
|
var depthHeight = Math.Max(depthTarget.Height, firstHeight);
|
|
depth = EnsureGuestDepthImage(device, depthTarget, depthWidth, depthHeight);
|
|
}
|
|
|
|
if (!TryGetDrawPipeline(device, draw, targetFormats, depth is not null, out var pipeline))
|
|
{
|
|
ReturnPooledGuestData(draw);
|
|
return;
|
|
}
|
|
|
|
var commandBuffer = BeginBatchedGuestCommands(queue);
|
|
Span<nint> textureHandles = stackalloc nint[draw.Textures.Length];
|
|
Span<bool> textureOwned = stackalloc bool[draw.Textures.Length];
|
|
ResolveDrawTextures(device, commandBuffer, draw.Textures, textureHandles, textureOwned);
|
|
|
|
var pass = MetalNative.Send(
|
|
MetalNative.Class("MTLRenderPassDescriptor"),
|
|
MetalNative.Selector("renderPassDescriptor"));
|
|
var colorAttachments = MetalNative.Send(pass, MetalNative.Selector("colorAttachments"));
|
|
for (var index = 0; index < targetTextures.Length; index++)
|
|
{
|
|
var attachment = MetalNative.SendAtIndex(
|
|
colorAttachments, MetalNative.Selector("objectAtIndexedSubscript:"), (nuint)index);
|
|
MetalNative.SendVoid(attachment, MetalNative.Selector("setTexture:"), targetTextures[index]);
|
|
MetalNative.Send(attachment, MetalNative.Selector("setLoadAction:"), (nint)targetLoadActions[index]);
|
|
MetalNative.Send(attachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore);
|
|
}
|
|
|
|
if (depth is not null && work.DepthTarget is { } depthDescriptor)
|
|
{
|
|
var depthAttachment = MetalNative.Send(pass, MetalNative.Selector("depthAttachment"));
|
|
MetalNative.SendVoid(depthAttachment, MetalNative.Selector("setTexture:"), depth.Texture);
|
|
MetalNative.Send(
|
|
depthAttachment,
|
|
MetalNative.Selector("setLoadAction:"),
|
|
(nint)(depth.Initialized ? LoadActionLoad : LoadActionClear));
|
|
MetalNative.Send(depthAttachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore);
|
|
MetalNative.SendVoidDouble(
|
|
depthAttachment, MetalNative.Selector("setClearDepth:"), depthDescriptor.ClearDepth);
|
|
}
|
|
|
|
var encoder = MetalNative.Send(
|
|
commandBuffer, MetalNative.Selector("renderCommandEncoderWithDescriptor:"), pass);
|
|
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
|
|
|
|
EncodeRenderState(device, encoder, draw.RenderState, depthState, depth is not null, firstWidth, firstHeight);
|
|
EncodeDrawBindings(device, encoder, work, textureHandles, textureOwned, out var writeBackBuffers);
|
|
EncodeDrawCall(encoder, draw);
|
|
|
|
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
|
|
|
|
// CPU-visible GPU writes are ordering points in the guest command
|
|
// stream: completing this work item is the signal WaitForGuestWork
|
|
// relies on, so the batch must land and the write-back must complete
|
|
// before this item does. Pure-GPU draws stay in the open batch.
|
|
if (writeBackBuffers.Count > 0)
|
|
{
|
|
var committed = FlushBatchedGuestCommands();
|
|
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
|
|
WriteBuffersBackToGuest(writeBackBuffers);
|
|
}
|
|
|
|
for (var index = 0; index < publishedTargets.Length; index++)
|
|
{
|
|
if (publishedTargets[index] is { } published)
|
|
{
|
|
published.MarkContentChanged();
|
|
}
|
|
}
|
|
|
|
depth?.MarkContentChanged();
|
|
ReturnPooledGuestData(draw);
|
|
}
|
|
|
|
/// <summary>Renders a presentation-carried draw (onscreen translated draw or a
|
|
/// recognized fixed-function draw) into the reusable onscreen target.</summary>
|
|
private static nint ExecutePresentationDraw(nint device, nint queue, Presentation presentation)
|
|
{
|
|
var target = GetTransientTarget(
|
|
device, MtlPixelFormat.Bgra8Unorm, presentation.Width, presentation.Height);
|
|
if (target == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (presentation.TranslatedDraw is { } translatedDraw)
|
|
{
|
|
ExecuteOffscreenDrawToTexture(device, queue, translatedDraw, target);
|
|
}
|
|
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
|
|
{
|
|
if (!TryGetFixedDrawPipeline(device, out var pipeline))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer"));
|
|
var encoder = MetalNative.Send(
|
|
commandBuffer,
|
|
MetalNative.Selector("renderCommandEncoderWithDescriptor:"),
|
|
CreateClearPass(target, new MtlClearColor { Alpha = 1 }));
|
|
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
|
|
MetalNative.SendDrawPrimitives(
|
|
encoder,
|
|
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"),
|
|
PrimitiveTypeTriangle,
|
|
0,
|
|
3);
|
|
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
|
|
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit"));
|
|
}
|
|
|
|
return target;
|
|
}
|
|
|
|
private static void ExecuteOffscreenDrawToTexture(
|
|
nint device,
|
|
nint queue,
|
|
TranslatedGuestDraw draw,
|
|
nint target)
|
|
{
|
|
var formats = new[] { new MetalRenderTargetFormat(MtlPixelFormat.Bgra8Unorm, Gen5PixelOutputKind.Float) };
|
|
if (!TryGetDrawPipeline(device, draw, formats, hasDepth: false, out var pipeline))
|
|
{
|
|
ReturnPooledGuestData(draw);
|
|
return;
|
|
}
|
|
|
|
var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer"));
|
|
Span<nint> textureHandles = stackalloc nint[draw.Textures.Length];
|
|
Span<bool> textureOwned = stackalloc bool[draw.Textures.Length];
|
|
ResolveDrawTextures(device, commandBuffer, draw.Textures, textureHandles, textureOwned);
|
|
var encoder = MetalNative.Send(
|
|
commandBuffer,
|
|
MetalNative.Selector("renderCommandEncoderWithDescriptor:"),
|
|
CreateClearPass(target, new MtlClearColor { Alpha = 1 }));
|
|
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
|
|
var work = new OffscreenGuestDraw(draw, [], null, PublishTarget: false, ShaderAddress: 0);
|
|
EncodeDrawBindings(device, encoder, work, textureHandles, textureOwned, out var writeBackBuffers);
|
|
EncodeDrawCall(encoder, draw);
|
|
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
|
|
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit"));
|
|
TagUploadPages(commandBuffer);
|
|
TagSnapshotResources(commandBuffer);
|
|
if (writeBackBuffers.Count > 0)
|
|
{
|
|
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
|
|
WriteBuffersBackToGuest(writeBackBuffers);
|
|
}
|
|
|
|
ReturnPooledGuestData(draw);
|
|
}
|
|
|
|
private static void EncodeRenderState(
|
|
nint device,
|
|
nint encoder,
|
|
GuestRenderState renderState,
|
|
GuestDepthState depthState,
|
|
bool hasDepth,
|
|
uint targetWidth,
|
|
uint targetHeight)
|
|
{
|
|
// CB_BLEND_RED..ALPHA feed the CONSTANT_COLOR / CONSTANT_ALPHA blend
|
|
// factors; encoder state, so set unconditionally like the Vulkan
|
|
// presenter's dynamic blend constants.
|
|
var blendConstant = renderState.BlendConstant;
|
|
MetalNative.SendVoidBlendColor(
|
|
encoder,
|
|
MetalNative.Selector("setBlendColorRed:green:blue:alpha:"),
|
|
blendConstant.Red,
|
|
blendConstant.Green,
|
|
blendConstant.Blue,
|
|
blendConstant.Alpha);
|
|
|
|
if (renderState.Viewport is { } viewport)
|
|
{
|
|
// Guests program Vulkan-style negative-height viewports to get y-up
|
|
// rendering out of Vulkan's y-down NDC. Metal's NDC is already
|
|
// y-up and rejects negative heights (the draw rasterizes nothing),
|
|
// so the equivalent is the normalized rect with the same on-screen
|
|
// mapping.
|
|
double originY = viewport.Y;
|
|
double height = viewport.Height;
|
|
if (height < 0)
|
|
{
|
|
originY += height;
|
|
height = -height;
|
|
}
|
|
|
|
MetalNative.SendVoidViewport(
|
|
encoder,
|
|
MetalNative.Selector("setViewport:"),
|
|
new MtlViewport
|
|
{
|
|
OriginX = viewport.X,
|
|
OriginY = originY,
|
|
Width = viewport.Width,
|
|
Height = height,
|
|
ZNear = viewport.MinDepth,
|
|
ZFar = viewport.MaxDepth,
|
|
});
|
|
}
|
|
|
|
if (renderState.Scissor is { } scissor)
|
|
{
|
|
var x = (nuint)Math.Clamp(scissor.X, 0, (int)targetWidth);
|
|
var y = (nuint)Math.Clamp(scissor.Y, 0, (int)targetHeight);
|
|
var width = Math.Min(scissor.Width, targetWidth - (uint)x);
|
|
var height = Math.Min(scissor.Height, targetHeight - (uint)y);
|
|
if (width > 0 && height > 0)
|
|
{
|
|
MetalNative.SendVoidScissor(
|
|
encoder,
|
|
MetalNative.Selector("setScissorRect:"),
|
|
new MtlScissorRect { X = x, Y = y, Width = width, Height = height });
|
|
}
|
|
}
|
|
|
|
var raster = renderState.Raster;
|
|
// MTLCullMode: None=0, Front=1, Back=2.
|
|
var cullMode = raster switch
|
|
{
|
|
{ CullFront: true, CullBack: true } => 3,
|
|
{ CullFront: true } => 1,
|
|
{ CullBack: true } => 2,
|
|
_ => 0,
|
|
};
|
|
if (cullMode == 3)
|
|
{
|
|
// Culling both faces draws nothing; Metal has no such mode, so an
|
|
// empty scissor is the cheapest equivalent.
|
|
MetalNative.SendVoidScissor(
|
|
encoder,
|
|
MetalNative.Selector("setScissorRect:"),
|
|
new MtlScissorRect { X = 0, Y = 0, Width = 1, Height = 1 });
|
|
}
|
|
else if (cullMode != 0)
|
|
{
|
|
MetalNative.Send(encoder, MetalNative.Selector("setCullMode:"), (nint)cullMode);
|
|
}
|
|
|
|
// MTLWinding: Clockwise=0, CounterClockwise=1.
|
|
MetalNative.Send(
|
|
encoder,
|
|
MetalNative.Selector("setFrontFacingWinding:"),
|
|
raster.FrontFaceClockwise ? 0 : 1);
|
|
if (raster.Wireframe)
|
|
{
|
|
// MTLTriangleFillMode.Lines = 1.
|
|
MetalNative.Send(encoder, MetalNative.Selector("setTriangleFillMode:"), 1);
|
|
}
|
|
|
|
if (hasDepth)
|
|
{
|
|
var descriptor = MetalNative.Send(
|
|
MetalNative.Send(MetalNative.Class("MTLDepthStencilDescriptor"), MetalNative.Selector("alloc")),
|
|
MetalNative.Selector("init"));
|
|
// The guest ZFUNC encoding matches MTLCompareFunction ordering.
|
|
MetalNative.Send(
|
|
descriptor,
|
|
MetalNative.Selector("setDepthCompareFunction:"),
|
|
(nint)(depthState.TestEnable ? depthState.CompareOp & 0x7 : 7));
|
|
MetalNative.SendVoidBool(
|
|
descriptor, MetalNative.Selector("setDepthWriteEnabled:"), depthState.WriteEnable);
|
|
var depthStencilState = MetalNative.Send(
|
|
device, MetalNative.Selector("newDepthStencilStateWithDescriptor:"), descriptor);
|
|
MetalNative.SendVoid(encoder, MetalNative.Selector("setDepthStencilState:"), depthStencilState);
|
|
}
|
|
}
|
|
|
|
/// <summary>Builds and binds a stage's sampler argument buffer: one 8-byte
|
|
/// Tier 2 resource ID per sampled image, written into an arena slice and
|
|
/// bound at the shader's SamplerArgBufferIndex. The stage's images are
|
|
/// draw.Textures[ImageBindingBase + j] for its j-th image, matching how the
|
|
/// translator numbered SamplerSlots. No-op for stages that sample nothing.</summary>
|
|
private static void BindSamplerArgumentBuffer(
|
|
nint device,
|
|
nint encoder,
|
|
nint selSetBuffer,
|
|
MetalCompiledGuestShader shader,
|
|
GuestDrawTexture[] textures)
|
|
{
|
|
var slots = shader.Shader.SamplerSlots;
|
|
var count = shader.Shader.SamplerCount;
|
|
var argIndex = shader.Shader.SamplerArgBufferIndex;
|
|
if (slots is null || count == 0 || argIndex < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var imageBase = shader.Shader.ImageBindingBase;
|
|
var slice = AllocateUpload(device, count * sizeof(ulong), out var buffer, out var offset);
|
|
slice.Clear();
|
|
for (var j = 0; j < slots.Count; j++)
|
|
{
|
|
var slot = slots[j];
|
|
if (slot < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var sampler = GetOrCreateSampler(device, textures[imageBase + j].Sampler);
|
|
var resourceId = MetalNative.SendGpuResourceId(sampler, MetalNative.Selector("gpuResourceID"));
|
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt64LittleEndian(
|
|
slice[(slot * sizeof(ulong))..], resourceId);
|
|
}
|
|
|
|
MetalNative.SendSetBuffer(encoder, selSetBuffer, buffer, (nuint)offset, (nuint)argIndex);
|
|
}
|
|
|
|
/// <summary>Resolves every texture a draw samples, encoding any snapshot
|
|
/// blits into <paramref name="blitCommandBuffer"/>; must run before the
|
|
/// consuming encoder opens on that command buffer.</summary>
|
|
private static void ResolveDrawTextures(
|
|
nint device,
|
|
nint blitCommandBuffer,
|
|
GuestDrawTexture[] textures,
|
|
Span<nint> handles,
|
|
Span<bool> owned)
|
|
{
|
|
for (var index = 0; index < textures.Length; index++)
|
|
{
|
|
handles[index] = CreateDrawTexture(
|
|
device, blitCommandBuffer, textures[index], out var ownedTexture);
|
|
owned[index] = ownedTexture;
|
|
}
|
|
}
|
|
|
|
/// <summary>Binds everything the translation contract names: global buffers
|
|
/// and SharpEmuUniforms to both stages, the pre-resolved textures/samplers
|
|
/// to both stages, vertex streams at the high slots. Collects the writable
|
|
/// buffers for guest write-back.</summary>
|
|
private static void EncodeDrawBindings(
|
|
nint device,
|
|
nint encoder,
|
|
OffscreenGuestDraw work,
|
|
ReadOnlySpan<nint> textureHandles,
|
|
ReadOnlySpan<bool> textureOwned,
|
|
out List<(nint Pointer, GuestMemoryBuffer Guest)> writeBackBuffers)
|
|
{
|
|
var draw = work.Draw;
|
|
writeBackBuffers = [];
|
|
|
|
var selSetVertexBuffer = MetalNative.Selector("setVertexBuffer:offset:atIndex:");
|
|
var selSetFragmentBuffer = MetalNative.Selector("setFragmentBuffer:offset:atIndex:");
|
|
var bufferCount = draw.GlobalMemoryBuffers.Length;
|
|
Span<uint> boundBytes = stackalloc uint[Math.Max(bufferCount, 1)];
|
|
for (var index = 0; index < bufferCount; index++)
|
|
{
|
|
var guest = draw.GlobalMemoryBuffers[index];
|
|
var pointer = UploadGlobalBuffer(
|
|
device, guest, out var buffer, out var offset, out boundBytes[index]);
|
|
MetalNative.SendSetBuffer(encoder, selSetVertexBuffer, buffer, (nuint)offset, (nuint)index);
|
|
MetalNative.SendSetBuffer(encoder, selSetFragmentBuffer, buffer, (nuint)offset, (nuint)index);
|
|
if (guest.Writable && guest.WriteBackToGuest)
|
|
{
|
|
writeBackBuffers.Add((pointer, guest));
|
|
}
|
|
}
|
|
|
|
// SharpEmuUniforms per the translation contract: dispatch limit (unused by
|
|
// graphics stages), reserved, then each bound buffer's byte length
|
|
// (including the alignment-bias prefix the shader indexes past).
|
|
var uniforms = AllocateUpload(
|
|
device,
|
|
16 + (Math.Max(bufferCount, 1) * sizeof(uint)),
|
|
out var uniformsBuffer,
|
|
out var uniformsOffset);
|
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms, 1);
|
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[4..], 1);
|
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[8..], 1);
|
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[12..], 0);
|
|
for (var index = 0; index < bufferCount; index++)
|
|
{
|
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
|
|
uniforms[(16 + (index * sizeof(uint)))..],
|
|
boundBytes[index]);
|
|
}
|
|
|
|
// Each stage declares SharpEmuUniforms at its own translation-time index
|
|
// (globalBufferBase + totalGlobalBufferCount). A draw whose vertex-stage
|
|
// guest buffers sit after the pixel stage's gives the two stages
|
|
// different indices, so bind the buffer at each stage's declared slot —
|
|
// one shared index leaves the other stage's uniforms unbound, which
|
|
// zeroes its bounds-checked loads (caught by Metal API validation as
|
|
// "missing Buffer binding ... for sharpemu_uniforms").
|
|
var vertexUniformsIndex = draw.VertexShader?.Shader.UniformsBufferIndex ?? -1;
|
|
MetalNative.SendSetBuffer(
|
|
encoder,
|
|
selSetVertexBuffer,
|
|
uniformsBuffer,
|
|
(nuint)uniformsOffset,
|
|
(nuint)(vertexUniformsIndex >= 0 ? vertexUniformsIndex : bufferCount));
|
|
var fragmentUniformsIndex = draw.PixelShader.Shader.UniformsBufferIndex;
|
|
MetalNative.SendSetBuffer(
|
|
encoder,
|
|
selSetFragmentBuffer,
|
|
uniformsBuffer,
|
|
(nuint)uniformsOffset,
|
|
(nuint)(fragmentUniformsIndex >= 0 ? fragmentUniformsIndex : bufferCount));
|
|
|
|
var selSetVertexTexture = MetalNative.Selector("setVertexTexture:atIndex:");
|
|
var selSetFragmentTexture = MetalNative.Selector("setFragmentTexture:atIndex:");
|
|
// Texture slots are global across the draw's stages ([0, vertexImageBase)
|
|
// is the pixel stage's block), so textures bind to both stage tables at
|
|
// their global index.
|
|
for (var index = 0; index < draw.Textures.Length; index++)
|
|
{
|
|
var texture = textureHandles[index];
|
|
if (texture != 0)
|
|
{
|
|
MetalNative.SendSetAtIndex(encoder, selSetVertexTexture, texture, (nuint)index);
|
|
MetalNative.SendSetAtIndex(encoder, selSetFragmentTexture, texture, (nuint)index);
|
|
if (textureOwned[index])
|
|
{
|
|
MetalNative.SendVoid(texture, MetalNative.Selector("release"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Samplers travel in a per-stage argument buffer (Metal caps direct
|
|
// sampler slots at 16 per stage, but shaders sample more), one entry
|
|
// per sampled image.
|
|
BindSamplerArgumentBuffer(device, encoder, selSetFragmentBuffer, draw.PixelShader, draw.Textures);
|
|
if (draw.VertexShader is { } vertexShader)
|
|
{
|
|
BindSamplerArgumentBuffer(device, encoder, selSetVertexBuffer, vertexShader, draw.Textures);
|
|
}
|
|
|
|
Span<nuint> vertexSlots = stackalloc nuint[draw.VertexBuffers.Length];
|
|
_ = TryAssignVertexBufferSlots(draw.VertexBuffers, vertexSlots);
|
|
for (var index = 0; index < draw.VertexBuffers.Length; index++)
|
|
{
|
|
// Streams sharing a slot read the same guest bytes; the first
|
|
// occurrence uploads and binds them once.
|
|
var duplicate = false;
|
|
for (var prior = 0; prior < index; prior++)
|
|
{
|
|
if (vertexSlots[prior] == vertexSlots[index])
|
|
{
|
|
duplicate = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (duplicate)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var vertexBuffer = draw.VertexBuffers[index];
|
|
var length = Math.Max(vertexBuffer.Length, 1);
|
|
var slice = AllocateUpload(device, length, out var buffer, out var offset);
|
|
vertexBuffer.Data.AsSpan(0, Math.Min(vertexBuffer.Length, vertexBuffer.Data.Length))
|
|
.CopyTo(slice);
|
|
// The attribute's byte offset (set in the vertex descriptor)
|
|
// selects the field inside the interleaved vertex; the bind
|
|
// offset is just the arena slice.
|
|
MetalNative.SendSetBuffer(
|
|
encoder, selSetVertexBuffer, buffer, (nuint)offset, vertexSlots[index]);
|
|
}
|
|
}
|
|
|
|
private static void EncodeDrawCall(nint encoder, TranslatedGuestDraw draw)
|
|
{
|
|
var primitive = GetPrimitiveType(draw.PrimitiveType);
|
|
var vertexCount = draw.PrimitiveType == 0x11 && draw.IndexBuffer is null
|
|
? 4u
|
|
: draw.VertexCount;
|
|
if (draw.IndexBuffer is { } indexBuffer)
|
|
{
|
|
var device = MetalNative.Send(encoder, MetalNative.Selector("device"));
|
|
var slice = AllocateUpload(
|
|
device, Math.Max(indexBuffer.Length, 1), out var buffer, out var offset);
|
|
indexBuffer.Data.AsSpan(0, Math.Min(indexBuffer.Length, indexBuffer.Data.Length))
|
|
.CopyTo(slice);
|
|
MetalNative.SendDrawIndexedPrimitives(
|
|
encoder,
|
|
MetalNative.Selector("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"),
|
|
primitive,
|
|
vertexCount,
|
|
indexBuffer.Is32Bit ? 1u : 0u,
|
|
buffer,
|
|
(nuint)offset,
|
|
Math.Max(draw.InstanceCount, 1));
|
|
if (indexBuffer.Pooled)
|
|
{
|
|
GuestDataPool.Shared.Return(indexBuffer.Data);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
MetalNative.SendDrawPrimitivesInstanced(
|
|
encoder,
|
|
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:instanceCount:"),
|
|
primitive,
|
|
0,
|
|
vertexCount,
|
|
Math.Max(draw.InstanceCount, 1));
|
|
}
|
|
}
|
|
|
|
private static bool TryGetDrawPipeline(
|
|
nint device,
|
|
TranslatedGuestDraw draw,
|
|
MetalRenderTargetFormat[] targetFormats,
|
|
bool hasDepth,
|
|
out nint pipeline)
|
|
{
|
|
var stateHash = 14695981039346656037UL;
|
|
void Mix(ulong value)
|
|
{
|
|
stateHash = (stateHash ^ value) * 1099511628211UL;
|
|
}
|
|
|
|
for (var index = 0; index < targetFormats.Length; index++)
|
|
{
|
|
Mix((ulong)targetFormats[index].Format);
|
|
var blend = draw.RenderState.Blends[index];
|
|
Mix(blend.Enable ? 1UL : 0UL);
|
|
Mix(blend.ColorSrcFactor | ((ulong)blend.ColorDstFactor << 8) | ((ulong)blend.ColorFunc << 16));
|
|
Mix(blend.AlphaSrcFactor | ((ulong)blend.AlphaDstFactor << 8) | ((ulong)blend.AlphaFunc << 16));
|
|
Mix(blend.SeparateAlphaBlend ? 1UL : 0UL);
|
|
Mix(blend.WriteMask);
|
|
}
|
|
|
|
Mix(hasDepth ? 2UL : 1UL);
|
|
Span<nuint> vertexSlots = stackalloc nuint[draw.VertexBuffers.Length];
|
|
if (!TryAssignVertexBufferSlots(draw.VertexBuffers, vertexSlots))
|
|
{
|
|
if (_vertexSlotOverflowTraces < 16)
|
|
{
|
|
_vertexSlotOverflowTraces++;
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] Metal skipped draw: " +
|
|
$"{draw.VertexBuffers.Length} vertex streams need more than " +
|
|
$"{MaxVertexStageBufferIndex - VertexBufferSlotBase + 1} unique buffer slots.");
|
|
}
|
|
|
|
pipeline = 0;
|
|
return false;
|
|
}
|
|
|
|
for (var index = 0; index < draw.VertexBuffers.Length; index++)
|
|
{
|
|
var vertexBuffer = draw.VertexBuffers[index];
|
|
Mix(vertexBuffer.Location |
|
|
((ulong)vertexBuffer.ComponentCount << 8) |
|
|
((ulong)vertexBuffer.DataFormat << 16) |
|
|
((ulong)vertexBuffer.NumberFormat << 26) |
|
|
((ulong)vertexBuffer.Stride << 34));
|
|
// The attribute byte offset and the assigned slot are baked into
|
|
// the pipeline's vertex descriptor, so both must key the cache
|
|
// (the slot captures which streams alias one guest buffer).
|
|
Mix(vertexBuffer.OffsetBytes | ((ulong)vertexSlots[index] << 32));
|
|
}
|
|
|
|
var key = new PipelineKey(draw.VertexShader, draw.PixelShader, stateHash);
|
|
lock (_pipelineCache)
|
|
{
|
|
if (_pipelineCache.TryGetValue(key, out pipeline))
|
|
{
|
|
return pipeline != 0;
|
|
}
|
|
}
|
|
|
|
pipeline = CreateDrawPipeline(device, draw, targetFormats, hasDepth);
|
|
lock (_pipelineCache)
|
|
{
|
|
_pipelineCache[key] = pipeline;
|
|
}
|
|
|
|
return pipeline != 0;
|
|
}
|
|
|
|
private static nint CreateDrawPipeline(
|
|
nint device,
|
|
TranslatedGuestDraw draw,
|
|
MetalRenderTargetFormat[] targetFormats,
|
|
bool hasDepth)
|
|
{
|
|
var vertexFunction = draw.VertexShader is { } vertexShader
|
|
? GetShaderFunction(device, vertexShader)
|
|
: GetFixedFullscreenVertexFunction(device, draw.AttributeCount);
|
|
var fragmentFunction = GetShaderFunction(device, draw.PixelShader);
|
|
if (vertexFunction == 0 || fragmentFunction == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var descriptor = MetalNative.Send(
|
|
MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")),
|
|
MetalNative.Selector("init"));
|
|
MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction);
|
|
MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction);
|
|
|
|
var colorAttachments = MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments"));
|
|
for (var index = 0; index < targetFormats.Length; index++)
|
|
{
|
|
var attachment = MetalNative.SendAtIndex(
|
|
colorAttachments, MetalNative.Selector("objectAtIndexedSubscript:"), (nuint)index);
|
|
MetalNative.Send(
|
|
attachment, MetalNative.Selector("setPixelFormat:"), (nint)targetFormats[index].Format);
|
|
var blend = draw.RenderState.Blends[index];
|
|
MetalNative.Send(
|
|
attachment,
|
|
MetalNative.Selector("setWriteMask:"),
|
|
(nint)ToMetalWriteMask(blend.WriteMask));
|
|
if (blend.Enable && !IsIntegerFormat(targetFormats[index].OutputKind))
|
|
{
|
|
MetalNative.SendVoidBool(attachment, MetalNative.Selector("setBlendingEnabled:"), true);
|
|
MetalNative.Send(
|
|
attachment,
|
|
MetalNative.Selector("setSourceRGBBlendFactor:"),
|
|
(nint)ToMetalBlendFactor(blend.ColorSrcFactor));
|
|
MetalNative.Send(
|
|
attachment,
|
|
MetalNative.Selector("setDestinationRGBBlendFactor:"),
|
|
(nint)ToMetalBlendFactor(blend.ColorDstFactor));
|
|
MetalNative.Send(
|
|
attachment,
|
|
MetalNative.Selector("setRgbBlendOperation:"),
|
|
(nint)ToMetalBlendOperation(blend.ColorFunc));
|
|
var alphaSrc = blend.SeparateAlphaBlend ? blend.AlphaSrcFactor : blend.ColorSrcFactor;
|
|
var alphaDst = blend.SeparateAlphaBlend ? blend.AlphaDstFactor : blend.ColorDstFactor;
|
|
var alphaFunc = blend.SeparateAlphaBlend ? blend.AlphaFunc : blend.ColorFunc;
|
|
MetalNative.Send(
|
|
attachment,
|
|
MetalNative.Selector("setSourceAlphaBlendFactor:"),
|
|
(nint)ToMetalBlendFactor(alphaSrc));
|
|
MetalNative.Send(
|
|
attachment,
|
|
MetalNative.Selector("setDestinationAlphaBlendFactor:"),
|
|
(nint)ToMetalBlendFactor(alphaDst));
|
|
MetalNative.Send(
|
|
attachment,
|
|
MetalNative.Selector("setAlphaBlendOperation:"),
|
|
(nint)ToMetalBlendOperation(alphaFunc));
|
|
}
|
|
}
|
|
|
|
if (hasDepth)
|
|
{
|
|
MetalNative.Send(
|
|
descriptor,
|
|
MetalNative.Selector("setDepthAttachmentPixelFormat:"),
|
|
(nint)MtlPixelFormat.Depth32Float);
|
|
}
|
|
|
|
if (draw.VertexShader is not null && draw.VertexBuffers.Length > 0)
|
|
{
|
|
MetalNative.SendVoid(
|
|
descriptor,
|
|
MetalNative.Selector("setVertexDescriptor:"),
|
|
CreateVertexDescriptor(draw.VertexBuffers));
|
|
}
|
|
|
|
nint error = 0;
|
|
var pipeline = MetalNative.Send(
|
|
device,
|
|
MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"),
|
|
descriptor,
|
|
ref error);
|
|
if (pipeline == 0)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Metal draw pipeline creation failed: {MetalNative.DescribeError(error)}");
|
|
}
|
|
|
|
Interlocked.Increment(ref _perfPipelineCreations);
|
|
return pipeline;
|
|
}
|
|
|
|
private static nint CreateVertexDescriptor(GuestVertexBuffer[] vertexBuffers)
|
|
{
|
|
var descriptor = MetalNative.Send(
|
|
MetalNative.Class("MTLVertexDescriptor"), MetalNative.Selector("vertexDescriptor"));
|
|
var attributes = MetalNative.Send(descriptor, MetalNative.Selector("attributes"));
|
|
var layouts = MetalNative.Send(descriptor, MetalNative.Selector("layouts"));
|
|
var selAt = MetalNative.Selector("objectAtIndexedSubscript:");
|
|
Span<nuint> slots = stackalloc nuint[vertexBuffers.Length];
|
|
_ = TryAssignVertexBufferSlots(vertexBuffers, slots);
|
|
for (var index = 0; index < vertexBuffers.Length; index++)
|
|
{
|
|
var vertexBuffer = vertexBuffers[index];
|
|
var slot = slots[index];
|
|
var attribute = MetalNative.SendAtIndex(attributes, selAt, vertexBuffer.Location);
|
|
MetalNative.Send(
|
|
attribute,
|
|
MetalNative.Selector("setFormat:"),
|
|
(nint)ToMetalVertexFormat(
|
|
vertexBuffer.DataFormat, vertexBuffer.NumberFormat, vertexBuffer.ComponentCount));
|
|
// The guest byte offset is the attribute's position inside the
|
|
// interleaved vertex; carry it on the attribute (buffer bound at 0)
|
|
// rather than the buffer bind offset. Metal fetches a fixed
|
|
// (bind-offset + attribute-offset + index*stride), so the two are
|
|
// arithmetically equal, but a non-zero per-buffer bind offset here
|
|
// fetched zero on this path — keeping the attribute offset is the
|
|
// layout Metal's vertex-descriptor path expects.
|
|
var attributeOffset = vertexBuffer.OffsetBytes < (uint)vertexBuffer.Length
|
|
? vertexBuffer.OffsetBytes
|
|
: 0;
|
|
MetalNative.Send(attribute, MetalNative.Selector("setOffset:"), (nint)attributeOffset);
|
|
MetalNative.Send(attribute, MetalNative.Selector("setBufferIndex:"), (nint)slot);
|
|
|
|
var layout = MetalNative.SendAtIndex(layouts, selAt, slot);
|
|
var stride = vertexBuffer.Stride != 0
|
|
? vertexBuffer.Stride
|
|
: Math.Max(vertexBuffer.ComponentCount, 1) * 4;
|
|
MetalNative.Send(layout, MetalNative.Selector("setStride:"), (nint)stride);
|
|
// MTLVertexStepFunction.PerVertex = 1.
|
|
MetalNative.Send(layout, MetalNative.Selector("setStepFunction:"), 1);
|
|
}
|
|
|
|
return descriptor;
|
|
}
|
|
|
|
private static nint GetShaderFunction(nint device, MetalCompiledGuestShader shader)
|
|
{
|
|
if (shader.CachedLibrary == 0)
|
|
{
|
|
if (!TryCompileLibrary(device, shader.Shader.Source, out var library, out var error))
|
|
{
|
|
Console.Error.WriteLine($"[LOADER][WARN] Metal shader compile failed: {error}");
|
|
return 0;
|
|
}
|
|
|
|
shader.CachedLibrary = library;
|
|
}
|
|
|
|
return MetalNative.Send(
|
|
shader.CachedLibrary,
|
|
MetalNative.Selector("newFunctionWithName:"),
|
|
MetalNative.NsString(shader.Shader.EntryPoint));
|
|
}
|
|
|
|
private static readonly Dictionary<uint, nint> _fixedVertexLibraries = new();
|
|
private static nint _fixedDrawPipeline;
|
|
|
|
private static nint GetFixedFullscreenVertexFunction(nint device, uint attributeCount)
|
|
{
|
|
nint library;
|
|
lock (_fixedVertexLibraries)
|
|
{
|
|
_fixedVertexLibraries.TryGetValue(attributeCount, out library);
|
|
}
|
|
|
|
if (library == 0)
|
|
{
|
|
if (!TryCompileLibrary(
|
|
device, MslFixedShaders.CreateFullscreenVertex(attributeCount), out library, out var error))
|
|
{
|
|
Console.Error.WriteLine($"[LOADER][WARN] Metal fullscreen vertex compile failed: {error}");
|
|
return 0;
|
|
}
|
|
|
|
lock (_fixedVertexLibraries)
|
|
{
|
|
_fixedVertexLibraries[attributeCount] = library;
|
|
}
|
|
}
|
|
|
|
return MetalNative.Send(
|
|
library, MetalNative.Selector("newFunctionWithName:"), MetalNative.NsString("fullscreen_vs"));
|
|
}
|
|
|
|
private static bool TryGetFixedDrawPipeline(nint device, out nint pipeline)
|
|
{
|
|
if (_fixedDrawPipeline != 0)
|
|
{
|
|
pipeline = _fixedDrawPipeline;
|
|
return true;
|
|
}
|
|
|
|
pipeline = 0;
|
|
var vertexFunction = GetFixedFullscreenVertexFunction(device, 1);
|
|
if (vertexFunction == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!TryCompileLibrary(device, MslFixedShaders.CreateAttributeFragment(0), out var library, out var error))
|
|
{
|
|
Console.Error.WriteLine($"[LOADER][WARN] Metal fixed draw pipeline unavailable: {error}");
|
|
return false;
|
|
}
|
|
|
|
var fragmentFunction = MetalNative.Send(
|
|
library, MetalNative.Selector("newFunctionWithName:"), MetalNative.NsString("attribute_fs"));
|
|
var descriptor = MetalNative.Send(
|
|
MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")),
|
|
MetalNative.Selector("init"));
|
|
MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction);
|
|
MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction);
|
|
var attachment = MetalNative.SendAtIndex(
|
|
MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments")),
|
|
MetalNative.Selector("objectAtIndexedSubscript:"),
|
|
0);
|
|
MetalNative.Send(attachment, MetalNative.Selector("setPixelFormat:"), (nint)MtlPixelFormat.Bgra8Unorm);
|
|
nint pipelineError = 0;
|
|
pipeline = MetalNative.Send(
|
|
device,
|
|
MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"),
|
|
descriptor,
|
|
ref pipelineError);
|
|
_fixedDrawPipeline = pipeline;
|
|
_ = error;
|
|
return pipeline != 0;
|
|
}
|
|
|
|
private static GuestImage? EnsureGuestRenderTarget(
|
|
nint device,
|
|
GuestRenderTarget target,
|
|
MtlPixelFormat format)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_guestImages.TryGetValue(target.Address, out var existing) &&
|
|
existing.Width == target.Width &&
|
|
existing.Height == target.Height)
|
|
{
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
if (target.Width == 0 || target.Height == 0 || target.Width > 16384 || target.Height > 16384)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var image = new GuestImage
|
|
{
|
|
Texture = CreateGuestTexture(device, format, target.Width, target.Height),
|
|
Width = target.Width,
|
|
Height = target.Height,
|
|
Format = format,
|
|
};
|
|
if (image.Texture == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
byte[]? initialData;
|
|
lock (_gate)
|
|
{
|
|
_pendingGuestImageInitialData.Remove(target.Address, out initialData);
|
|
if (_guestImages.TryGetValue(target.Address, out var replaced))
|
|
{
|
|
RetireGuestImageVariantLocked(target.Address, replaced);
|
|
}
|
|
|
|
_guestImages[target.Address] = image;
|
|
_guestImageExtents[target.Address] = (target.Width, target.Height, (ulong)target.Width * target.Height * 4);
|
|
}
|
|
|
|
// Pending initial data is RGBA8; only 4-byte-texel targets take it verbatim.
|
|
if (initialData is not null &&
|
|
MetalRenderTargetFormat.GetBytesPerPixel(format) == 4 &&
|
|
(ulong)initialData.Length >= (ulong)target.Width * target.Height * 4)
|
|
{
|
|
ReplaceTextureContents(
|
|
image.Texture, target.Width, target.Height, initialData, target.Width, bytesPerPixel: 4);
|
|
// A guest-memory seed initializes content without marking it
|
|
// GPU-produced; the version still moves so snapshots refresh.
|
|
image.Initialized = true;
|
|
image.ContentVersion++;
|
|
}
|
|
|
|
return image;
|
|
}
|
|
|
|
private static void RetireGuestImageVariantLocked(ulong address, GuestImage retired)
|
|
{
|
|
var key = (address, retired.Width, retired.Height, retired.Format);
|
|
if (_guestImageVariants.Remove(key, out var previous))
|
|
{
|
|
previous.ReleaseSnapshot();
|
|
MetalNative.SendVoid(previous.Texture, MetalNative.Selector("release"));
|
|
}
|
|
else
|
|
{
|
|
while (_guestImageVariantOrder.Count >= MaxGuestImageVariants)
|
|
{
|
|
var evicted = _guestImageVariantOrder.Dequeue();
|
|
if (_guestImageVariants.Remove(evicted, out var old))
|
|
{
|
|
old.ReleaseSnapshot();
|
|
MetalNative.SendVoid(old.Texture, MetalNative.Selector("release"));
|
|
}
|
|
}
|
|
|
|
_guestImageVariantOrder.Enqueue(key);
|
|
}
|
|
|
|
_guestImageVariants[key] = retired;
|
|
}
|
|
|
|
private static GuestImage EnsureGuestDepthImage(
|
|
nint device,
|
|
GuestDepthTarget target,
|
|
uint width,
|
|
uint height)
|
|
{
|
|
var address = target.Address;
|
|
lock (_gate)
|
|
{
|
|
if (target.ReadAddress != 0 && target.ReadAddress != address)
|
|
{
|
|
_guestDepthReadAliases[target.ReadAddress] = address;
|
|
}
|
|
|
|
if (_guestDepthImages.TryGetValue(address, out var existing) &&
|
|
existing.Width == width &&
|
|
existing.Height == height)
|
|
{
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
var descriptor = MetalNative.SendTextureDescriptor(
|
|
MetalNative.Class("MTLTextureDescriptor"),
|
|
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
|
|
(nuint)MtlPixelFormat.Depth32Float,
|
|
width,
|
|
height,
|
|
mipmapped: false);
|
|
MetalNative.Send(
|
|
descriptor, MetalNative.Selector("setUsage:"), (nint)(UsageRenderTarget | UsageShaderRead));
|
|
// MTLStorageMode.Private = 2: depth never round-trips to the CPU.
|
|
MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), 2);
|
|
var image = new GuestImage
|
|
{
|
|
Texture = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor),
|
|
Width = width,
|
|
Height = height,
|
|
Format = MtlPixelFormat.Depth32Float,
|
|
};
|
|
lock (_gate)
|
|
{
|
|
if (_guestDepthImages.Remove(address, out var replaced))
|
|
{
|
|
replaced.ReleaseSnapshot();
|
|
MetalNative.SendVoid(replaced.Texture, MetalNative.Selector("release"));
|
|
}
|
|
|
|
_guestDepthImages[address] = image;
|
|
}
|
|
|
|
return image;
|
|
}
|
|
|
|
private static nint GetTransientTarget(nint device, MtlPixelFormat format, uint width, uint height)
|
|
{
|
|
var key = (format, width, height);
|
|
lock (_transientTargets)
|
|
{
|
|
if (_transientTargets.TryGetValue(key, out var existing))
|
|
{
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
var texture = CreateGuestTexture(device, format, width, height);
|
|
lock (_transientTargets)
|
|
{
|
|
_transientTargets[key] = texture;
|
|
}
|
|
|
|
return texture;
|
|
}
|
|
|
|
private static int _missingTextureTraces;
|
|
|
|
/// <summary>Resolves one draw texture. Snapshot copies for feedback reads
|
|
/// are encoded into <paramref name="blitCommandBuffer"/>, so this must be
|
|
/// called before the consuming render or compute encoder opens on that
|
|
/// same command buffer — encoder order is what keeps the snapshot after
|
|
/// earlier batched passes that render to the source image.</summary>
|
|
private static nint CreateDrawTexture(
|
|
nint device,
|
|
nint blitCommandBuffer,
|
|
GuestDrawTexture texture,
|
|
out bool ownedByCaller)
|
|
{
|
|
ownedByCaller = true;
|
|
if (texture.Width == 0 || texture.Height == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var cacheable = IsCacheableDrawTexture(texture);
|
|
|
|
// Feedback reads of a live guest render target sample an ordered
|
|
// snapshot, resolved like the Vulkan presenter: a guest depth image
|
|
// first (shadow-style depth sampling), then the current image or a
|
|
// retired variant at the same address, scored by descriptor match.
|
|
if (texture.RgbaPixels.Length == 0 && texture.Address != 0)
|
|
{
|
|
if (TryCreateDepthSampleTexture(device, blitCommandBuffer, texture, out var depthSample))
|
|
{
|
|
ownedByCaller = false;
|
|
return depthSample;
|
|
}
|
|
|
|
var live = ResolveGuestImageAlias(texture);
|
|
if (live is { Initialized: true } && blitCommandBuffer != 0)
|
|
{
|
|
// One snapshot per content version: draws sampling the same
|
|
// unchanged image share it, so the blit happens per content
|
|
// change instead of per draw — compositing games otherwise
|
|
// copy a full render target for every draw. The image holds
|
|
// the retain; consuming command buffers keep replaced
|
|
// snapshots alive until they complete.
|
|
if (live.SnapshotTexture != 0 && live.SnapshotVersion == live.ContentVersion)
|
|
{
|
|
ownedByCaller = false;
|
|
return live.SnapshotTexture;
|
|
}
|
|
|
|
var snapshot = CreateGuestTexture(device, live.Format, live.Width, live.Height);
|
|
if (snapshot != 0)
|
|
{
|
|
EncodeCopyTexture(blitCommandBuffer, live.Texture, snapshot);
|
|
live.ReleaseSnapshot();
|
|
live.SnapshotTexture = snapshot;
|
|
live.SnapshotVersion = live.ContentVersion;
|
|
ownedByCaller = false;
|
|
return snapshot;
|
|
}
|
|
}
|
|
|
|
// Empty texels can also mean the submit thread skipped the
|
|
// guest-memory copy because this identity is cached here.
|
|
if (cacheable && TryGetCachedDrawTexture(texture, out var cachedSkip))
|
|
{
|
|
ownedByCaller = false;
|
|
return cachedSkip;
|
|
}
|
|
|
|
// A miss on skipped texels is an invalidation race (the entry
|
|
// was evicted after the submit thread checked). Self-heal by
|
|
// reading the texels directly rather than rendering a fallback.
|
|
var refreshed = TryReadGuestDrawTexturePixels(texture);
|
|
if (refreshed is null)
|
|
{
|
|
if (_missingTextureTraces < 16)
|
|
{
|
|
_missingTextureTraces++;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Metal draw texture unresolved: live 0x{texture.Address:X} " +
|
|
$"{texture.Width}x{texture.Height} found={live is not null} " +
|
|
$"init={live?.Initialized ?? false}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
texture = texture with { RgbaPixels = refreshed };
|
|
}
|
|
else if (cacheable && TryGetCachedDrawTexture(texture, out var cached))
|
|
{
|
|
// Fresh texels for an identity already cached: the content is
|
|
// unchanged (a guest write would have evicted the entry at drain
|
|
// start), so skip the redundant texture creation and upload.
|
|
ownedByCaller = false;
|
|
return cached;
|
|
}
|
|
|
|
// Default-on GPU detile packages the tiled source + resolved DetileParams
|
|
// with empty RgbaPixels so a backend can deswizzle on the GPU. The Metal
|
|
// GPU compute pass (MetalDetilePass / detile_compute.msl) is the intended
|
|
// equivalent of VulkanDetilePass, but it is Mac-untested, so the active
|
|
// Metal path CPU-detiles here via GnmTiling.DetileWithParams — the exact
|
|
// same DetileParams addressing the kernel runs — restoring the linear-upload
|
|
// behavior Metal had before GPU detile existed, with no regression.
|
|
if (texture.RgbaPixels.Length == 0 &&
|
|
texture.TiledSource is { } tiledSource &&
|
|
texture.Detile is { } detileParameters)
|
|
{
|
|
// The tiled source packs the array slices contiguously (one per layer);
|
|
// detile each into its layer-major linear region so the reconstructed
|
|
// pixels match what the CPU array-upload path produced pre-GPU-detile.
|
|
// A plain 2D texture is just one layer.
|
|
var layers = Math.Max((int)texture.ArrayLayers, 1);
|
|
var sliceLinearBytes =
|
|
detileParameters.ElementsWide * detileParameters.ElementsHigh * detileParameters.BytesPerElement;
|
|
var sliceTiledBytes = tiledSource.Length / layers;
|
|
var linear = new byte[sliceLinearBytes * layers];
|
|
var detiledAll = true;
|
|
for (var layer = 0; layer < layers; layer++)
|
|
{
|
|
if (!GnmTiling.DetileWithParams(
|
|
detileParameters,
|
|
tiledSource.AsSpan(layer * sliceTiledBytes, sliceTiledBytes),
|
|
linear.AsSpan(layer * sliceLinearBytes, sliceLinearBytes)))
|
|
{
|
|
detiledAll = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (detiledAll)
|
|
{
|
|
texture = texture with { RgbaPixels = linear };
|
|
}
|
|
}
|
|
|
|
// AGC ships the raw (detiled) source texels; create the texture in the
|
|
// guest's native format — Mac-family GPUs sample BC blocks directly —
|
|
// and size expectations with the same block-aware math AGC used.
|
|
var textureFormat = MetalGuestFormats.DecodeTextureFormat(texture.Format, texture.NumberType);
|
|
var pitch = texture.Pitch != 0 ? Math.Max(texture.Pitch, texture.Width) : texture.Width;
|
|
var expectedBytes = MetalGuestFormats.GetTextureByteCount(textureFormat, pitch, texture.Height);
|
|
if ((ulong)texture.RgbaPixels.Length < expectedBytes)
|
|
{
|
|
if (_missingTextureTraces < 16)
|
|
{
|
|
_missingTextureTraces++;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Metal draw texture undersized: 0x{texture.Address:X} " +
|
|
$"{texture.Width}x{texture.Height} pitch={texture.Pitch} " +
|
|
$"fmt={texture.Format}/{texture.NumberType} " +
|
|
$"bytes={texture.RgbaPixels.Length} expected={expectedBytes}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
var descriptor = MetalNative.SendTextureDescriptor(
|
|
MetalNative.Class("MTLTextureDescriptor"),
|
|
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
|
|
(nuint)textureFormat.Format,
|
|
texture.Width,
|
|
texture.Height,
|
|
mipmapped: false);
|
|
// MTLStorageModeShared (0): CPU-uploaded (replaceRegion) + GPU-sampled;
|
|
// the Managed default reads stale on unified memory (see CreateGuestTexture).
|
|
MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), (nint)0);
|
|
if (texture.IsStorage)
|
|
{
|
|
MetalNative.Send(
|
|
descriptor,
|
|
MetalNative.Selector("setUsage:"),
|
|
(nint)(UsageShaderRead | UsageShaderWrite));
|
|
}
|
|
else if (texture.DstSelect != 0xFAC && texture.DstSelect != 0)
|
|
{
|
|
// Channel select from the guest descriptor, like the Vulkan view's
|
|
// component mapping. Shader-writable textures reject swizzles, so
|
|
// storage stays identity (matching Vulkan, which never swizzles
|
|
// storage views either).
|
|
MetalNative.SendVoidSwizzle(
|
|
descriptor,
|
|
MetalNative.Selector("setSwizzle:"),
|
|
ToMetalSwizzle(texture.DstSelect));
|
|
}
|
|
|
|
var handle = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor);
|
|
if (handle != 0)
|
|
{
|
|
ReplaceDrawTextureContents(handle, texture, pitch, textureFormat);
|
|
if (cacheable)
|
|
{
|
|
CacheDrawTexture(texture, handle);
|
|
}
|
|
}
|
|
|
|
return handle;
|
|
}
|
|
|
|
/// <summary>Uploads a draw texture's source texels: linear formats reuse the
|
|
/// row-clamping helper; block-compressed formats upload whole 4x4 block rows
|
|
/// with the block-row stride replaceRegion expects.</summary>
|
|
private static unsafe void ReplaceDrawTextureContents(
|
|
nint handle,
|
|
GuestDrawTexture texture,
|
|
uint pitch,
|
|
in MetalTextureFormat format)
|
|
{
|
|
if (!format.IsBlockCompressed)
|
|
{
|
|
ReplaceTextureContents(
|
|
handle, texture.Width, texture.Height, texture.RgbaPixels, pitch, format.BytesPerPixel);
|
|
return;
|
|
}
|
|
|
|
var blocksWide = ((ulong)Math.Max(pitch, texture.Width) + 3) / 4;
|
|
var bytesPerBlockRow = blocksWide * format.BlockBytes;
|
|
if (bytesPerBlockRow == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var blockRows = Math.Min(
|
|
((ulong)texture.Height + 3) / 4,
|
|
(ulong)texture.RgbaPixels.Length / bytesPerBlockRow);
|
|
if (blockRows == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var texelRows = Math.Min(texture.Height, (uint)(blockRows * 4));
|
|
fixed (byte* source = texture.RgbaPixels)
|
|
{
|
|
MetalNative.SendReplaceRegion(
|
|
handle,
|
|
MetalNative.Selector("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"),
|
|
new MtlRegion { X = 0, Y = 0, Z = 0, Width = texture.Width, Height = texelRows, Depth = 1 },
|
|
0,
|
|
(nint)source,
|
|
(nuint)bytesPerBlockRow);
|
|
}
|
|
}
|
|
|
|
/// <summary>Guest DST_SEL (3 bits per channel: 0=zero, 1=one, 4..7=RGBA) to
|
|
/// Metal swizzle bytes, mirroring the Vulkan view's component mapping.</summary>
|
|
private static MtlTextureSwizzleChannels ToMetalSwizzle(uint dstSelect) => new()
|
|
{
|
|
Red = ToMetalSwizzleChannel(dstSelect & 0x7, identity: 2),
|
|
Green = ToMetalSwizzleChannel((dstSelect >> 3) & 0x7, identity: 3),
|
|
Blue = ToMetalSwizzleChannel((dstSelect >> 6) & 0x7, identity: 4),
|
|
Alpha = ToMetalSwizzleChannel((dstSelect >> 9) & 0x7, identity: 5),
|
|
};
|
|
|
|
private static byte ToMetalSwizzleChannel(uint selector, byte identity) =>
|
|
selector switch
|
|
{
|
|
0 => 0,
|
|
1 => 1,
|
|
4 => 2,
|
|
5 => 3,
|
|
6 => 4,
|
|
7 => 5,
|
|
_ => identity,
|
|
};
|
|
|
|
/// <summary>Resolves a live guest texture to the current image or a retired
|
|
/// same-address variant, scored by descriptor match like the Vulkan
|
|
/// presenter's guest-image variants: exact extent outranks format, format
|
|
/// outranks initialization, and the active image breaks ties.</summary>
|
|
private static GuestImage? ResolveGuestImageAlias(GuestDrawTexture texture)
|
|
{
|
|
var hasViewFormat = MetalGuestFormats.TryDecodeRenderTargetFormat(
|
|
texture.Format, texture.NumberType, out var viewFormat);
|
|
GuestImage? best = null;
|
|
var bestScore = int.MinValue;
|
|
|
|
void Consider(GuestImage candidate, bool isActive)
|
|
{
|
|
// Exact extent always qualifies; a larger image qualifies only for
|
|
// tiled descriptors, mirroring IsCompatibleGuestImageAlias.
|
|
var sizeMatch = candidate.Width == texture.Width &&
|
|
candidate.Height == texture.Height;
|
|
if (!sizeMatch &&
|
|
(texture.TileMode == 0 ||
|
|
texture.Width == 0 ||
|
|
texture.Height == 0 ||
|
|
texture.Width > candidate.Width ||
|
|
texture.Height > candidate.Height))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var score = 0;
|
|
if (sizeMatch)
|
|
{
|
|
score += 32;
|
|
}
|
|
|
|
if (hasViewFormat && candidate.Format == viewFormat.Format)
|
|
{
|
|
score += 16;
|
|
}
|
|
|
|
if (candidate.Initialized)
|
|
{
|
|
score += 4;
|
|
}
|
|
|
|
if (isActive)
|
|
{
|
|
score += 1;
|
|
}
|
|
|
|
if (score > bestScore)
|
|
{
|
|
best = candidate;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_guestImages.TryGetValue(texture.Address, out var active))
|
|
{
|
|
Consider(active, isActive: true);
|
|
}
|
|
|
|
foreach (var (key, candidate) in _guestImageVariants)
|
|
{
|
|
if (key.Address == texture.Address)
|
|
{
|
|
Consider(candidate, isActive: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
/// <summary>Snapshots a guest depth image for sampling when the texture
|
|
/// descriptor names a depth target's write or read address. Depth32Float
|
|
/// cannot blit to a color format, so the copy round-trips through a
|
|
/// buffer into an R32Float texture the translated shader can sample.</summary>
|
|
private static bool TryCreateDepthSampleTexture(
|
|
nint device,
|
|
nint blitCommandBuffer,
|
|
GuestDrawTexture texture,
|
|
out nint sample)
|
|
{
|
|
sample = 0;
|
|
// Identity channel select only; swizzled depth reads keep the
|
|
// unresolved-texture warning until a title needs them.
|
|
if (texture.DstSelect != 0xFAC || blitCommandBuffer == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
GuestImage? depth;
|
|
lock (_gate)
|
|
{
|
|
if (!_guestDepthImages.TryGetValue(texture.Address, out depth) &&
|
|
_guestDepthReadAliases.TryGetValue(texture.Address, out var primary))
|
|
{
|
|
_guestDepthImages.TryGetValue(primary, out depth);
|
|
}
|
|
}
|
|
|
|
if (depth is null ||
|
|
!depth.Initialized ||
|
|
texture.Width > depth.Width ||
|
|
texture.Height > depth.Height)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var bytesPerRow = (nuint)depth.Width * 4;
|
|
var bytesPerImage = bytesPerRow * depth.Height;
|
|
var staging = AcquireSnapshotBuffer(device, bytesPerImage);
|
|
if (staging == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
sample = AcquireSnapshotTexture(
|
|
device,
|
|
MtlPixelFormat.R32Float,
|
|
depth.Width,
|
|
depth.Height,
|
|
(nint)UsageShaderRead);
|
|
if (sample == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var blit = MetalNative.Send(blitCommandBuffer, MetalNative.Selector("blitCommandEncoder"));
|
|
var size = new MtlSize { Width = depth.Width, Height = depth.Height, Depth = 1 };
|
|
MetalNative.SendCopyTextureToBuffer(
|
|
blit,
|
|
MetalNative.Selector(
|
|
"copyFromTexture:sourceSlice:sourceLevel:sourceOrigin:sourceSize:" +
|
|
"toBuffer:destinationOffset:destinationBytesPerRow:destinationBytesPerImage:"),
|
|
depth.Texture,
|
|
0,
|
|
0,
|
|
default,
|
|
size,
|
|
staging,
|
|
0,
|
|
bytesPerRow,
|
|
bytesPerImage);
|
|
MetalNative.SendCopyBufferToTexture(
|
|
blit,
|
|
MetalNative.Selector(
|
|
"copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:" +
|
|
"toTexture:destinationSlice:destinationLevel:destinationOrigin:"),
|
|
staging,
|
|
0,
|
|
bytesPerRow,
|
|
bytesPerImage,
|
|
size,
|
|
sample,
|
|
0,
|
|
0,
|
|
default);
|
|
MetalNative.SendVoid(blit, MetalNative.Selector("endEncoding"));
|
|
return true;
|
|
}
|
|
|
|
private static nint GetOrCreateSampler(nint device, GuestSampler sampler)
|
|
{
|
|
lock (_samplerCache)
|
|
{
|
|
if (_samplerCache.TryGetValue(sampler, out var cached))
|
|
{
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
var descriptor = MetalNative.Send(
|
|
MetalNative.Send(MetalNative.Class("MTLSamplerDescriptor"), MetalNative.Selector("alloc")),
|
|
MetalNative.Selector("init"));
|
|
MetalNative.Send(
|
|
descriptor,
|
|
MetalNative.Selector("setSAddressMode:"),
|
|
(nint)ToMetalAddressMode(sampler.Word0 & 0x7));
|
|
MetalNative.Send(
|
|
descriptor,
|
|
MetalNative.Selector("setTAddressMode:"),
|
|
(nint)ToMetalAddressMode((sampler.Word0 >> 3) & 0x7));
|
|
MetalNative.Send(
|
|
descriptor,
|
|
MetalNative.Selector("setRAddressMode:"),
|
|
(nint)ToMetalAddressMode((sampler.Word0 >> 6) & 0x7));
|
|
var magFilter = (sampler.Word2 >> 20) & 0x3;
|
|
var minFilter = (sampler.Word2 >> 22) & 0x3;
|
|
MetalNative.Send(
|
|
descriptor,
|
|
MetalNative.Selector("setMagFilter:"),
|
|
magFilter is 1 or 3 ? 1 : 0);
|
|
MetalNative.Send(
|
|
descriptor,
|
|
MetalNative.Selector("setMinFilter:"),
|
|
minFilter is 1 or 3 ? 1 : 0);
|
|
|
|
var handle = MetalNative.Send(
|
|
device, MetalNative.Selector("newSamplerStateWithDescriptor:"), descriptor);
|
|
lock (_samplerCache)
|
|
{
|
|
_samplerCache[sampler] = handle;
|
|
}
|
|
|
|
return handle;
|
|
}
|
|
|
|
// A guest global buffer is bound so the shader's alignment bias (the guest
|
|
// base address's low bits below the storage-buffer offset alignment) lands
|
|
// on the real data: the slice holds bias + length bytes with the data at
|
|
// offset bias, matching how the Vulkan backend binds into a larger
|
|
// allocation at an aligned-down descriptor offset. boundBytes is what
|
|
// SharpEmuUniforms must carry so the shader's bounds check passes. The
|
|
// returned pointer addresses the data (past the bias) for write-backs.
|
|
private const ulong StorageBufferOffsetAlignment = 256;
|
|
|
|
private static unsafe nint UploadGlobalBuffer(
|
|
nint device,
|
|
GuestMemoryBuffer guest,
|
|
out nint buffer,
|
|
out int offset,
|
|
out uint boundBytes)
|
|
{
|
|
var bias = (int)((ulong)guest.BaseAddress & (StorageBufferOffsetAlignment - 1));
|
|
var length = Math.Clamp(guest.Length, 0, guest.Data.Length);
|
|
boundBytes = (uint)(bias + length);
|
|
var slice = AllocateUpload(device, bias + Math.Max(length, 1), out buffer, out offset);
|
|
if (bias != 0)
|
|
{
|
|
// Deterministic zeros below the bias, like the padded copy had.
|
|
slice[..bias].Clear();
|
|
}
|
|
|
|
guest.Data.AsSpan(0, length).CopyTo(slice[bias..]);
|
|
fixed (byte* data = slice)
|
|
{
|
|
return (nint)(data + bias);
|
|
}
|
|
}
|
|
|
|
private static void WriteBuffersBackToGuest(
|
|
List<(nint Pointer, GuestMemoryBuffer Guest)> writeBackBuffers)
|
|
{
|
|
var memory = _guestMemory;
|
|
if (memory is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var (pointer, guest) in writeBackBuffers)
|
|
{
|
|
unsafe
|
|
{
|
|
// The pointer addresses the slice's data (past the alignment
|
|
// bias) inside its shared-storage arena page, which stays
|
|
// alive until the command buffer completes — and the caller
|
|
// waited on that before reading.
|
|
_ = memory.TryWrite(
|
|
guest.BaseAddress,
|
|
new ReadOnlySpan<byte>((void*)pointer, guest.Length));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ReturnPooledGuestData(TranslatedGuestDraw draw)
|
|
{
|
|
foreach (var buffer in draw.GlobalMemoryBuffers)
|
|
{
|
|
if (buffer.Pooled)
|
|
{
|
|
GuestDataPool.Shared.Return(buffer.Data);
|
|
}
|
|
}
|
|
|
|
foreach (var vertexBuffer in draw.VertexBuffers)
|
|
{
|
|
if (vertexBuffer.Pooled)
|
|
{
|
|
GuestDataPool.Shared.Return(vertexBuffer.Data);
|
|
}
|
|
}
|
|
}
|
|
|
|
// MTLPrimitiveType: Point=0, Line=1, LineStrip=2, Triangle=3, TriangleStrip=4.
|
|
private static nuint GetPrimitiveType(uint guestPrimitiveType)
|
|
{
|
|
switch (guestPrimitiveType)
|
|
{
|
|
case 1:
|
|
return 0;
|
|
case 2:
|
|
return 1;
|
|
case 3:
|
|
return 2;
|
|
case 5:
|
|
// Metal has no triangle fans; a list is the closest safe shape.
|
|
if (!_tracedTriangleFan)
|
|
{
|
|
_tracedTriangleFan = true;
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] Metal has no triangle-fan primitive; drawing as a list.");
|
|
}
|
|
|
|
return 3;
|
|
case 6:
|
|
case 0x11:
|
|
return 4;
|
|
default:
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
private static bool IsIntegerFormat(Gen5PixelOutputKind kind) =>
|
|
kind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint;
|
|
|
|
// Guest CB write-mask bits are R=1,G=2,B=4,A=8; MTLColorWriteMask reverses them.
|
|
private static nuint ToMetalWriteMask(uint guestMask) =>
|
|
((guestMask & 1) != 0 ? 8u : 0u) |
|
|
((guestMask & 2) != 0 ? 4u : 0u) |
|
|
((guestMask & 4) != 0 ? 2u : 0u) |
|
|
((guestMask & 8) != 0 ? 1u : 0u);
|
|
|
|
// Guest CB_BLEND factor codes to MTLBlendFactor, matching the Vulkan mapping.
|
|
private static nuint ToMetalBlendFactor(uint factor) =>
|
|
factor switch
|
|
{
|
|
0 => 0, // Zero
|
|
1 => 1, // One
|
|
2 => 2, // SourceColor
|
|
3 => 3, // OneMinusSourceColor
|
|
4 => 4, // SourceAlpha
|
|
5 => 5, // OneMinusSourceAlpha
|
|
6 => 8, // DestinationAlpha
|
|
7 => 9, // OneMinusDestinationAlpha
|
|
8 => 6, // DestinationColor
|
|
9 => 7, // OneMinusDestinationColor
|
|
10 => 10, // SourceAlphaSaturated
|
|
13 => 11, // BlendColor
|
|
14 => 12, // OneMinusBlendColor
|
|
15 => 15, // Source1Color
|
|
16 => 16, // OneMinusSource1Color
|
|
17 => 17, // Source1Alpha
|
|
18 => 18, // OneMinusSource1Alpha
|
|
19 => 13, // BlendAlpha
|
|
20 => 14, // OneMinusBlendAlpha
|
|
_ => 1,
|
|
};
|
|
|
|
// Guest COMB_FCN codes to MTLBlendOperation (Add=0, Sub=1, RevSub=2, Min=3, Max=4).
|
|
private static nuint ToMetalBlendOperation(uint function) =>
|
|
function switch
|
|
{
|
|
0 => 0,
|
|
1 => 1,
|
|
2 => 3,
|
|
3 => 4,
|
|
4 => 2,
|
|
_ => 0,
|
|
};
|
|
|
|
// Guest sampler clamp codes to MTLSamplerAddressMode, matching the Vulkan mapping.
|
|
private static nuint ToMetalAddressMode(uint mode) =>
|
|
mode switch
|
|
{
|
|
0 => 2, // Repeat
|
|
1 => 3, // MirrorRepeat
|
|
2 => 0, // ClampToEdge
|
|
3 or 5 or 7 => 1, // MirrorClampToEdge
|
|
4 or 6 => 5, // ClampToBorderColor
|
|
_ => 0,
|
|
};
|
|
|
|
// Guest vertex (dataFormat, numberFormat) codes to MTLVertexFormat raw values,
|
|
// mirroring the Vulkan attribute table; unmapped codes fall back to float{n}.
|
|
private static nuint ToMetalVertexFormat(uint dataFormat, uint numberFormat, uint componentCount)
|
|
{
|
|
var format = (dataFormat, numberFormat) switch
|
|
{
|
|
(1, 0) => 47u, // ucharNormalized
|
|
(1, 1) => 48u, // charNormalized
|
|
(1, 4) => 45u, // uchar
|
|
(1, 5) => 46u, // char
|
|
(2, 0) => 51u, // ushortNormalized
|
|
(2, 1) => 52u, // shortNormalized
|
|
(2, 4) => 49u, // ushort
|
|
(2, 5) => 50u, // short
|
|
(2, 7) => 53u, // half
|
|
(3, 0) => 7u, // uchar2Normalized
|
|
(3, 1) => 10u, // char2Normalized
|
|
(3, 4) => 1u, // uchar2
|
|
(3, 5) => 4u, // char2
|
|
(4, 4) => 36u, // uint
|
|
(4, 5) => 32u, // int
|
|
(4, 7) => 28u, // float
|
|
(5, 0) => 19u, // ushort2Normalized
|
|
(5, 1) => 22u, // short2Normalized
|
|
(5, 4) => 13u, // ushort2
|
|
(5, 5) => 16u, // short2
|
|
(5, 7) => 25u, // half2
|
|
(6, 7) or (7, 7) => 54u, // floatRG11B10
|
|
(8, 0) or (9, 0) => 41u, // uint1010102Normalized (R in bits 0..9)
|
|
(8, 1) or (9, 1) => 40u, // int1010102Normalized
|
|
(10, 0) => 9u, // uchar4Normalized
|
|
(10, 1) => 12u, // char4Normalized
|
|
(10, 4) => 3u, // uchar4
|
|
(10, 5) => 6u, // char4
|
|
(11, 4) => 37u, // uint2
|
|
(11, 5) => 33u, // int2
|
|
(11, 7) => 29u, // float2
|
|
(12, 0) => 21u, // ushort4Normalized
|
|
(12, 1) or (12, 6) => 24u, // short4Normalized
|
|
(12, 4) => 15u, // ushort4
|
|
(12, 5) => 18u, // short4
|
|
(12, 7) => 27u, // half4
|
|
(13, 4) => 38u, // uint3
|
|
(13, 5) => 34u, // int3
|
|
(13, 7) => 30u, // float3
|
|
(14, 4) => 39u, // uint4
|
|
(14, 5) => 35u, // int4
|
|
(14, 7) => 31u, // float4
|
|
(34, 7) => 55u, // floatRGB9E5
|
|
_ => 0u,
|
|
};
|
|
if (format != 0)
|
|
{
|
|
return format;
|
|
}
|
|
|
|
return componentCount switch
|
|
{
|
|
1 => 28,
|
|
2 => 29,
|
|
3 => 30,
|
|
_ => 31,
|
|
};
|
|
}
|
|
}
|