diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl index 0c9323abb..39aac9860 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl @@ -1,14 +1,16 @@ // Temporal Anti-Aliasing (clean-room). // -// 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. +// Phase 2: motion-vector reprojection. The history is no longer read at the fixed pixel but at the +// position the pixel's content occupied last frame, following our reconstructed motion field: // -// result = mix(current, history, blend) // blend = history weight (e.g. 0.90) +// prevPos = loc + motionSign * mv // motionSign = +1 (calibrated): follow the field to the history +// history = bilinear sample of HistoryRead at prevPos +// result = mix(current, history, blend) // -// 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. +// This makes the accumulated history track moving geometry, so the massive ghosting from Phase 1 +// collapses while the camera turns. Pixels whose previous position falls off-screen (disocclusion) +// fall back to the current frame so freshly revealed areas do not smear. Neighborhood clamping for +// the remaining ghosting/fireflies comes in Phase 3. #version 430 core @@ -16,14 +18,17 @@ layout (local_size_x = 16, local_size_y = 16) in; 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 = 3, set = 2) uniform sampler2D HistoryRead; +layout (binding = 1, set = 2) uniform sampler2D Source; // current color +layout (binding = 3, set = 2) uniform sampler2D HistoryRead; // previous accumulated result +layout (binding = 5, set = 2) uniform sampler2D Motion; // rg16f motion field, render-res pixels 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 + 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 + float motionSign; // +1 (calibrated) maps a pixel back to its previous position (prevPos = loc + mv) + float hasMotion; // >0.5 once a previous frame exists to estimate motion from }; void main() @@ -36,10 +41,17 @@ void main() } 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; + // This pixel's motion (in render-resolution pixels); reproject the history read by it. + vec2 mv = hasMotion > 0.5 ? texelFetch(Motion, loc, 0).xy : vec2(0.0); + vec2 prevPos = vec2(loc) + 0.5 + motionSign * mv; + vec2 uv = prevPos / vec2(width, height); + + bool onScreen = all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0))); + vec4 history = texture(HistoryRead, uv); // bilinear: prevPos is sub-pixel + + // No history yet, or reprojected off-screen (disocclusion): take the current frame, no ghost. + float w = (hasHistory > 0.5 && onScreen) ? blend : 0.0; vec4 result = mix(current, history, w); imageStore(imgOutput, loc, result); diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv index fc5f4acc7..f1eebbd8e 100644 Binary files a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv differ diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs index 18585ab49..6aa291853 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs @@ -14,14 +14,16 @@ namespace Ryujinx.Graphics.Vulkan.Effects /// /// Clean-room native Temporal Anti-Aliasing. /// - /// Phase 1: fixed-weight temporal accumulation. Each frame blends the current frame with the previous - /// accumulated frame (result = mix(current, history, blend)) 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). + /// Phase 2: fixed-weight temporal accumulation with motion-vector reprojection. Each frame it + /// reconstructs a motion field from the current and previous color (the same Lucas-Kanade pass + 3x3 + /// median filter used by the DLSS path -- self-contained, so TAA works with DLSS off), then blends the + /// current frame with the history sampled at the reprojected position (prevPos = loc - mv). Static + /// content stays rock-steady and the Phase 1 ghosting collapses while the camera moves. Neighborhood + /// clamping for the remaining ghosting/fireflies is Phase 3. /// /// 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). + /// default render is byte-identical. RYUJINX_TAA_BLEND overrides the history weight (default 0.90); + /// RYUJINX_TAA_MV_SIGN flips the reprojection direction (default +1) for sign calibration. /// internal class TemporalFilter : IPostProcessingEffect { @@ -29,13 +31,20 @@ 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 const float MaxMotion = 32f; // motion-vector clamp, in render-resolution pixels + private const int CounterCount = 9; // uints in the motion-pass SSBO (unused by TAA, required by the shader) + private static readonly float HistoryBlend = ParseBlend(); + private static readonly float MotionSign = ParseSign(); private readonly VulkanRenderer _renderer; - private readonly PipelineHelperShader _pipeline; + private readonly PipelineHelperShader _pipeline; // accumulation/blend pass + private readonly PipelineHelperShader _motionPipeline; // motion estimate + median filter passes private ISampler _sampler; private ShaderCollection _program; + private ShaderCollection _motionProgram; + private ShaderCollection _motionFilterProgram; + private BufferHandle _sceneChangeBuffer; // Output keeps the input format so the downstream blit/scaling filter sees an identical texture. private TextureView _output; @@ -46,12 +55,19 @@ namespace Ryujinx.Graphics.Vulkan.Effects private bool _readFromHistory0; private bool _hasHistory; + // Optical-flow inputs/outputs: previous color (input format), raw and median-filtered motion (rg16f). + private TextureView _prevColor; + private TextureView _motion; + private TextureView _motionFiltered; + private bool _hasPrev; + private bool _activeLogged; public TemporalFilter(VulkanRenderer renderer, Device device) { _renderer = renderer; _pipeline = new PipelineHelperShader(renderer, device); + _motionPipeline = new PipelineHelperShader(renderer, device); Initialize(); } @@ -69,24 +85,62 @@ namespace Ryujinx.Graphics.Vulkan.Effects return 0.90f; } + private static float ParseSign() + { + // Default +1 (prevPos = loc + mv): calibrated in game. The Lucas-Kanade field is oriented so that + // following +mv lands the read on the history; -1 doubled the image. RYUJINX_TAA_MV_SIGN=-1 flips back. + return Environment.GetEnvironmentVariable("RYUJINX_TAA_MV_SIGN") is "-1" ? -1f : 1f; + } + private void Initialize() { _pipeline.Initialize(); - - byte[] shader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv"); - - ResourceLayout layout = new ResourceLayoutBuilder() - .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) - .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) + _motionPipeline.Initialize(); _sampler = _renderer.CreateSampler(SamplerCreateInfo.Create(MinFilter.Linear, MagFilter.Linear)); + // Accumulation/blend pass: current color (b1), history read (b3), motion (b5), params (b2), + // present output image (b0/set3), history write image (b1/set3). + byte[] blendShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv"); + ResourceLayout blendLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 3) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 5) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true) + .Add(ResourceStages.Compute, ResourceType.Image, 1, true).Build(); + _program = _renderer.CreateProgramWithMinimalLayout([ - new ShaderSource(shader, ShaderStage.Compute, TargetLanguage.Spirv) - ], layout); + new ShaderSource(blendShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], blendLayout); + + // Motion estimate (Lucas-Kanade): current (b1), previous (b3), params (b2), stats SSBO (b0/set1), + // motion image (b0/set3). Reused verbatim from the DLSS path. + byte[] motionShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.spv"); + ResourceLayout motionLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.StorageBuffer, 0) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 3) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + + _motionProgram = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(motionShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], motionLayout); + + // 3x3 median outlier filter: params (b2), stats SSBO (b0/set1), filtered out (b0/set3), raw in (b1/set3). + byte[] filterShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.spv"); + ResourceLayout filterLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.StorageBuffer, 0) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true) + .Add(ResourceStages.Compute, ResourceType.Image, 1, true).Build(); + + _motionFilterProgram = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(filterShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], filterLayout); + + _sceneChangeBuffer = _renderer.BufferManager.CreateWithHandle(_renderer, CounterCount * sizeof(uint)); } private static TextureCreateInfo MakeInfo(TextureCreateInfo b, int w, int h, Format format, int bytesPerPixel) @@ -119,14 +173,21 @@ namespace Ryujinx.Graphics.Vulkan.Effects _output?.Dispose(); _history0?.Dispose(); _history1?.Dispose(); + _prevColor?.Dispose(); + _motion?.Dispose(); + _motionFiltered?.Dispose(); _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; + _prevColor = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, view.Info.Format, view.Info.BytesPerPixel)) as TextureView; + _motion = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16Float, 4)) as TextureView; + _motionFiltered = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16Float, 4)) as TextureView; - // Fresh history: the first frame has nothing to blend against. + // Fresh resources: nothing to blend or estimate motion against yet. _readFromHistory0 = true; _hasHistory = false; + _hasPrev = false; } public TextureView Run(TextureView view, CommandBufferScoped cbs, int width, int height) @@ -136,7 +197,15 @@ namespace Ryujinx.Graphics.Vulkan.Effects if (!_activeLogged) { _activeLogged = true; - Logger.Info?.Print(LogClass.Gpu, $"TAA: active (accumulation, history blend {HistoryBlend:0.00})."); + Logger.Info?.Print(LogClass.Gpu, + $"TAA: active (reprojection, history blend {HistoryBlend:0.00}, mv sign {MotionSign:+0;-0})."); + } + + // Reconstruct this frame's motion field from (current, previous), then median-filter it. + if (_hasPrev) + { + RunMotionPass(view, cbs); + RunMotionFilterPass(view, cbs); } // Ping-pong: read last frame's result, write this frame's result to the other target. @@ -147,8 +216,17 @@ namespace Ryujinx.Graphics.Vulkan.Effects _pipeline.SetProgram(_program); _pipeline.SetTextureAndSampler(ShaderStage.Compute, 1, view, _sampler); _pipeline.SetTextureAndSampler(ShaderStage.Compute, 3, historyRead, _sampler); + _pipeline.SetTextureAndSampler(ShaderStage.Compute, 5, _motionFiltered, _sampler); - ReadOnlySpan paramsBuffer = [view.Width, view.Height, HistoryBlend, _hasHistory ? 1f : 0f]; + ReadOnlySpan paramsBuffer = + [ + view.Width, + view.Height, + HistoryBlend, + _hasHistory ? 1f : 0f, + MotionSign, + _hasPrev ? 1f : 0f, + ]; int rangeSize = paramsBuffer.Length * sizeof(float); using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, rangeSize); buffer.Holder.SetDataUnchecked(buffer.Offset, paramsBuffer); @@ -164,21 +242,100 @@ namespace Ryujinx.Graphics.Vulkan.Effects _pipeline.Finish(); - // Next frame reads the target we just wrote. + // Keep this frame's color as the "previous" frame for next time's motion estimate. + CopyToPrev(view, cbs); + + // Next frame reads the target we just wrote and has both a history and a previous frame. _readFromHistory0 = !_readFromHistory0; _hasHistory = true; + _hasPrev = true; return _output; } + private void RunMotionPass(TextureView input, CommandBufferScoped cbs) + { + _motionPipeline.SetCommandBuffer(cbs); + _motionPipeline.SetProgram(_motionProgram); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 1, input, _sampler); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 3, _prevColor, _sampler); + + // width/height/maxMotion/metrics + dejitterX/Y + 2 pad. No clip-space jitter on the TAA input + // (that is a DLSS-only feature), so de-jitter is zero; metrics off. + ReadOnlySpan p = [input.Width, input.Height, MaxMotion, 0f, 0f, 0f, 0f, 0f]; + using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, p.Length * sizeof(float)); + buffer.Holder.SetDataUnchecked(buffer.Offset, p); + + _motionPipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + _motionPipeline.SetStorageBuffers([new BufferAssignment(0, new BufferRange(_sceneChangeBuffer, 0, CounterCount * sizeof(uint), true))]); + _motionPipeline.SetImage(0, _motion.GetImageView()); + + _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(input.Width, 16), BitUtils.DivRoundUp(input.Height, 16), 1); + _motionPipeline.ComputeBarrier(); + _motionPipeline.Finish(); + } + + private void RunMotionFilterPass(TextureView input, CommandBufferScoped cbs) + { + _motionPipeline.SetCommandBuffer(cbs); + _motionPipeline.SetProgram(_motionFilterProgram); + + ReadOnlySpan p = [input.Width, input.Height, MaxMotion, 0f, 0f, 0f, 0f, 0f]; + using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, p.Length * sizeof(float)); + buffer.Holder.SetDataUnchecked(buffer.Offset, p); + + _motionPipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + _motionPipeline.SetStorageBuffers([new BufferAssignment(0, new BufferRange(_sceneChangeBuffer, 0, CounterCount * sizeof(uint), true))]); + _motionPipeline.SetImage(0, _motionFiltered.GetImageView()); + _motionPipeline.SetImage(1, _motion.GetImageView()); + + _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(input.Width, 16), BitUtils.DivRoundUp(input.Height, 16), 1); + _motionPipeline.ComputeBarrier(); + _motionPipeline.Finish(); + } + + private void CopyToPrev(TextureView input, CommandBufferScoped cbs) + { + ImageSubresourceLayers layers = new() + { + AspectMask = ImageAspectFlags.ColorBit, + MipLevel = 0, + BaseArrayLayer = 0, + LayerCount = 1, + }; + + ImageCopy region = new() + { + SrcSubresource = layers, + DstSubresource = layers, + Extent = new Extent3D((uint)input.Width, (uint)input.Height, 1), + }; + + _renderer.Api.CmdCopyImage( + cbs.CommandBuffer, + input.GetImage().Get(cbs).Value, + ImageLayout.General, + _prevColor.GetImage().Get(cbs).Value, + ImageLayout.General, + 1, + in region); + } + public void Dispose() { _pipeline.Dispose(); + _motionPipeline.Dispose(); _program.Dispose(); + _motionProgram.Dispose(); + _motionFilterProgram.Dispose(); _sampler.Dispose(); _output?.Dispose(); _history0?.Dispose(); _history1?.Dispose(); + _prevColor?.Dispose(); + _motion?.Dispose(); + _motionFiltered?.Dispose(); + _renderer.BufferManager.Delete(_sceneChangeBuffer); } } }