AGC: honour DCC fast clears instead of drawing the clear quad (#738)

On GFX10 a colour clear is not a packet. The driver programs
CB_COLORn_CLEAR_WORD0/1 and draws a covering quad which the colour block
turns into DCC clear codes, discarding whatever the pixel shader
exported. We executed that quad as an ordinary draw, so the shaded output
landed in the surface instead of a clear.

That alone would be a wrong-pixels bug, but the blend those quads use
makes it compound. Every draw into the target blends src=ONE,
dst=ONE_MINUS_SRC_ALPHA, so alpha follows a <- a_src + a_dst*(1 - a_src),
whose fixed point is 1. A guest colour attachment is cleared once on
first use and loaded on every pass after, so nothing ever resets it and
the channel climbs until it saturates. Where such a surface is a
compositing layer, the final image is ui.rgb + scene.rgb*(1 - ui.a) and a
saturated alpha multiplies the scene away entirely - the scene renders
correctly the whole time and is then masked to black.

Recognise the clear and perform it: the attachment is reset and the quad
is dropped, which reproduces the observable effect without modelling DCC
block state. The reset drops the image's Initialized flag so the next
render pass clears via AttachmentLoadOp.Clear, rather than enqueuing a
CmdClearColorImage - the latter lands outside the following render pass,
and a target cleared that way was still observed reading back its
previous contents.

Restricted to clear-to-zero: the reset clears to zero, so a nonzero
CLEAR_WORD would be cleared to the wrong colour and is left to be drawn.
Zero is zero under every encoding the register pair can carry, so the
test needs no format handling.

The clip-space span test is load-bearing rather than defensive. Fills
sharing the vertex count, topology and blend outnumber the clears by two
orders of magnitude and sit well outside the frame; treating those as
clears erases the UI and blanks video surfaces.
This commit is contained in:
Arseny Yankovsky
2026-08-02 15:39:25 +02:00
committed by GitHub
parent cf3bd0b4f2
commit 4b5ea6a793
2 changed files with 185 additions and 7 deletions
+154 -7
View File
@@ -142,9 +142,15 @@ public static partial class AgcExports
private const uint CbColor0Base = 0x318;
private const uint CbColorRegisterStride = 15;
private const uint CbColor0Info = 0x31C;
private const uint CbColor0ClearWord0 = 0x323;
private const uint CbColor0ClearWord1 = 0x324;
private const uint CbColor0BaseExt = 0x390;
private const uint CbColor0Attrib2 = 0x3B0;
private const uint CbColor0Attrib3 = 0x3B8;
// CB_COLORn_INFO.DCC_ENABLE (gc_10_1_0_sh_mask.h). On GFX10 the legacy
// FAST_CLEAR and COMPRESSION bits stay clear because DCC, not CMASK,
// carries the compression.
private const uint CbColorInfoDccEnableMask = 1u << 28;
private const uint CbBlend0Control = 0x1E0;
private const uint PaScModeCntl0 = 0x292;
// GFX10 DB context registers (register byte address minus 0x28000, / 4).
@@ -504,7 +510,8 @@ public static partial class AgcExports
float ClearRed = 0f,
float ClearGreen = 0f,
float ClearBlue = 0f,
float ClearAlpha = 1f);
float ClearAlpha = 1f,
bool IsDccFastClear = false);
private sealed record TranslatedImageBinding(
TextureDescriptor Descriptor,
@@ -6865,6 +6872,29 @@ public static partial class AgcExports
$"dst=0x{resolveDestination.Address:X16}");
}
// A DCC fast clear writes metadata only; the colour block discards
// the quad's shaded output. Reset the attachment and drop the draw,
// which reproduces the observable effect of a clear to zero without
// modelling DCC block state.
if (translatedDraw.IsDccFastClear)
{
foreach (var target in translatedDraw.GuestTargets)
{
if (target.Address != 0)
{
VulkanVideoPresenter.RequestGuestColorClear(target.Address);
}
}
ReturnPooledDrawArrays(
translatedDraw,
globals: true,
vertex: true,
index: true);
state.TranslatedDraw = null;
return;
}
var firstTarget = translatedDraw.RenderTargets.FirstOrDefault();
if (firstTarget.Address != 0)
{
@@ -7781,6 +7811,12 @@ public static partial class AgcExports
pixelUserData[index] = pixelEvaluation.InitialScalarRegisters[index];
}
var renderState = ApplyTransparentPremultipliedFillClear(
CreateRenderState(state.CxRegisters, renderTargets, pixelColorExportMasks),
textures,
vertexInputs,
pixelEvaluation.InitialScalarRegisters);
draw = new TranslatedGuestDraw(
exportShaderAddress,
pixelShaderAddress,
@@ -7798,11 +7834,7 @@ public static partial class AgcExports
renderTargets,
DecodeDepthTarget(state.CxRegisters),
guestTargets,
ApplyTransparentPremultipliedFillClear(
CreateRenderState(state.CxRegisters, renderTargets, pixelColorExportMasks),
textures,
vertexInputs,
pixelEvaluation.InitialScalarRegisters),
renderState,
pixelUserData,
state.CxRegisters.TryGetValue(CbBlend0Control, out var rawBlend) ? rawBlend : 0,
state.CxRegisters.TryGetValue(
@@ -7816,7 +7848,15 @@ public static partial class AgcExports
fullscreenClearColor.Red,
fullscreenClearColor.Green,
fullscreenClearColor.Blue,
fullscreenClearColor.Alpha);
fullscreenClearColor.Alpha,
IsDccFastClearDraw(
state.CxRegisters,
renderTargets,
textures,
vertexInputs,
renderState,
primitiveType,
vertexCount));
return true;
}
@@ -8093,6 +8133,113 @@ public static partial class AgcExports
};
}
/// <summary>
/// Recognises the covering quad a GFX10 driver issues to clear a
/// DCC-compressed colour target. There is no clear packet: the driver
/// programs CB_COLORn_CLEAR_WORD0/1 and draws a quad that the colour block
/// turns into DCC clear codes, discarding whatever the pixel shader
/// exported. Executing it as an ordinary draw writes the shaded output
/// instead, and because the blend it uses computes
/// <c>a &lt;- a_src + a_dst * (1 - a_src)</c> - fixed point 1 - the target's
/// alpha then climbs every frame and saturates.
///
/// Restricted to clear-to-zero. The reset performed for a match clears the
/// attachment to zero, so a nonzero CLEAR_WORD would be cleared to the
/// wrong colour; those fall through and are drawn. Zero is zero under every
/// encoding the register can carry, so the pair needs no format handling.
///
/// The clip-space test is load-bearing rather than belt-and-braces: fills
/// sharing the vertex count, topology and blend outnumber the clears by two
/// orders of magnitude and sit at coordinates well outside the frame.
/// </summary>
private const uint TriangleStripPrimitive = 6;
// A float32x3 vertex position stream (BUF_DATA_FORMAT_32_32_32 / FLOAT).
private const uint PositionDataFormat = 13;
private const uint PositionNumberFormat = 7;
private static bool IsDccFastClearDraw(
IReadOnlyDictionary<uint, uint> registers,
IReadOnlyList<RenderTargetDescriptor> renderTargets,
IReadOnlyList<TranslatedImageBinding> textures,
IReadOnlyList<Gen5VertexInputBinding> vertexInputs,
GuestRenderState renderState,
uint primitiveType,
uint vertexCount)
{
if (textures.Count != 0 ||
vertexCount != 4 ||
primitiveType != TriangleStripPrimitive ||
renderTargets.Count == 0 ||
renderState.Blends.Count == 0 ||
!renderState.Blends.All(IsTransparentPremultipliedFillBlend))
{
return false;
}
var slotStride = renderTargets[0].Slot * CbColorRegisterStride;
return registers.TryGetValue(CbColor0Info + slotStride, out var info) &&
(info & CbColorInfoDccEnableMask) != 0 &&
registers.TryGetValue(CbColor0ClearWord0 + slotStride, out var clearWord0) &&
registers.TryGetValue(CbColor0ClearWord1 + slotStride, out var clearWord1) &&
clearWord0 == 0 &&
clearWord1 == 0 &&
CoversClipSpace(vertexInputs, vertexCount);
}
/// <summary>
/// True when the draw's float32x3 position stream spans the full clip
/// rectangle, i.e. x and y both reach -1 and +1.
/// </summary>
private static bool CoversClipSpace(
IReadOnlyList<Gen5VertexInputBinding> vertexInputs,
uint vertexCount)
{
const float Tolerance = 0.001f;
foreach (var input in vertexInputs)
{
if (input.DataFormat != PositionDataFormat ||
input.NumberFormat != PositionNumberFormat)
{
continue;
}
var stride = input.Stride == 0 ? 12u : input.Stride;
var available = Math.Min(input.DataLength, input.Data.Length);
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
var seen = 0;
for (var vertex = 0u; vertex < vertexCount; vertex++)
{
var at = (int)(input.OffsetBytes + (vertex * stride));
if (at + 12 > available)
{
break;
}
var position = input.Data.AsSpan(at);
var x = BitConverter.ToSingle(position);
var y = BitConverter.ToSingle(position[4..]);
if (!float.IsFinite(x) || !float.IsFinite(y))
{
return false;
}
minX = Math.Min(minX, x);
maxX = Math.Max(maxX, x);
minY = Math.Min(minY, y);
maxY = Math.Max(maxY, y);
seen++;
}
return seen >= 3 &&
minX <= -1f + Tolerance && maxX >= 1f - Tolerance &&
minY <= -1f + Tolerance && maxY >= 1f - Tolerance;
}
return false;
}
private static bool IsTransparentPremultipliedFillBlend(GuestBlendState blend) =>
blend is
{
@@ -3,6 +3,7 @@
using Silk.NET.Core;
using Silk.NET.Core.Native;
using System.Collections.Concurrent;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Media;
@@ -1259,6 +1260,25 @@ internal static unsafe class VulkanVideoPresenter
}
}
private static readonly ConcurrentDictionary<ulong, byte> _pendingGuestColorClears = new();
/// <summary>
/// Clear a guest colour target to zero at its next render pass.
///
/// Deliberately not <see cref="SubmitOffscreenColorClear"/>: that enqueues
/// a CmdClearColorImage which lands outside the render pass that follows
/// it, so a target cleared this way was still observed reading back its
/// previous contents. Dropping <c>Initialized</c> makes the render pass
/// itself clear via <see cref="AttachmentLoadOp.Clear"/>.
/// </summary>
internal static void RequestGuestColorClear(ulong address)
{
if (address != 0)
{
_pendingGuestColorClears[address] = 0;
}
}
/// <summary>
/// Apply a solid color clear to offscreen guest render targets without a
/// graphics pipeline. Used for empty-SRT procedural clear draws that
@@ -12412,6 +12432,17 @@ internal static unsafe class VulkanVideoPresenter
// format could later be replayed inside a render pass of the
// other identity.
formats[index] = targets[index].Format;
// Guest colour attachments load their previous contents on
// every pass after the first, so nothing resets one until the
// guest clears it. Consume a pending clear here, before the
// render pass is built, so the pass uses LoadOp.Clear.
if (work.Targets[index].Address != 0 &&
_pendingGuestColorClears.TryRemove(work.Targets[index].Address, out _))
{
targets[index].Initialized = false;
}
if (work.Targets[index].Address != 0 &&
TakeGuestImageInitialData(work.Targets[index].Address) is { } initialData &&
!targets[index].Initialized &&