Skip to content

feat(agc): blend into 11_11_10 float color targets in the pixel shader - #9020

Open
frankea wants to merge 31 commits into
boykopovar:mainfrom
frankea:packed-float-blend
Open

frankea wants to merge 31 commits into
boykopovar:mainfrom
frankea:packed-float-blend

Conversation

@frankea

@frankea frankea commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

A blended or partial write into a one-sample 11_11_10 float color target used to throw (AGC graphics: blending into an 11_11_10 float color target is unsupported). No host format holds this target's bits (red in 31..21, blue in 9..0, the mirror of B10G11R11), so it renders as R32_UINT and fixed-function blending can't apply. This PR blends in the pixel shader instead, through the pixel-interlock merge that JAB-dev's #8564 adds for partial 4_4_4_4 writes. The merge mechanism is theirs; this PR adds blending to it and opens it to mips and 2D array slices.

This branch carries #8563 and #8564 unchanged and is merged with current main. The changes of this PR are its two commits, 9a7a202 (the blend) and 6f240cb (mips and array slices), plus the resolution of the merge commits, which move #8564's serializer fields from version 40 to 43: frankea/AnyPS5@f5edd53...6f240cb

  • DecodeState turns a blended or partial write into a one-sample 11_11_10 float target into a merge. TargetMerge carries the target's packing and the color equation of CB_BLEND_CONTROL, and its mask is 32 bits wide; ColorTarget.partialWriteMask is 32 bits as well, next to a new mergeBlend.
  • The pixel shader unpacks the stored word, evaluates the equation in fp32 and packs the result with the same toward-zero conversion the export path uses, then applies the write mask. The target has no alpha, so destination alpha is 1. MIN and MAX ignore the factors, and a ZERO or ONE factor never multiplies, so an infinite operand doesn't turn into NaN.
  • Mips and 2D array slices: TargetMergeSurface describes the resident image of the whole array (ArraySurfaceForTarget, moved from Draw.cpp to State.cpp and shared) at the drawn mip, so the merge's storage image is the same image the target renders into. EncodeTextureResource encodes a mip range and 2D arrays, and the shader addresses the view's first slice plus gl_Layer, clamped to the view's last slice as the console clamps the render target index to SLICE_MAX, so a guest layer index past the view never reaches outside the image. This applies to one-sample 4_4_4_4 merges too.
  • Still throw: constant color and dual-source factors, multisampled 11_11_10 targets, volume targets, multisampled mips or arrays, and devices without fragmentShaderPixelInterlock. The refusal now names the target's shape.
  • Formats: request serializer 45 (feat(agc): merge partial writes into 4_4_4_4 UNORM color targets #8564's merge fields read from 43, packing and blend from 44, the layered flag and the view's first and last slice from 45; main's float32DenormPreserve stays at 41 and its sampleOrder at 42), shader disk cache 45, pipeline journal 6.
  • Overlap with fix(agc): write a replace blend into a packed color target without blending #7243 (gjonn): it turns a ONE/ZERO/ADD color equation into a plain write for packed targets, and this PR leaves that to it. Without fix(agc): write a replace blend into a packed color target without blending #7243 such a blend goes through the merge and gives the same result. fix(agc): write a replace blend into a packed color target without blending #7243's unit test expects an additive blend into 11_11_10 to throw; whichever lands second updates that test.

Tested

  • tests/execution/PackedFloatBlend.cpp (new) against a CPU reference, bit for bit on every pixel: 11 equations, a blended and a plain partial write, a back-to-back chain, two same-draw overdraws of 16 alternating layers, and three array merges (mip 0 of all six slices of a 128x128 array of 8 mips, one layer per slice, the shape Gran Turismo 7 renders; mip 1 of slices 1 to 3 of a 256x256 array; one slice drawn without a layer export).
  • RTX 4090 (NVIDIA 616.92, Windows 11, WinLibs GCC 15.2), head 5990601: agc_driver_packed_float_blend, agc_driver_packed_unorm4444_partial_write, agc_driver_packed_unorm4444_multisample_partial_write, agc_driver_packed_float_color_target, agc_driver_layered_array_color_draw, agc_driver_graphics and agc_shader_memory pass.
  • Linux x86-64 (Docker, emulated on an Apple-silicon Mac): the 35 tests of request_serializer, agc_shader_disk_cache, agc_shader_memory, agc_shader_control_flow, agc_driver_bda_device, agc_shader_replay_requests, agc_driver_graphics, agc_pipeline_journal and agc_driver_prepared_shaders pass.
  • After the main merges, head e554d27 on the RTX 4090: agc_shader_memory, agc_driver_graphics, agc_driver_layered_array_color_draw, agc_driver_packed_float_blend, agc_driver_packed_float_color_target, agc_driver_packed_unorm4444_partial_write and agc_driver_packed_unorm4444_multisample_partial_write pass. The same 35 Linux tests pass on 374556c, the main merge before it.
  • Gran Turismo 7 (PPSA01317) on the RTX 4090, with an earlier state of these commits (before the main merge and the layer clamp) and fix(agc): raise no end-of-pipe event from flips and carry the release's interrupt context #7978, fix(agc): write a replace blend into a packed color target without blending #7243, feat(agc): render layered draws into a depth view of several slices #7256 and fix(shader): answer image_get_resinfo on any T# dimension #7550 merged locally: past the 11_11_10 blend stop and the cube-map target, the main menu at 4.4 FPS. With scripted input it goes on to the World Map, the Used Cars dealer and a car purchase, then stops in the car delivery scene on color targets with fewer fragments than samples (EQAA) are unsupported, which this PR doesn't touch.

Checklist

JAB-dev and others added 28 commits October 20, 2026 19:08
… exports

CB_COLOR_INFO format 19 with UNORM and COMP_SWAP 2 lists A, B, G, R from the
least significant bits. No Vulkan device is required to render into the 4-bit
formats and NVIDIA exposes none of them as color attachments, so the target
is an R16_UINT image and the pixel shader packs the exports into the guest
layout, as the 10_11_11 target does. Writes of fewer than four components,
blending, DCC and truncating rounding are refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed words

Draws 32_ABGR and FP16_ABGR exports into a linear 4_4_4_4 target and checks
every pixel against the guest layout (A in bits 3..0, B in 7..4, G in 11..8,
R in 15..12), including NaN, negative, infinite, denormal and rounded values,
and the pixels outside the scissor.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A CB resolve from a packed color target throws, so a 2-sample 4_4_4_4
target cannot be resolved by the color block.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A CB_TARGET_MASK that writes fewer than four components of a 4_4_4_4
target leaves the other nibbles of each packed word as they were, which
an R16_UINT attachment cannot do. Such draws now close the attachment's
color write mask, and the pixel shader merges the written nibbles into
the target through a storage image: inside a pixel interlock it reads
the word, keeps the bits the mask leaves alone and stores it back.

The merge (export index, word mask, sample count) is part of the static
shader key, request version 40 and shader disk cache format 42; the
storage image is appended to the shader's images with a single runtime
mode, and its T# is encoded from the target's surface at draw time, so
the draw resolves it to the target's resident image, which Draw
requires. Merging shaders declare early fragment tests; shaders that
kill under late tests, export depth, stencil or coverage, use
alpha-to-coverage or a quad sample mask are refused, as are
multisampled, mipmapped, volume and array targets and more than one
merged target per draw.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…echeck

Merged and full writes into one target are now also drawn without a wait
between them and checked once at the end, so the render pass that follows
a merging draw has to end on the storage write to its attachment. The
unit test also passes the partial target masks through DrawRejection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… depth or stencil

A packed 4_4_4_4 partial write merges inside the pixel shader after early
depth and stencil tests. Moving the tests ahead of a pixel kill only changes
the result when the draw writes depth or stencil, so the target merge now
carries whether the draw does (depth write, or stencil with a write mask and
a non-keep operation), and the late-test kill refusal applies only then.
The flag is part of the static shader key, the serialized request (now
version 40) and the pixel input info.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e shader

The merge used to write the target at the color export, so a pixel killed
later by a valid-mask export still changed its word. The merged word is now
kept until the shader returns and written inside the pixel interlock only
when the pixel's valid mask is still set. The execution test kills a
checkerboard both before and after the merging export.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A merging draw read and wrote its packed target as a storage image while the
same image stayed a color attachment of the pass, a feedback loop that
NVIDIA only tolerates. The merged slot is now VK_ATTACHMENT_UNUSED in the
subpass and VK_FORMAT_UNDEFINED in the rendering formats, its view is left
out of the render pass and VkFramebuffer, and dynamic rendering binds no view
there. The target stays in the draw's tracked and dirty images. Pipelines
and continued passes are keyed on the merged slot.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…aw and depth

The partial write test now names the word mask of every CB_TARGET_MASK case
instead of taking it from PackedWriteMask, draws 16 overlapping layers in
one draw whose merged nibble must come from the last layer, and merges
behind and in front of a depth target filled by a checkerboard-killing
draw: a depth-rejected fragment leaves its word.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ites

Occlusion counts are taken at the forced early tests, the refused merge
draws and why a late depth write was not used, and the one GPU the merge
was measured on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… image

OpBeginInvocationInterlockEXT and OpEndInvocationInterlockEXT order the
critical sections of overlapping fragment invocations but perform no
availability or visibility operations (Vulkan memory model, fragment
shader interlock), so without Coherent a merge could read a word that an
earlier overlapping invocation stored but did not make visible. The
image variable the merged target is bound through is now decorated
Coherent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A one-sample target under a multisampled rasterizer whose
OVERRASTERIZATION_AMOUNT matches (radeonsi line and polygon smoothing)
decodes with mergedCoverage and is drawn through mixed-samples coverage
reduction onto its one-sample attachments. A merging draw leaves the
4_4_4_4 target out of the attachments, a combination that is not tested,
so a partial write of such a draw now throws; the limit is listed in the
technical debt file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… under late tests

A partial 4_4_4_4 write merges inside a pixel shader that declares
early fragment tests. When the guest runs the shader under late tests,
pixels that the depth or stencil test rejects still execute it on the
console, so their buffer and image stores, atomics and GDS operations
happen there. Under forced early tests they would be dropped. Such a
merge now throws, unless the guest asked for early tests itself. The
merged target's own image write does not count.

A recompile test checks two things for a merging shader that adds to a
buffer counter: under late tests it throws, and under the guest's early
tests it compiles with its one atomic add beside the merge. The same
shader without a merge still compiles under late tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… samples

A merged 4_4_4_4 write runs its pixel shader after early depth and
stencil tests. Vulkan counts a sample for an occlusion query at those
tests, so pixels the guest shader kills under late tests would be
counted, and on the console they are not. Once the game has read an
occlusion counter the recorder counts samples for every later draw,
so from then on such a merge throws. The check runs where the recorded
and recipe draw paths prepare their inputs. The state records
DB_SHADER_CONTROL.KILL_ENABLE for it, which fits in the existing
padding.

The execution test starts sample counting and then checks two cases: a
merge that kills a checkerboard under late tests is refused, and a
merge that kills nothing still draws. A decode test reads KILL_ENABLE.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eject pixels

The refusal of a merging shader that writes guest memory under late
tests fired in two cases where the early tests change nothing.

The first was a draw with no depth, stencil or depth-bounds test. Early
tests reject no pixel there, so every write the console makes still
happens. The target merge now carries whether the draw has such a
test, and the refusal applies only then. This adds one byte to the
version 40 merge fields of the recompile request.

The second was any GDS binding, including a shader that only reads GDS.
The refusal now looks for GDS writes, atomics, appends and consumes.

Tests: the depth and stencil decode checks the new flag, and the
static shader key depends on it. A counter-adding shader under late
tests compiles without a test and throws with one. A late-test merge
that reads GDS compiles, and one that adds to GDS throws.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A partial write of a multisampled 4_4_4_4 target now merges per sample:
the draw shades per sample, and the pixel shader reads and writes its own
sample (gl_SampleID) of the target through a multisampled storage image
whose T# describes the target's surface. Draw makes the multisampled
target storable before it takes the attachment view, so the image swap
of the first storage access cannot leave the framebuffer on the retired
image, and it accepts the target among the draw's multisampled sources
only as that one merge binding.

An execution test draws a full write into a 2-sample target, merges an X
write of a triangle that cuts through pixels, and loads every sample:
covered samples hold the merged word, uncovered ones the first draw's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A multisampled target's storage writes are ordered for later draws only
through MultisampleTarget::Storage(), which a reused resource build would
skip. The merging draw now takes the storage view once more after its
resources are resolved, so it is the last access before recording;
multisampled draws make no draw recipe. The multisampled test draws a
full write right after the merge and checks that it covers the merged
samples it overlaps.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The 2-sample test decodes its draw states from registers and checks the
word mask, the closed write mask, per-sample shading and the exported
merge, and a skip names the missing device feature.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Per-sample shading of a merging draw makes centroid and sample-located
inputs and the ancillary sample number follow the invocation's sample
(centre-located inputs too on devices without interpolation functions
and fragment shader barycentrics), and the 2-sample merge was measured
on one GPU only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A partial write of a multisampled 4_4_4_4 target forces per-sample
shading after DecodeState checked SAMPLE_COVERAGE against the guest's
own iteration, so a pixel-rate shader reading SAMPLE_COVERAGE got only
its invocation's sample in gl_SampleMaskIn instead of the whole pixel's
coverage. The forced shading now runs the same check and throws its
message; a full write of the same target still decodes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rates

A partial write of a multisampled 4_4_4_4 target makes the draw shade
per sample. For a shader the guest runs per pixel, every invocation of
a pixel must then see the same inputs. Otherwise its colours and kill
decisions follow each sample, while the console runs one invocation.

The decode now throws in two cases when the guest did not ask for
per-sample shading. The first is a shader that reads the ancillary
word, whose sample number would become the invocation's. The second
is a shader that reads centroid, sample-located or pull-model
barycentrics, Z, 1/W, or a position at the centroid or a sample. That
case is allowed when every sample sits at the pixel centre, as in a
draw raised from a one-sample rasterizer. The recompiler throws for
centre-located barycentrics on a device without interpolation
functions and fragment shader barycentrics, where they would not be
rebuilt at the centre.

Decode tests run each moving input on a 2-sample rasterizer, where it
throws, and with PS_ITER_SAMPLE, where it decodes. The raised draw
keeps the located inputs and refuses the ancillary word. Centre and
upper-left positions, centre barycentrics, the front face and the
fixed-point position decode, and a recompile test covers the missing
barycentrics. The debt entry now lists what is kept and what throws.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A merge into a multisampled 4_4_4_4 target runs the pixel shader once
per sample. A shader that stores to guest memory, runs atomics or uses
GDS would then repeat those writes for every covered sample. An
8-sample pixel would add to a counter eight times. Such a merge now
throws.

The recompile request carries the merge's sample count, not whether
the guest asked for per-sample shading, so this also refuses shaders
the guest iterates itself, where the console repeats the writes too.

A recompile test checks that a 2-sample merging shader adding to a
buffer counter throws, and that the same shader without the merge
compiles.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The 2-sample test now draws two merges back to back with no read-back
in between: an X merge over one triangle and a W merge over a crossing
one. It then loads every sample. Samples inside both triangles must
hold both nibbles, so the second merge has to read the word the first
one wrote. Each merging draw takes the target's storage view before
it records. That view ends any open render pass and records a
shader-write barrier, so two merges never share a pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…it at the centre

The recompiler refused centre-located barycentrics in a multisampled
merge on a device without interpolation functions and fragment shader
barycentrics. It did so even when every sample sits at the pixel
centre, as in a draw raised from a one-sample rasterizer, where the
barycentrics are the centre's anyway. The refusal now applies only
when the merge's sample locations are off the centre. The pixel
request carries those locations for such a merge.

The recompile test runs on two states. On a 2-sample rasterizer, the
missing barycentrics throw, and the shader compiles once they are
present. On a raised draw, the shader compiles without them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The multisampled merge accepts 2, 4 and 8 samples, but the execution
test drew only 2-sample targets. It now runs the same draws on 2, 4
and 8 samples, each count with its own target: a cutting merge under
a full write, then an X and a W merge drawn back to back. Every
sample is read back through a T# of the target's sample count and
checked against the standard sample locations. A partly merged pixel
is now any pixel with some, but not all, of its samples merged.

A count whose R16_UINT colour attachment with storage use the device
does not offer is skipped and printed. The test is skipped when the
device offers none of the three.

The debt entry on measured hardware now names the 4- and 8-sample
merges.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A blended or partial write into a one-sample 11_11_10 float target goes
through the pixel-interlock merge: the pixel shader unpacks the stored
word, evaluates the CB_BLEND_CONTROL color equation in fp32 and packs the
result toward zero before the write mask. Constant color and dual-source
factors still throw, and so does a mipmapped, volume, array or
multisampled target, now with the target's shape in the message.
A one-sample 4_4_4_4 partial write or 11_11_10 blend into a mip of a
mipmapped target, or into a slice range of a 2D array, now goes through the
pixel-interlock merge instead of throwing. The merge descriptor names the
same whole-array surface as the resident color image at the drawn mip, and
the shader addresses the slice as the view's first slice plus gl_Layer,
clamped to the view's last slice as the console clamps the render target
index to SLICE_MAX. Volume targets and multisampled mips or arrays still
throw.
@github-actions github-actions Bot added the unblocks title Names a title ID on the Unblocks line label Oct 21, 2026
…ed-float-blend

# Conflicts:
#	core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawRegisterKey.cpp
#	core/libs/prx/libSceAgcDriver/tests/ShaderMemory.cpp
#	core/shader/recompiler/CacheKey.hpp
#	core/shader/recompiler/ControlFlow/src/RequestSerializer.cpp
#	core/shader/recompiler/Optimization/include/Optimization/ShaderStageInputInfo.hpp
#	core/shader/recompiler/Recompiler.hpp
#	core/shader/recompiler/Translation/src/ShaderInputInfoBuilder.cpp
#	core/shader/recompiler/tests/RequestSerializerTests.cpp
#	docs/dev/debt/libSceAgcDriver.md

@CnRJay CnRJay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing a thrown refusal with a working blend is worth having (GT7 gets past the 11_11_10 stop to its main menu), and building on #8564's interlock merge is reasonable. The cost of that mechanism is not in either PR.

  • A blended draw into this target now runs its pixel shader in a fragment-shader-interlock critical section with early fragment tests forced, and for multisampled targets with per-sample shading. Interlock serializes overlapping fragments of the same pixel, and forcing early fragment tests changes when depth is tested relative to the shader's discards; on a blend-heavy pass (particles, UI, fog) that can be much slower than fixed-function blending. The description's only speed figure is "main menu at 4.4 FPS" in GT7 on an RTX 4090 with an earlier state of the commits. Please post the summary lines of the same scene with and without the blend path (the unsupported draw can be skipped with a switch for the comparison), and Graphics.Draw per frame, so the cost is known before titles depend on it.
  • State which GPUs support fragmentShaderPixelInterlock and what happens on those that do not (the previous behavior was a clear throw; the replacement should be too, with a message that names the missing feature), including lavapipe, which the tests run on.
  • The 2,961 lines include #8563 and #8564 "unchanged". Rebase onto them once they merge so this PR's two commits are what is reviewed; as it stands this PR is marked conflicting and cannot be tested as a unit.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 21, 2026
@frankea

frankea commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

@CnRJay thanks, all three points are fair. In order:

Cost in GT7. Here is the same scene with and without the blend path, from the same build: Gran Turismo 7 (PPSA01317 01.710.000) idling on its main menu with no input, RTX 4090 (NVIDIA 616.92), Windows 11, Ryzen 7 5800X, 3840x2160 output, warm shader cache. The build is this PR before its main merge, plus what GT7 needs to reach the menu (#9068's two fixes, #7978, #7243, #7256, #7550, and a local change that draws a partially resident depth target whose pages are all committed) and local diagnostics. For "without", a local switch that is not in this PR (APS5_REFUSE_PACKED_MERGE) throws for any pixel stage that would merge, and APS5_SKIP_REFUSED_DRAWS=1 skips those draws. Both configurations set APS5_SKIP_REFUSED_DRAWS=1 and APS5_TRACE_SUMMARY_EVERY=10. They also set two local switches for later GT7 stops (APS5_SKIP_REFUSED_DISPATCHES, APS5_A2C_UNDITHERED), and neither fires in these runs. Two runs of each, alternating; each value is the median of the 10 s interval lines from 240 s to 510 s after the first present, when the menu is steady:

run frame p50 (ms) p90 p99 GPU busy per present (ms) Graphics.Draw per frame (ms) Driver.Packet per frame (ms)
without, run 1 221.5 228.5 234.7 67.1 134.4 256.3
with, run 1 220.4 226.9 234.9 67.1 133.6 254.8
without, run 2 222.9 230.3 236.0 67.2 135.3 257.5
with, run 2 224.3 230.1 236.9 67.4 136.4 259.0

About 12 draws per frame go through the merge here, out of about 1,880, and all of them are this PR's path: blended, layered draws into an 11_11_10 array target, from one pixel shader (0x120af97800). GPU busy per present is the same either way, so in this scene the interlocked merge costs less than the run-to-run spread. But the merged draws are few, and the frame is CPU-bound (queue worker 0 is about 95% busy, Driver.Packet takes about 255 ms per frame), so this scene says little about a blend-heavy pass with large overlapping draws. A skipped draw still goes through decode and capture up to the refusal every frame, so the CPU columns of the "without" rows include part of those draws' cost; the GPU column doesn't.

For that bound I added a timed overdraw case to the GPU test, aab4fb4: agc_driver_packed_float_blend_tests --benchmark-overdraw, which CTest doesn't run. It records one draw of N full-target triangles with a premultiplied source-over blend (CB_BLEND_CONTROL 0x40000501, the blend of GT7's World Map draw in #9335) into a 2048x2048 tiled target, twice: once as 11_11_10 float through the merge, and once as 10_11_11 float, the same 32 bits in mirrored order, which the host blends itself as B10G11R11_UFLOAT. GPU time comes from the recorder's batch timestamps (APS5_PROFILE_GPU). The time per layer is (N=64 minus N=1) / 63, which leaves out the fixed cost of the batch. Medians of 9 rounds, three runs on the RTX 4090:

path N=1 N=64 per full-target layer fill rate
11_11_10 through the merge 0.060 ms 3.654 to 3.659 ms 0.057 ms 73 Gpixel/s
10_11_11 blended by the host 0.033 to 0.034 ms 1.768 to 1.769 ms 0.0275 ms 152 Gpixel/s

So when every fragment overlaps 63 others in the same draw, the merge blends at about half the host's rate on this GPU: 2.07x the time per layer, the same in all three runs. At GT7's 12 merged draws per frame that disappears in the noise, as above. A full-screen particle or fog pass with heavy overdraw into an 11_11_10 target would take about twice as long as fixed-function blending. I only measured the RTX 4090.

One summary line of each configuration
{"commit":"6b2abf6280b2","dirty":false,"env":{"APS5_A2C_UNDITHERED":"1","APS5_COUNT_PACKED_MERGES":"1","APS5_PAD_INPUT_FILE":"<path>","APS5_REFUSE_PACKED_MERGE":"1","APS5_SKIP_REFUSED_DISPATCHES":"1","APS5_SKIP_REFUSED_DRAWS":"1","APS5_TRACE_DIR":"<path>","APS5_TRACE_SUMMARY_EVERY":"10"},"gpu":"NVIDIA GeForce RTX 4090","driver":"NVIDIA 616.92","os":"Windows 10.0.26200","cpu":"AMD Ryzen 7 5800X 8-Core Processor","cpu_threads":16,"memory_mib":32688,"title_id":"PPSA01317","title":"Gran Turismo® 7","content_version":"01.710.000","resolution":"3840x2160","window":{"spec":"summary_every 10","first_present":6002,"ms":10068.172},"interval_start_s":308.048,"interval_end_s":318.116,"presents":45,"frame_ms":{"p50":223.224,"p90":228.548,"p99":231.352,"max":231.352},"gpu_busy_ms_per_present":67.109,"worker_busy_pct":{"queue worker 0x0":94.996,"queue worker 0x50":16.165,"queue worker 0x51":2.0,"queue worker 0x2a":1.611,"queue worker 0x23":0.137,"queue worker 0x2b":1.064,"queue worker 0x4a":0.0,"queue worker 0x4b":0.362},"top_phases_ms_per_frame":{"Driver.Packet":257.351,"Driver.Packet.draw":136.407,"Driver.Draw":135.473,"Graphics.Draw":134.318,"Graphics.Draw.draw_and_resource_release":122.08,"Driver.Draw.shader_compile_and_link":75.933,"Shader.DrawStage":68.33,"Resources.Complete":66.874},"trace_dropped":false,"trace":""}
{"commit":"6b2abf6280b2","dirty":false,"env":{"APS5_A2C_UNDITHERED":"1","APS5_COUNT_PACKED_MERGES":"1","APS5_PAD_INPUT_FILE":"<path>","APS5_SKIP_REFUSED_DISPATCHES":"1","APS5_SKIP_REFUSED_DRAWS":"1","APS5_TRACE_DIR":"<path>","APS5_TRACE_SUMMARY_EVERY":"10"},"gpu":"NVIDIA GeForce RTX 4090","driver":"NVIDIA 616.92","os":"Windows 10.0.26200","cpu":"AMD Ryzen 7 5800X 8-Core Processor","cpu_threads":16,"memory_mib":32688,"title_id":"PPSA01317","title":"Gran Turismo® 7","content_version":"01.710.000","resolution":"3840x2160","window":{"spec":"summary_every 10","first_present":6068,"ms":10133.97},"interval_start_s":308.212,"interval_end_s":318.346,"presents":46,"frame_ms":{"p50":220.356,"p90":227.307,"p99":230.243,"max":230.243},"gpu_busy_ms_per_present":67.021,"worker_busy_pct":{"queue worker 0x0":94.948,"queue worker 0x50":16.493,"queue worker 0x51":2.003,"queue worker 0x2a":1.57,"queue worker 0x23":0.063,"queue worker 0x2b":1.069,"queue worker 0x4a":0.007,"queue worker 0x4b":0.33},"top_phases_ms_per_frame":{"Driver.Packet":253.753,"Driver.Packet.draw":137.355,"Driver.Draw":136.429,"Graphics.Draw":133.212,"Graphics.Draw.draw_and_resource_release":120.961,"Driver.Draw.shader_compile_and_link":74.699,"Shader.DrawStage":67.017,"Resources.Complete":65.963},"trace_dropped":false,"trace":""}

On the mechanism:

Devices without fragmentShaderPixelInterlock. That case stays a throw that names the feature. The recompiler throws a partial write of a packed color target needs the fragmentShaderPixelInterlock feature, which the device lacks (#8564's check in SpirvModuleSetup.cpp). #8564's unit test pins it for a 4_4_4_4 merge with a target that lacks the capability, and the 11_11_10 merge goes through the same check. The GPU tests print skipped, the device lacks fragmentShaderPixelInterlock and return the skip code. Which drivers expose the feature:

  • Mesa at 3e32b0e1f: lavapipe always (lvp_device.c), so the CI's lavapipe runs these tests rather than skipping them. ANV always (anv_physical_device.c). RADV on GFX9 and newer when it compiles with ACO (radv_has_pops). NVK, turnip, panvk and honeykrisp don't expose it.
  • MoltenVK 1.4.1: when the Metal device reports raster order groups (MVKDevice.mm).
  • Windows, from the Vulkan Hardware Database's fragmentShaderPixelInterlock listing and its &option=not counterpart: NVIDIA from second-generation Maxwell on; the reports without it are Kepler, first-generation Maxwell, or drivers from 2019 and earlier. Intel from Gen9 on (Broadwell and older lack it). AMD's own driver from around Vulkan driver version 2.0.317 (early 2025), for example the RX 9070 GRE on 2.0.395 and the Radeon PRO W7900 on 2.0.353. It is missing on 2.0.310 and older, for example the RX 7700S on 2.0.310 and the RX 7800 XT on 2.0.299.

So on an AMD Windows driver from before early 2025, GT7 now stops on the interlock message instead of the old 11_11_10 blend message.

#8563 and #8564. Agreed. The branch carries them unchanged so that it builds and tests as a whole. This PR's own change is the two commits in the compare link in the description. I merged current main into the branch today (e554d27), so it no longer conflicts. Once they merge, I'll rebase onto main and drop them.

AI-assisted: yes (Claude Code)

@Celegans12

Copy link
Copy Markdown
Contributor

Ghost of Yōtei (PPSA26344) on plain main (0c6a5bd) stops about 20 minutes into a warm open-world run on AGC graphics: blending into a 10_11_11 unorm color target is unsupported. That's the sibling packed target, so I'd like to cover it on top of your merge instead of adding a second mechanism.

Your blend path already passes the packing through TargetMerge, PackedWriteMask and the merge emitter. As far as I can see, 10_11_11 unorm needs four things on top: decode letting Unorm10_11_11 into the merge, its 10/11/11 fields in PackedWriteMask, unorm unpack and pack in the merge emitter (round to nearest, as the existing export does), and the serializer range. We've carried a working version of that blend in our own tree since 10-12. It uses a separate epilogue, so I wouldn't send it as is, but its CPU reference test (the factor and op combinations Yōtei uses, add and both subtracts) should carry over.

Would you rather fold 10_11_11 unorm into #9020, or should I open a small follow-up with Depends on: #9020 once your stack settles? Either way I'll run it in Yōtei and post the result here. I don't think #7243's ONE/ZERO case covers it: I haven't pinned down the exact blend Yōtei sets on this target yet, but our working version needed real blending.

@frankea

frankea commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

@Celegans12 thanks for the Yōtei report. Please open it as a follow-up with Depends on: #9020; I'd keep #9020 to 11_11_10 float while it's under review. #9020 itself depends on #8564, which is still open, so your PR stacks on both.

Your list matches what I see in #9020's head (aab4fb4):

  • Decode: in DecodeColorBuffer, only an 11_11_10 float target moves the blend into mergeBlend, so a blended 10_11_11 unorm target still reaches blending into a 10_11_11 unorm color target is unsupported. The decode already requires ROUND_MODE 0 for 10_11_11 unorm, which fits your round-to-nearest pack.
  • PackedWriteMask takes 4_4_4_4 unorm and 11_11_10 float only. It needs the 10_11_11 fields: red in the low 11 bits, green in the next 11 and blue in the top 10.
  • Merge emitter: ShaderInputInfoBuilder refuses a blending merge into anything but 11_11_10 float. That's where your unorm unpack and pack go.
  • Serializer: I don't think it needs a change. Since version 44 it reads ColorExportPacking up to Unorm4_4_4_4, which includes Unorm10_11_11, and it reads the merge's blend control unconditionally.

Two limits of #9020 to keep in mind for Yōtei. First, its blend refuses a multisampled 11_11_10 float target, so a multisampled 10_11_11 unorm target would need that case too. The merge as a whole also refuses volume targets and mipmapped or array multisampled ones. Second, the merge needs fragmentShaderPixelInterlock, and devices without it throw.

Post the Yōtei result here or on your PR, whichever you prefer. I can run your branch on an RTX 4090 if that helps.

AI-assisted: yes (Claude Code)

@Celegans12

Copy link
Copy Markdown
Contributor

@frankea thanks for the pointers. The 10_11_11 unorm follow-up is up as #9748, stacked on this PR's head aab4fb4, so only its last commit is new.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agc libSceAgc* build docs needs rebase Conflicts with main, rebase needed shader core/shader unblocks title Names a title ID on the Unblocks line

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants