TAA Phase 1: fixed-weight temporal accumulation (ping-pong history)

Blends each frame with the previous accumulated frame (result = mix(current,
history, blend); default 0.90, RYUJINX_TAA_BLEND override) using two ping-pong
rgba16f history targets. No motion vectors yet, so history is read at the same
pixel: static/slow content converges to a steady image, fast motion ghosts (by
design; Phase 2 reprojects). First frame seeds history (no flash). Validated in
game: static smoothing clean, expected ghosting on motion, no errors.
This commit is contained in:
2026-06-28 19:33:58 -04:00
parent bb8a8a091d
commit f569140a77
3 changed files with 74 additions and 21 deletions
@@ -1,19 +1,29 @@
// Temporal Anti-Aliasing (clean-room).
//
// Phase 0: pure pass-through copy (current -> output). This mirrors the FXAA effect's exact
// read/store path (a no-op FXAA is already an identity pass), so the result is visually identical
// to the input -- it only proves the slot and resources. Later phases reproject the ping-pong
// history with our motion vectors, neighborhood-clamp it and accumulate it exponentially.
// Phase 1: fixed-weight temporal accumulation with ping-pong history. No motion vectors yet, so the
// history is read at the SAME pixel -- static and slow-moving content converges to a rock-steady image
// (cascades/scenery freeze like marble), while fast camera motion ghosts. That ghosting is expected and
// goes away in Phase 2, which reprojects the history with our reconstructed motion vectors.
//
// result = mix(current, history, blend) // blend = history weight (e.g. 0.90)
//
// The result is written both to the present output (input format) and to the write-side history target
// (rgba16f), which becomes next frame's read-side history.
#version 430 core
layout (local_size_x = 16, local_size_y = 16) in;
layout (rgba8, binding = 0, set = 3) uniform image2D imgOutput;
layout (rgba8, binding = 0, set = 3) uniform image2D imgOutput;
layout (rgba16f, binding = 1, set = 3) uniform image2D imgHistoryWrite;
layout (binding = 1, set = 2) uniform sampler2D Source;
layout (binding = 2) uniform dimensions {
layout (binding = 3, set = 2) uniform sampler2D HistoryRead;
layout (binding = 2) uniform params {
float width;
float height;
float blend; // history weight in [0,1]; current weight is (1 - blend)
float hasHistory; // >0.5 once the read-side history holds a valid previous frame
};
void main()
@@ -25,6 +35,13 @@ void main()
return;
}
// Integer fetch, no filtering: the stored value is exactly the source texel.
imageStore(imgOutput, loc, texelFetch(Source, loc, 0));
vec4 current = texelFetch(Source, loc, 0);
vec4 history = texelFetch(HistoryRead, loc, 0);
// First frame after (re)allocation has no valid history: pass current through and seed the history.
float w = hasHistory > 0.5 ? blend : 0.0;
vec4 result = mix(current, history, w);
imageStore(imgOutput, loc, result);
imageStore(imgHistoryWrite, loc, result);
}
@@ -5,6 +5,7 @@ using Ryujinx.Graphics.Shader;
using Ryujinx.Graphics.Shader.Translation;
using Silk.NET.Vulkan;
using System;
using System.Globalization;
using Format = Ryujinx.Graphics.GAL.Format;
using SamplerCreateInfo = Ryujinx.Graphics.GAL.SamplerCreateInfo;
@@ -13,13 +14,14 @@ namespace Ryujinx.Graphics.Vulkan.Effects
/// <summary>
/// Clean-room native Temporal Anti-Aliasing.
///
/// Phase 0 is pure scaffolding: it allocates the ping-pong history targets (rgba16f, for the precise
/// accumulation later phases need) and runs a pass-through copy whose output is visually identical to
/// the input -- so the present slot and the resources are proven without changing a single pixel.
/// Later phases reproject the history with our motion vectors, neighborhood-clamp it and accumulate it.
/// Phase 1: fixed-weight temporal accumulation. Each frame blends the current frame with the previous
/// accumulated frame (<c>result = mix(current, history, blend)</c>) using two ping-pong rgba16f history
/// targets (one read, one written, swapped every frame). With no motion vectors yet the history is read
/// at the same pixel, so static/slow content converges to a rock-steady image while fast motion ghosts
/// (resolved in Phase 2 by reprojecting the history with our reconstructed motion vectors).
///
/// Gated entirely on RYUJINX_TAA=1: when it is unset the present path never constructs or runs this,
/// so the default render is byte-identical to before.
/// Gated entirely on RYUJINX_TAA=1: when unset the present path never constructs or runs this, so the
/// default render is byte-identical to before. RYUJINX_TAA_BLEND overrides the history weight (default 0.90).
/// </summary>
internal class TemporalFilter : IPostProcessingEffect
{
@@ -27,6 +29,9 @@ namespace Ryujinx.Graphics.Vulkan.Effects
public static readonly bool IsEnabled =
Environment.GetEnvironmentVariable("RYUJINX_TAA") is "1" or "true" or "TRUE" or "True";
// History weight (fraction of the accumulated history kept each frame). Tunable for bring-up.
private static readonly float HistoryBlend = ParseBlend();
private readonly VulkanRenderer _renderer;
private readonly PipelineHelperShader _pipeline;
private ISampler _sampler;
@@ -35,9 +40,11 @@ namespace Ryujinx.Graphics.Vulkan.Effects
// Output keeps the input format so the downstream blit/scaling filter sees an identical texture.
private TextureView _output;
// Ping-pong history, rgba16f. Allocated now to prove the resources; consumed from Phase 1 onward.
// Ping-pong history, rgba16f. Each frame one is read (previous result) and one is written (new result).
private TextureView _history0;
private TextureView _history1;
private bool _readFromHistory0;
private bool _hasHistory;
private bool _activeLogged;
@@ -49,6 +56,19 @@ namespace Ryujinx.Graphics.Vulkan.Effects
Initialize();
}
private static float ParseBlend()
{
string value = Environment.GetEnvironmentVariable("RYUJINX_TAA_BLEND");
if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float blend) &&
blend >= 0f && blend <= 0.99f)
{
return blend;
}
return 0.90f;
}
private void Initialize()
{
_pipeline.Initialize();
@@ -57,8 +77,10 @@ namespace Ryujinx.Graphics.Vulkan.Effects
ResourceLayout layout = new ResourceLayoutBuilder()
.Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2)
.Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1)
.Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build();
.Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) // current color
.Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 3) // history (read)
.Add(ResourceStages.Compute, ResourceType.Image, 0, true) // present output
.Add(ResourceStages.Compute, ResourceType.Image, 1, true).Build(); // history (write)
_sampler = _renderer.CreateSampler(SamplerCreateInfo.Create(MinFilter.Linear, MagFilter.Linear));
@@ -101,6 +123,10 @@ namespace Ryujinx.Graphics.Vulkan.Effects
_output = _renderer.CreateTexture(view.Info) as TextureView;
_history0 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView;
_history1 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView;
// Fresh history: the first frame has nothing to blend against.
_readFromHistory0 = true;
_hasHistory = false;
}
public TextureView Run(TextureView view, CommandBufferScoped cbs, int width, int height)
@@ -110,20 +136,26 @@ namespace Ryujinx.Graphics.Vulkan.Effects
if (!_activeLogged)
{
_activeLogged = true;
Logger.Info?.Print(LogClass.Gpu, "TAA: active");
Logger.Info?.Print(LogClass.Gpu, $"TAA: active (accumulation, history blend {HistoryBlend:0.00}).");
}
// Ping-pong: read last frame's result, write this frame's result to the other target.
TextureView historyRead = _readFromHistory0 ? _history0 : _history1;
TextureView historyWrite = _readFromHistory0 ? _history1 : _history0;
_pipeline.SetCommandBuffer(cbs);
_pipeline.SetProgram(_program);
_pipeline.SetTextureAndSampler(ShaderStage.Compute, 1, view, _sampler);
_pipeline.SetTextureAndSampler(ShaderStage.Compute, 3, historyRead, _sampler);
ReadOnlySpan<float> dimensionsBuffer = [view.Width, view.Height];
int rangeSize = dimensionsBuffer.Length * sizeof(float);
ReadOnlySpan<float> paramsBuffer = [view.Width, view.Height, HistoryBlend, _hasHistory ? 1f : 0f];
int rangeSize = paramsBuffer.Length * sizeof(float);
using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, rangeSize);
buffer.Holder.SetDataUnchecked(buffer.Offset, dimensionsBuffer);
buffer.Holder.SetDataUnchecked(buffer.Offset, paramsBuffer);
_pipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]);
_pipeline.SetImage(ShaderStage.Compute, 0, _output.GetView(FormatTable.ConvertRgba8SrgbToUnorm(view.Info.Format)));
_pipeline.SetImage(1, historyWrite.GetImageView());
int dispatchX = BitUtils.DivRoundUp(view.Width, 16);
int dispatchY = BitUtils.DivRoundUp(view.Height, 16);
@@ -132,6 +164,10 @@ namespace Ryujinx.Graphics.Vulkan.Effects
_pipeline.Finish();
// Next frame reads the target we just wrote.
_readFromHistory0 = !_readFromHistory0;
_hasHistory = true;
return _output;
}