diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift index 62aadd44..08b4b80b 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift @@ -617,6 +617,31 @@ public func InitDebugPipeline() -> RenderPipeline? { ) } +public func InitTAAResolvePipeline() -> RenderPipeline? { + let wf = renderInfo.colorPipeline.working + return CreatePipeline( + vertexShader: "vertexTAAResolveShader", + fragmentShader: "fragmentTAAResolveShader", + vertexDescriptor: createPostProcessVertexDescriptor(), + colorFormats: [wf.lookOutput], + depthFormat: .invalid, + depthEnabled: false, + name: "TAA Resolve Pipeline" + ) +} + +public func InitVelocityPipeline() -> RenderPipeline? { + CreatePipeline( + vertexShader: "vertexVelocityShader", + fragmentShader: "fragmentVelocityShader", + vertexDescriptor: createPostProcessVertexDescriptor(), + colorFormats: [.rg16Float], + depthFormat: .invalid, + depthEnabled: false, + name: "Velocity Pipeline" + ) +} + public func InitTransparencyPipeline() -> RenderPipeline? { CreatePipeline( vertexShader: "vertexModelShader", @@ -677,6 +702,8 @@ public func DefaultPipeLines() -> [(RenderPipelineType, RenderPipelineInitBlock) (.outputTransform, InitOutputTransformPipeline), (.debug, InitDebugPipeline), (.transparency, InitTransparencyPipeline), + (.velocity, InitVelocityPipeline), + (.taaResolve, InitTAAResolvePipeline), ] } diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift index 6efb6a2b..92e54b00 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift @@ -52,4 +52,6 @@ public extension RenderPipelineType { static let fxaa: RenderPipelineType = "fxaa" static let transparency: RenderPipelineType = "transparency" static let debug: RenderPipelineType = "debug" + static let velocity: RenderPipelineType = "velocity" + static let taaResolve: RenderPipelineType = "taaResolve" } diff --git a/Sources/UntoldEngine/Renderer/RenderInitializer.swift b/Sources/UntoldEngine/Renderer/RenderInitializer.swift index 077b528b..26a813ba 100644 --- a/Sources/UntoldEngine/Renderer/RenderInitializer.swift +++ b/Sources/UntoldEngine/Renderer/RenderInitializer.swift @@ -392,6 +392,16 @@ func initRenderPassDescriptors() { ], depthAttachment: nil ) + + // TAA: velocity pass writes rg16Float motion vectors each frame. + renderInfo.velocityRenderPassDescriptor = createRenderPassDescriptor( + width: Int(renderInfo.viewPort.x), + height: Int(renderInfo.viewPort.y), + colorAttachments: [ + (textureResources.velocityTexture, .clear, .store, MTLClearColorMake(0.0, 0.0, 0.0, 0.0)), + ], + depthAttachment: nil + ) } func initTextureResources() { @@ -635,6 +645,62 @@ func initTextureResources() { storageMode: .shared ) + // TAA: screen-space motion vectors (rg16Float, camera-only velocity). + textureResources.velocityTexture = createTexture( + device: renderInfo.device, + label: "Velocity Texture", + pixelFormat: .rg16Float, + width: viewportWidth, + height: viewportHeight, + usage: [.shaderRead, .renderTarget], + storageMode: .shared + ) + + // TAA output, history, and position-history textures (one set per eye in stereo). + let eyeCount = renderInfo.isXRStereoMode ? 2 : 1 + for eye in 0 ..< eyeCount { + // .shared so the CPU can read it back via getBytes (PSNR tests, debug captures). + // History and position-history remain .private — they are GPU-internal only. + let outTex = createTexture( + device: renderInfo.device, + label: "TAA Output Eye \(eye)", + pixelFormat: wf.lookOutput, + width: viewportWidth, + height: viewportHeight, + usage: [.shaderRead, .renderTarget], + storageMode: .shared + ) + let histTex = createTexture( + device: renderInfo.device, + label: "TAA History Eye \(eye)", + pixelFormat: wf.lookOutput, + width: viewportWidth, + height: viewportHeight, + usage: [.shaderRead, .renderTarget], + storageMode: .private + ) + let posHistTex = createTexture( + device: renderInfo.device, + label: "TAA Position History Eye \(eye)", + pixelFormat: wf.gBufferPosition, + width: viewportWidth, + height: viewportHeight, + usage: [.shaderRead, .renderTarget], + storageMode: .private + ) + + if eye == 0 { + textureResources.taaOutputTexture = outTex + textureResources.taaHistoryTexture = histTex + textureResources.taaPositionHistoryTexture = posHistTex + } + textureResources.taaOutputTextureEye[eye] = outTex + textureResources.taaHistoryTextureEye[eye] = histTex + textureResources.taaPositionHistoryTextureEye[eye] = posHistTex + } + TemporalAA.shared.markReady() + Logger.log(message: "TAA textures initialised (\(eyeCount) eye(s)).") + textureResources.fxaaTexture = createTexture( device: renderInfo.device, label: "FXAA Output Texture", diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 938eeec6..68daac9a 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -1955,6 +1955,7 @@ public enum RenderPasses { } renderInfo.offscreenRenderPassDescriptor.depthAttachment.loadAction = .load // set the states for the pipeline + renderPassDescriptor.colorAttachments[0].texture = textureResources.deferredColorMap renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadAction.load renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(1.0, 1.0, 1.0, 1.0) renderPassDescriptor.colorAttachments[0].storeAction = MTLStoreAction.store diff --git a/Sources/UntoldEngine/Renderer/RenderResources.swift b/Sources/UntoldEngine/Renderer/RenderResources.swift index 83d43f1e..dc73c8c0 100644 --- a/Sources/UntoldEngine/Renderer/RenderResources.swift +++ b/Sources/UntoldEngine/Renderer/RenderResources.swift @@ -60,6 +60,21 @@ public struct RenderInfo { public var isXRStereoMode: Bool = false public var xrEye0ViewProjection: simd_float4x4 = matrix_identity_float4x4 public var xrEye1ViewProjection: simd_float4x4 = matrix_identity_float4x4 + + // TAA / MetalFX Temporal Scaler + // unjitteredPerspectiveSpace is the clean projection matrix used by culling, shadow cascades, + // and velocity computation. perspectiveSpace carries the per-frame Halton jitter for rasterisation. + public var unjitteredPerspectiveSpace: simd_float4x4 = matrix_identity_float4x4 + public var taaJitterX: Float = 0 // current frame jitter offset in pixels (X) + public var taaJitterY: Float = 0 // current frame jitter offset in pixels (Y) + + // Per-eye unjittered view-projection matrices for camera-only velocity computation. + // Index 0 = left / mono, index 1 = right (XR stereo). + public var prevViewProjectionEye: [simd_float4x4] = [matrix_identity_float4x4, matrix_identity_float4x4] + public var currentViewProjectionEye: [simd_float4x4] = [matrix_identity_float4x4, matrix_identity_float4x4] + + /// Velocity pass render pass descriptor (writes rg16Float motion vectors) + public var velocityRenderPassDescriptor: MTLRenderPassDescriptor! } @inline(__always) @@ -227,6 +242,21 @@ public struct TextureResources { public var depthMapEye: [MTLTexture?] = [nil, nil] public var hzbDepthPyramidEye: [MTLTexture?] = [nil, nil] public var hzbMipViewsEye: [[MTLTexture]] = [[], []] + + // TAA / MetalFX Temporal Scaler textures. + // velocityTexture: rg16Float screen-space motion vectors (camera-only, re-computed each frame). + // taaOutputTexture: resolved TAA output for mono / desktop. + // taaOutputTextureEye[]: per-eye resolved TAA output for XR stereo. + public var velocityTexture: MTLTexture? + public var taaOutputTexture: MTLTexture? + public var taaOutputTextureEye: [MTLTexture?] = [nil, nil] + // History textures: the previous frame's resolved TAA output, read by the resolve shader. + public var taaHistoryTexture: MTLTexture? + public var taaHistoryTextureEye: [MTLTexture?] = [nil, nil] + // Previous frame's world-position G-buffer, used to reject stale history on + // disocclusions and object motion that camera-only velocity cannot represent. + public var taaPositionHistoryTexture: MTLTexture? + public var taaPositionHistoryTextureEye: [MTLTexture?] = [nil, nil] } public struct AccelStructResources { diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index d31a8f89..7bc2d8fd 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -502,6 +502,40 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { initSizeableResources() pendingResize = false } + + // TAA jitter (desktop / mono path). + // Apply a per-frame Halton sub-pixel offset to the projection matrix so the + // temporal scaler can accumulate multiple sample positions over time. + // Culling and shadow systems use unjitteredPerspectiveSpace to stay stable. + if TAAParams.shared.enabled, TemporalAA.shared.isSupported { + let jitter = TemporalAA.shared.currentJitter() + let vw = Float(renderInfo.viewPort.x) + let vh = Float(renderInfo.viewPort.y) + var jittered = renderInfo.unjitteredPerspectiveSpace + jittered[3][0] += jitter.x * 2.0 / vw + jittered[3][1] += jitter.y * 2.0 / vh + renderInfo.perspectiveSpace = jittered + renderInfo.taaJitterX = jitter.x + renderInfo.taaJitterY = jitter.y + + // Track prev/current unjittered VP for the camera-only velocity pass. + if let cam = CameraSystem.shared.activeCamera, + let camComp = scene.get(component: CameraComponent.self, for: cam) + { + let effectiveView = SceneRootTransform.shared.effectiveViewMatrix(camComp.viewSpace) + let unjitteredVP = simd_mul(renderInfo.unjitteredPerspectiveSpace, effectiveView) + renderInfo.prevViewProjectionEye[0] = renderInfo.currentViewProjectionEye[0] + renderInfo.currentViewProjectionEye[0] = unjitteredVP + + // Auto-reset TAA history after large camera jumps (teleport / scene cut). + TemporalAA.shared.checkAndResetIfNeeded(cameraPosition: camComp.localPosition) + } + } else { + renderInfo.perspectiveSpace = renderInfo.unjitteredPerspectiveSpace + renderInfo.taaJitterX = 0 + renderInfo.taaJitterY = 0 + } + // Tick the progressive loader here (main thread, before runFrame) so newly // registered entities are picked up by BatchingSystem in the same frame. // In XR, UntoldEngineXR.renderNewFrame() dispatches this to the main thread @@ -529,6 +563,9 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { fovyRadians: degreesToRadians(degrees: fov), aspectRatio: aspect, nearZ: near, farZ: far ) + // Store the clean (unjittered) projection. The per-frame draw() applies Halton + // jitter on top of this into perspectiveSpace before each render. + renderInfo.unjitteredPerspectiveSpace = projectionMatrix renderInfo.perspectiveSpace = projectionMatrix let viewPortSize: simd_float2 = simd_make_float2(Float(size.width), Float(size.height)) @@ -576,6 +613,12 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { renderer.initResources() + // On Vision Pro the display resolution is high enough that spatial aliasing is + // barely visible, and TAA's temporal ghosting is more noticeable than aliasing + // on a precision head-tracked display. FXAA gives clean edges with zero lag. + TAAParams.shared.enabled = false + FXAAParams.shared.enabled = true + renderEnvironment = true return renderer @@ -632,8 +675,6 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { projectionMatrix: simd_float4x4, eyeIndex: Int ) { - renderInfo.perspectiveSpace = projectionMatrix - guard let camera = CameraSystem.shared.activeCamera, let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { handleError(.noActiveCamera) return @@ -641,12 +682,45 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { cameraComponent.viewSpace = viewMatrix - // Save this eye's view-projection for next frame's per-eye HZB culling. + // --- Unjittered VP (used by culling, shadow cascades, and velocity) --- + let effectiveVM = SceneRootTransform.shared.effectiveViewMatrix(viewMatrix) + let unjitteredVP = simd_mul(projectionMatrix, effectiveVM) + + // Save this eye's unjittered VP for next frame's per-eye HZB culling. if renderInfo.isXRStereoMode { - let effectiveVM = SceneRootTransform.shared.effectiveViewMatrix(viewMatrix) - let eyeVP = simd_mul(projectionMatrix, effectiveVM) - if eyeIndex == 0 { renderInfo.xrEye0ViewProjection = eyeVP } - else { renderInfo.xrEye1ViewProjection = eyeVP } + if eyeIndex == 0 { renderInfo.xrEye0ViewProjection = unjitteredVP } + else { renderInfo.xrEye1ViewProjection = unjitteredVP } + } + + // Track prev/current unjittered VP for the camera-only velocity pass. + let safeEye = min(eyeIndex, 1) + renderInfo.prevViewProjectionEye[safeEye] = renderInfo.currentViewProjectionEye[safeEye] + renderInfo.currentViewProjectionEye[safeEye] = unjitteredVP + + // --- Jittered projection (rasterisation only) --- + // Both eyes share the same Halton sample for a given frame so the temporal + // accumulation in each per-eye scaler sees a consistent sample pattern. + if TAAParams.shared.enabled, TemporalAA.shared.isSupported { + let jitter = TemporalAA.shared.currentJitter() + let vw = Float(renderInfo.viewPort.x) + let vh = Float(renderInfo.viewPort.y) + var jittered = projectionMatrix + jittered[3][0] += jitter.x * 2.0 / vw + jittered[3][1] += jitter.y * 2.0 / vh + renderInfo.perspectiveSpace = jittered + renderInfo.unjitteredPerspectiveSpace = projectionMatrix + renderInfo.taaJitterX = jitter.x + renderInfo.taaJitterY = jitter.y + + // Auto-reset TAA history after large head-position jumps (XR teleport). + if safeEye == 0 { + TemporalAA.shared.checkAndResetIfNeeded(cameraPosition: cameraComponent.localPosition) + } + } else { + renderInfo.perspectiveSpace = projectionMatrix + renderInfo.unjitteredPerspectiveSpace = projectionMatrix + renderInfo.taaJitterX = 0 + renderInfo.taaJitterY = 0 } configuration.updateXRRenderingSystemCallback!(.xr(commandBuffer: commandBuffer, passDescriptor: passDescriptor)) diff --git a/Sources/UntoldEngine/Shaders/taaResolveShader.metal b/Sources/UntoldEngine/Shaders/taaResolveShader.metal new file mode 100644 index 00000000..3731fbf0 --- /dev/null +++ b/Sources/UntoldEngine/Shaders/taaResolveShader.metal @@ -0,0 +1,149 @@ +// +// taaResolveShader.metal +// Untold Engine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Custom Temporal Anti-Aliasing resolve pass. +// +// Algorithm (neighbourhood-clamped temporal accumulation): +// 1. Sample current-frame colour and reprojected history (via motion vectors). +// 2. Build a tight AABB from a 3×3 neighbourhood in the current frame. +// 3. Clamp history to that AABB — kills ghosting when objects move. +// 4. Blend adaptively: base weight (0.65 XR / 0.1 desktop) is floored up by +// two signals — per-pixel motion and camera-centre displacement — so that +// head rotation clears history quickly even for pixels at the rotation axis. +// +// On the first frame (reset == true) the shader outputs current colour only so +// no garbage history bleeds into the accumulated result. + +#include +#include "../../CShaderTypes/ShaderTypes.h" +#include "ShaderStructs.h" +using namespace metal; + +typedef enum { + taaCurrentColorIndex = 0, + taaHistoryColorIndex = 1, + taaVelocityMapIndex = 2, + taaPositionMapIndex = 3, + taaPositionHistoryIndex = 4, +} TAATextureIndices; + +typedef enum { + taaViewportSizeIndex = 0, + taaResetFlagIndex = 1, + taaBlendFactorIndex = 2, + taaCamDisplacementIndex = 3, + taaCameraPositionIndex = 4, + taaPositionRejectBaseIndex = 5, + taaPositionRejectDistanceScaleIndex = 6, + taaMotionBlendStartIndex = 7, + taaMotionBlendEndIndex = 8, + taaCameraBoostStartIndex = 9, + taaCameraBoostEndIndex = 10, + taaClampRadiusIndex = 11, +} TAABufferIndices; + +vertex VertexCompositeOutput vertexTAAResolveShader(VertexCompositeIn in [[stage_in]]) { + VertexCompositeOutput out; + out.position = float4(float3(in.position), 1.0); + out.uvCoords = in.uvCoords; + return out; +} + +fragment float4 fragmentTAAResolveShader( + VertexCompositeOutput in [[stage_in]], + texture2d currentColor [[texture(taaCurrentColorIndex)]], + texture2d historyColor [[texture(taaHistoryColorIndex)]], + texture2d velocityMap [[texture(taaVelocityMapIndex)]], + texture2d positionMap [[texture(taaPositionMapIndex)]], + texture2d positionHistory [[texture(taaPositionHistoryIndex)]], + constant float2 &viewportSize [[buffer(taaViewportSizeIndex)]], + constant uint &resetFlag [[buffer(taaResetFlagIndex)]], + constant float &blendFactor [[buffer(taaBlendFactorIndex)]], + constant float &camDisplacement [[buffer(taaCamDisplacementIndex)]], + constant float3 &cameraPosition [[buffer(taaCameraPositionIndex)]], + constant float &positionRejectBase [[buffer(taaPositionRejectBaseIndex)]], + constant float &positionRejectDistanceScale [[buffer(taaPositionRejectDistanceScaleIndex)]], + constant float &motionBlendStart [[buffer(taaMotionBlendStartIndex)]], + constant float &motionBlendEnd [[buffer(taaMotionBlendEndIndex)]], + constant float &cameraBoostStart [[buffer(taaCameraBoostStartIndex)]], + constant float &cameraBoostEnd [[buffer(taaCameraBoostEndIndex)]], + constant int &clampRadiusParam [[buffer(taaClampRadiusIndex)]] +) { + uint2 gid = uint2(in.position.xy); + float4 current = currentColor.read(gid); + + // First frame or after a scene cut — skip history to avoid garbage blend. + if (resetFlag != 0u) return current; + + // Pixels without opaque G-buffer position do not have reliable camera-only + // velocity, so do not blend old history into them. + float4 worldPos = positionMap.read(gid); + if (worldPos.w < 0.5) return current; + + // Reproject: find the previous-frame UV for this pixel using motion vectors. + // velocityMap stores pixel-space backward motion (current -> previous). + float2 motion = float2(velocityMap.read(gid)); + float2 prevUV = (float2(gid) + motion + 0.5) / viewportSize; + + // Disocclusion / offscreen history rejection. Clamping here would pull old + // edge pixels into the current frame during fast head movement. + if (any(prevUV < 0.0) || any(prevUV > 1.0)) return current; + + constexpr sampler bilinear(coord::normalized, filter::linear, address::clamp_to_edge); + + float4 previousWorldPos = positionHistory.sample(bilinear, prevUV); + if (previousWorldPos.w < 0.5) return current; + + // Camera/head motion should reproject to the same world-space surface. + // Reject history when it does not; this catches disocclusion and stale + // reprojected samples around silhouettes. + float positionDelta = length(previousWorldPos.xyz - worldPos.xyz); + float cameraDistance = length(worldPos.xyz - cameraPosition); + float positionRejectThreshold = max(0.0, positionRejectBase) + + cameraDistance * max(0.0, positionRejectDistanceScale); + if (positionDelta > positionRejectThreshold) return current; + + float4 history = historyColor.sample(bilinear, prevUV); + + // Build a colour AABB in the current frame to clamp the history. + // This is the key ghost-rejection step: history that falls outside the + // neighbourhood's plausible colour range is pulled back to the boundary. + int2 iSize = int2(viewportSize); + int radius = clamp(clampRadiusParam, 1, 2); + float4 minC = current, maxC = current; + for (int dy = -radius; dy <= radius; dy++) { + for (int dx = -radius; dx <= radius; dx++) { + if (dx == 0 && dy == 0) continue; + int2 coord = clamp(int2(gid) + int2(dx, dy), int2(0), iSize - 1); + float4 nb = currentColor.read(uint2(coord)); + minC = min(minC, nb); + maxC = max(maxC, nb); + } + } + history = clamp(history, minC, maxC); + + // Temporal blend. Two signals drive the current-frame weight: + // + // 1. Per-pixel motion magnitude — handles edge/object motion. + // 2. Camera displacement (passed from CPU) — handles head rotation where + // center pixels have near-zero local motion but the whole view has changed. + // smoothstep(2, 20) ramps the boost from zero at 2 px to full at 20 px of + // view-center displacement, raising the floor blend for all pixels so that + // even the rotation-axis center converges quickly after a head turn. + float motionPixels = length(motion); + float motionStart = min(motionBlendStart, motionBlendEnd); + float motionEnd = max(max(motionBlendStart, motionBlendEnd), motionStart + 0.001); + float cameraStart = min(cameraBoostStart, cameraBoostEnd); + float cameraEnd = max(max(cameraBoostStart, cameraBoostEnd), cameraStart + 0.001); + float cameraBoost = smoothstep(cameraStart, cameraEnd, camDisplacement); + float adaptiveBlend = max(blendFactor + cameraBoost * (1.0 - blendFactor), + smoothstep(motionStart, motionEnd, motionPixels)); + return mix(history, current, adaptiveBlend); +} diff --git a/Sources/UntoldEngine/Shaders/velocityShader.metal b/Sources/UntoldEngine/Shaders/velocityShader.metal new file mode 100644 index 00000000..6a23750a --- /dev/null +++ b/Sources/UntoldEngine/Shaders/velocityShader.metal @@ -0,0 +1,73 @@ +// +// velocityShader.metal +// Untold Engine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Camera-only velocity (motion vector) pass for TAA / MetalFX Temporal Scaler. +// +// For every pixel this shader reconstructs the world-space position from the +// G-buffer positionMap, projects it with the current and previous (unjittered) +// view-projection matrices, then outputs the pixel-space delta as rg16Float. +// +// Motion vector convention (matches MetalFX default with scaleX/Y = 1): +// +x = moved right (screen-space) +// +y = moved DOWN (screen-space / Metal texture origin) + +#include +#include "../../CShaderTypes/ShaderTypes.h" +#include "ShaderStructs.h" +using namespace metal; + +typedef enum { + velocityPositionMapIndex = 0, +} VelocityTextureIndices; + +typedef enum { + velocityCurrentVPIndex = 0, + velocityPreviousVPIndex = 1, + velocityViewportIndex = 2, +} VelocityBufferIndices; + +vertex VertexCompositeOutput vertexVelocityShader(VertexCompositeIn in [[stage_in]]) { + VertexCompositeOutput out; + out.position = float4(float3(in.position), 1.0); + out.uvCoords = in.uvCoords; + return out; +} + +fragment half2 fragmentVelocityShader( + VertexCompositeOutput in [[stage_in]], + texture2d positionMap [[texture(velocityPositionMapIndex)]], + constant float4x4 ¤tVP [[buffer(velocityCurrentVPIndex)]], + constant float4x4 &previousVP [[buffer(velocityPreviousVPIndex)]], + constant float2 &viewportSize [[buffer(velocityViewportIndex)]] +) { + // Read the world-space position stored by the G-buffer model pass. + // w == 0 means background (sky / no geometry) → zero velocity. + uint2 gid = uint2(in.position.xy); + float4 worldPos = positionMap.read(gid); + if (worldPos.w < 0.5h) { + return half2(0.0h); + } + + // Project world position with unjittered current and previous VP matrices. + float4 clipCurrent = currentVP * float4(worldPos.xyz, 1.0); + float4 clipPrev = previousVP * float4(worldPos.xyz, 1.0); + + float2 ndcCurrent = clipCurrent.xy / clipCurrent.w; + float2 ndcPrev = clipPrev.xy / clipPrev.w; + + // Motion vectors point from the current pixel BACK to where it came from in the + // previous frame (reprojection direction), which is what MetalFX expects. + // NDC +Y is up; Metal texture +Y is down, so the Y sign flips cancel out: + // backward direction negate (-ndcDelta) × screen-Y-flip negate = no net Y negate. + float2 ndcDelta = ndcCurrent - ndcPrev; + float2 pixelMotion = float2(-ndcDelta.x, ndcDelta.y) * viewportSize * 0.5; + + return half2(pixelMotion); +} diff --git a/Sources/UntoldEngine/Systems/RenderingSystem.swift b/Sources/UntoldEngine/Systems/RenderingSystem.swift index c062de48..50e73ae7 100644 --- a/Sources/UntoldEngine/Systems/RenderingSystem.swift +++ b/Sources/UntoldEngine/Systems/RenderingSystem.swift @@ -55,7 +55,12 @@ func UpdateRenderingSystem(in view: MTKView) { let cullingStart = CACurrentMediaTime() #endif EngineProfiler.shared.beginScope(.culling) + // Culling must see the unjittered projection so frustum planes are stable. + // The render graph (executeGraph below) still gets the jittered perspectiveSpace. + let jitteredProj = renderInfo.perspectiveSpace + renderInfo.perspectiveSpace = renderInfo.unjitteredPerspectiveSpace performFrustumCulling(commandBuffer: commandBuffer) + renderInfo.perspectiveSpace = jitteredProj EngineProfiler.shared.endScope(.culling) #if ENGINE_STATS_ENABLED let cullingMs = (CACurrentMediaTime() - cullingStart) * 1000.0 @@ -287,11 +292,52 @@ public func buildGameModeGraph() -> RenderGraphResult { let gaussianPass = RenderPass(id: "gaussian", dependencies: ["model"], execute: RenderPasses.gaussianExecution) graph[gaussianPass.id] = gaussianPass + // TAA and FXAA are mutually exclusive. TAA takes priority. + let taaEnabled = TAAParams.shared.enabled && TemporalAA.shared.isSupported + let postProcessInputID: String + + if taaEnabled { + // Camera-only velocity pass: reads positionMap (written by batchedModel) and outputs + // pixel-space motion vectors for temporal reprojection. + let velocityPass = RenderPass( + id: "velocity", + dependencies: ["batchedModel"], + execute: velocityRenderPass + ) + graph[velocityPass.id] = velocityPass + + // TAA resolves the lit scene before post-processing so effects like bloom, + // DoF, vignette, and the final look pass do not get temporally smeared. + let taaPass = RenderPass( + id: "taa", + dependencies: [spatialDebugPass.id, velocityPass.id], + execute: taaRenderPass + ) + graph[taaPass.id] = taaPass + + let taaPostProcessSourcePass = RenderPass( + id: "taaPostProcessSource", + dependencies: [taaPass.id], + execute: { _ in + let eyeIdx = renderInfo.isXRStereoMode ? renderInfo.currentEye : 0 + let safeEye = min(eyeIdx, 1) + let resolvedTexture = safeEye == 0 + ? textureResources.taaOutputTexture + : textureResources.taaOutputTextureEye[safeEye] + renderInfo.deferredRenderPassDescriptor?.colorAttachments[0].texture = resolvedTexture + } + ) + graph[taaPostProcessSourcePass.id] = taaPostProcessSourcePass + postProcessInputID = taaPostProcessSourcePass.id + } else { + postProcessInputID = spatialDebugPass.id + } + let postProcessID: String if bypassPostProcessing { let bypassPass = RenderPass( id: "postProcessBypass", - dependencies: [spatialDebugPass.id], + dependencies: [postProcessInputID], execute: { _ in guard let deferredDescriptor = renderInfo.deferredRenderPassDescriptor else { return @@ -303,7 +349,7 @@ public func buildGameModeGraph() -> RenderGraphResult { graph[bypassPass.id] = bypassPass postProcessID = bypassPass.id } else { - let postProcess = postProcessingEffects(graph: &graph, deferredPassId: spatialDebugPass.id) + let postProcess = postProcessingEffects(graph: &graph, deferredPassId: postProcessInputID) postProcessID = postProcess.id } @@ -323,7 +369,8 @@ public func buildGameModeGraph() -> RenderGraphResult { graph[lookPass.id] = lookPass let outputDependency: String - if FXAAParams.shared.enabled { + + if !taaEnabled, FXAAParams.shared.enabled { let fxaaPass = RenderPass( id: "fxaa", dependencies: [lookPass.id], @@ -741,7 +788,7 @@ func chromaticAberrationCustomization(encoder: MTLRenderCommandEncoder) { } let depthOfFieldRenderPass: RenderPasses.RenderPassExecution = { commandBuffer in - guard let sourceTexture = textureResources.deferredColorMap, + guard let sourceTexture = renderInfo.deferredRenderPassDescriptor?.colorAttachments[0].texture, let destinationTexture = textureResources.depthOfFieldTexture, let pipeline = PipelineManager.shared.renderPipelinesByType[.depthOfField] else { @@ -789,6 +836,198 @@ func depthOfFieldCustomization(encoder: MTLRenderCommandEncoder) { encoder.setFragmentBytes(&reverseZ, length: MemoryLayout.stride, index: Int(depthOfFieldPassReverseZIndex.rawValue)) } +// MARK: - Camera-only Velocity Pass + +public let velocityRenderPass: RenderPasses.RenderPassExecution = { commandBuffer in + guard TAAParams.shared.enabled && TemporalAA.shared.isSupported else { return } + + guard let pipeline = PipelineManager.shared.renderPipelinesByType[.velocity] else { + handleError(.pipelineStateNulled, "Velocity Pipeline is nil") + return + } + guard let descriptor = renderInfo.velocityRenderPassDescriptor else { return } + + // Keep the velocity descriptor pointing at the current velocity texture + // (it may have been reallocated after a viewport resize). + descriptor.colorAttachments[0].texture = textureResources.velocityTexture + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { + handleError(.renderPassCreationFailed, "Velocity Pass") + return + } + renderEncoder.label = "Velocity Pass" + renderEncoder.pushDebugGroup("Velocity Pass") + defer { renderEncoder.popDebugGroup(); renderEncoder.endEncoding() } + + renderEncoder.setRenderPipelineState(pipeline.pipelineState!) + renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) + renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) + + renderEncoder.setFragmentTexture(textureResources.positionMap, index: 0) + + // Determine which eye's VP matrices to use. + let eyeIdx = renderInfo.isXRStereoMode ? renderInfo.currentEye : 0 + let safeEye = min(eyeIdx, 1) + var currentVP = renderInfo.currentViewProjectionEye[safeEye] + var previousVP = renderInfo.prevViewProjectionEye[safeEye] + var viewport = renderInfo.viewPort ?? simd_float2(1, 1) + + renderEncoder.setFragmentBytes(¤tVP, length: MemoryLayout.stride, index: 0) + renderEncoder.setFragmentBytes(&previousVP, length: MemoryLayout.stride, index: 1) + renderEncoder.setFragmentBytes(&viewport, length: MemoryLayout.stride, index: 2) + + renderEncoder.drawIndexedPrimitivesTracked( + type: .triangle, indexCount: 6, indexType: .uint16, + indexBuffer: bufferResources.quadIndexBuffer!, + indexBufferOffset: 0 + ) +} + +// MARK: - Custom TAA Resolve Pass + +public let taaRenderPass: RenderPasses.RenderPassExecution = { commandBuffer in + guard TAAParams.shared.enabled && TemporalAA.shared.isSupported else { return } + guard let pipeline = PipelineManager.shared.renderPipelinesByType[.taaResolve] else { + handleError(.pipelineStateNulled, "TAA Resolve Pipeline is nil") + return + } + + let eyeIdx = renderInfo.isXRStereoMode ? renderInfo.currentEye : 0 + let safeEye = min(eyeIdx, 1) + + guard + let currentColor = textureResources.deferredColorMap, + let velocityTex = textureResources.velocityTexture, + let positionTex = textureResources.positionMap, + let outputTex = (safeEye == 0 + ? textureResources.taaOutputTexture + : textureResources.taaOutputTextureEye[safeEye]), + let historyTex = (safeEye == 0 + ? textureResources.taaHistoryTexture + : textureResources.taaHistoryTextureEye[safeEye]), + let positionHistoryTex = (safeEye == 0 + ? textureResources.taaPositionHistoryTexture + : textureResources.taaPositionHistoryTextureEye[safeEye]) + else { + handleError(.renderPassCreationFailed, "TAA Resolve: missing textures for eye \(safeEye)") + return + } + + // --- Resolve pass --- + let descriptor = MTLRenderPassDescriptor() + descriptor.colorAttachments[0].texture = outputTex + descriptor.colorAttachments[0].loadAction = .dontCare + descriptor.colorAttachments[0].storeAction = .store + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { + handleError(.renderPassCreationFailed, "TAA Resolve encoder for eye \(safeEye)") + return + } + renderEncoder.label = "TAA Resolve Eye \(safeEye)" + renderEncoder.pushDebugGroup("TAA Resolve") + + renderEncoder.setRenderPipelineState(pipeline.pipelineState!) + renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) + renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) + + renderEncoder.setFragmentTexture(currentColor, index: 0) + renderEncoder.setFragmentTexture(historyTex, index: 1) + renderEncoder.setFragmentTexture(velocityTex, index: 2) + renderEncoder.setFragmentTexture(positionTex, index: 3) + renderEncoder.setFragmentTexture(positionHistoryTex, index: 4) + + var viewport = renderInfo.viewPort ?? simd_float2(1, 1) + var resetFlag = UInt32(TemporalAA.shared.needsReset ? 1 : 0) + // XR uses a higher base weight for the current frame so the history sheds + // faster after head movement (0.65 → 35% history vs 0.1 → 90% for desktop). + let taaParams = TAAParams.shared + var blendFactor = renderInfo.isXRStereoMode + ? min(max(taaParams.xrBlendFactor, 0.0), 1.0) + : min(max(taaParams.desktopBlendFactor, 0.0), 1.0) + + // Compute camera centre displacement in pixels between this frame and the + // previous one. Projecting a fixed reference point through both unjittered + // VP matrices gives a single scalar that reflects how much the whole view + // has shifted — including rotation where per-pixel motion at the image + // centre is near zero. + let curVP = renderInfo.currentViewProjectionEye[safeEye] + let prevVP = renderInfo.prevViewProjectionEye[safeEye] + let ref = simd_float4(0, 0, -1, 1) + let clipC = simd_mul(curVP, ref) + let clipP = simd_mul(prevVP, ref) + var camDisplacementPixels: Float = 0.0 + if clipC.w > 0.001 && clipP.w > 0.001 { + let ndcC = simd_float2(clipC.x / clipC.w, clipC.y / clipC.w) + let ndcP = simd_float2(clipP.x / clipP.w, clipP.y / clipP.w) + camDisplacementPixels = simd_length((ndcC - ndcP) * viewport * 0.5) + } + + var cameraPosition = simd_float3.zero + if let camera = CameraSystem.shared.activeCamera, + let cameraComponent = scene.get(component: CameraComponent.self, for: camera) + { + cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + } + var positionRejectBase = taaParams.positionRejectBase + var positionRejectDistanceScale = taaParams.positionRejectDistanceScale + var motionBlendStart = taaParams.motionBlendStartPixels + var motionBlendEnd = taaParams.motionBlendEndPixels + var cameraBoostStart = taaParams.cameraBoostStartPixels + var cameraBoostEnd = taaParams.cameraBoostEndPixels + var clampRadius = taaParams.clampNeighborhoodRadius + + renderEncoder.setFragmentBytes(&viewport, length: MemoryLayout.stride, index: 0) + renderEncoder.setFragmentBytes(&resetFlag, length: MemoryLayout.stride, index: 1) + renderEncoder.setFragmentBytes(&blendFactor, length: MemoryLayout.stride, index: 2) + renderEncoder.setFragmentBytes(&camDisplacementPixels, length: MemoryLayout.stride, index: 3) + renderEncoder.setFragmentBytes(&cameraPosition, length: MemoryLayout.stride, index: 4) + renderEncoder.setFragmentBytes(&positionRejectBase, length: MemoryLayout.stride, index: 5) + renderEncoder.setFragmentBytes(&positionRejectDistanceScale, length: MemoryLayout.stride, index: 6) + renderEncoder.setFragmentBytes(&motionBlendStart, length: MemoryLayout.stride, index: 7) + renderEncoder.setFragmentBytes(&motionBlendEnd, length: MemoryLayout.stride, index: 8) + renderEncoder.setFragmentBytes(&cameraBoostStart, length: MemoryLayout.stride, index: 9) + renderEncoder.setFragmentBytes(&cameraBoostEnd, length: MemoryLayout.stride, index: 10) + renderEncoder.setFragmentBytes(&clampRadius, length: MemoryLayout.stride, index: 11) + + renderEncoder.drawIndexedPrimitivesTracked( + type: .triangle, indexCount: 6, indexType: .uint16, + indexBuffer: bufferResources.quadIndexBuffer!, + indexBufferOffset: 0 + ) + renderEncoder.popDebugGroup() + renderEncoder.endEncoding() + + // --- History blit: copy resolved output → history for next frame --- + if let blitEncoder = commandBuffer.makeBlitCommandEncoder() { + blitEncoder.label = "TAA History Blit Eye \(safeEye)" + blitEncoder.copy( + from: outputTex, + sourceSlice: 0, sourceLevel: 0, + sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0), + sourceSize: MTLSize(width: outputTex.width, height: outputTex.height, depth: 1), + to: historyTex, + destinationSlice: 0, destinationLevel: 0, + destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0) + ) + blitEncoder.copy( + from: positionTex, + sourceSlice: 0, sourceLevel: 0, + sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0), + sourceSize: MTLSize(width: positionTex.width, height: positionTex.height, depth: 1), + to: positionHistoryTex, + destinationSlice: 0, destinationLevel: 0, + destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0) + ) + blitEncoder.endEncoding() + } + + // Advance Halton counter once after the last eye. + let isLastEye = !renderInfo.isXRStereoMode || renderInfo.currentEye == 1 + if isLastEye { TemporalAA.shared.advanceFrame() } +} + +// MARK: - FXAA Pass + public let fxaaRenderPass: RenderPasses.RenderPassExecution = { commandBuffer in guard let sourceTexture = textureResources.lookTexture, let destinationTexture = textureResources.fxaaTexture, @@ -927,9 +1166,12 @@ public let lookRenderPass: RenderPasses.RenderPassExecution = { commandBuffer in } public let outputTransformRenderPass: RenderPasses.RenderPassExecution = { commandBuffer in - let sourceTexture = FXAAParams.shared.enabled - ? textureResources.fxaaTexture - : textureResources.lookTexture + let sourceTexture: MTLTexture? + if FXAAParams.shared.enabled, !(TAAParams.shared.enabled && TemporalAA.shared.isSupported) { + sourceTexture = textureResources.fxaaTexture + } else { + sourceTexture = textureResources.lookTexture + } guard let sourceTexture else { handleError(.renderPassCreationFailed, "Output Transform Pass: source texture is nil") return diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air index df07c23a..25eecfe4 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib index e7634169..f6b22e31 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air index b1a09602..c2679853 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib index 300cafb2..61f7dab0 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air index e9ba3b45..f8ed0e07 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib index a114b57d..caa58fe9 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air index 2866d7fa..64d34638 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib index a34d7efd..ae404ab5 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air index 26ef7b18..c0002cb1 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib index a8892d1e..17d3a98c 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metal b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metal index b2bf1192..98c62e3a 100644 --- a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metal +++ b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metal @@ -43,6 +43,9 @@ using namespace metal; #include "../Shaders/SSAOUpsampleShader.metal" #include "../Shaders/spatialDebugShader.metal" #include "../Shaders/FXAAShader.metal" +#include "../Shaders/velocityShader.metal" +#include "../Shaders/taaResolveShader.metal" + // Gaussian kernels #include "../Shaders/BitonicSort.metal" #include "../Shaders/Gaussians.metal" diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib index c55153bc..b877e35e 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ diff --git a/Sources/UntoldEngine/Utils/FuncUtils.swift b/Sources/UntoldEngine/Utils/FuncUtils.swift index 0aa7330e..963394c9 100644 --- a/Sources/UntoldEngine/Utils/FuncUtils.swift +++ b/Sources/UntoldEngine/Utils/FuncUtils.swift @@ -1408,3 +1408,18 @@ public func enableBatching(_ enabled: Bool) { public func isBatchingEnabled() -> Bool { BatchingSystem.shared.isEnabled() } + +// MARK: - Halton low-discrepancy sequence + +private func halton(_ index: Int, _ base: Int) -> Float { + var f: Float = 1; var r: Float = 0; var i = index + while i > 0 { + f /= Float(base); r += f * Float(i % base); i /= base + } + return r +} + +/// Pre-computed Halton(2,3) sub-pixel jitter table (16 samples, range [-0.5, 0.5]). +/// Consumed by TemporalAA.currentJitter() for per-frame projection jitter. +let haltonJitterTable: [simd_float2] = + (1 ... 16).map { i in simd_float2(halton(i, 2) - 0.5, halton(i, 3) - 0.5) } diff --git a/Sources/UntoldEngine/Utils/Globals.swift b/Sources/UntoldEngine/Utils/Globals.swift index bdb08f83..0e19c824 100644 --- a/Sources/UntoldEngine/Utils/Globals.swift +++ b/Sources/UntoldEngine/Utils/Globals.swift @@ -259,6 +259,9 @@ let shadowMaxHeight: Float = 300.0 let csmCascadeCount: Int = 3 let shadowResolution: simd_int2 = .init(2048, 2048) +// TAA: Halton table size (frame counter lives inside TemporalAA) +let haltonTableSize: Int = 16 + var rayTracingPipeline: ComputePipeline { get { let state = CoreRuntimeGlobals.shared @@ -1473,12 +1476,41 @@ public final class DepthOfFieldParams: ObservableObject, @unchecked Sendable { public final class FXAAParams: ObservableObject, @unchecked Sendable { public static let shared = FXAAParams() - @Published public var enabled: Bool = true + @Published public var enabled: Bool = false @Published public var subpixelQuality: Float = 0.75 // 0.0–1.0; higher = stronger sub-pixel smoothing @Published public var edgeThreshold: Float = 0.125 // minimum local contrast to trigger AA @Published public var edgeThresholdMin: Float = 0.0625 // absolute threshold floor (skip very dark edges) } +/// TAA + MetalFX Temporal Scaler parameters. +/// TAA and FXAA are mutually exclusive — enabling TAA auto-disables FXAA. +public final class TAAParams: ObservableObject, @unchecked Sendable { + public static let shared = TAAParams() + + @Published public var enabled: Bool = true + + /// Current-frame blend weight when running on the desktop / mono path. + @Published public var desktopBlendFactor: Float = 0.1 + + /// Current-frame blend weight when running stereo XR. + @Published public var xrBlendFactor: Float = 0.65 + + /// Pixel-motion range that ramps the resolve from history-heavy to current-heavy. + @Published public var motionBlendStartPixels: Float = 0.5 + @Published public var motionBlendEndPixels: Float = 8.0 + + /// Camera/view-center displacement range that increases current-frame weight. + @Published public var cameraBoostStartPixels: Float = 2.0 + @Published public var cameraBoostEndPixels: Float = 20.0 + + /// Base world-space rejection threshold plus distance-scaled slack. + @Published public var positionRejectBase: Float = 0.03 + @Published public var positionRejectDistanceScale: Float = 0.001 + + /// Radius for current-frame color clamp neighbourhood. 1 = 3x3, 2 = 5x5. + @Published public var clampNeighborhoodRadius: Int32 = 1 +} + /// SSAO Quality Settings public enum SSAOQuality: Int, CaseIterable { case fast = 0 diff --git a/Sources/UntoldEngine/Utils/TemporalAA.swift b/Sources/UntoldEngine/Utils/TemporalAA.swift new file mode 100644 index 00000000..d61f26cf --- /dev/null +++ b/Sources/UntoldEngine/Utils/TemporalAA.swift @@ -0,0 +1,66 @@ +// +// TemporalAA.swift +// Untold Engine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd + +/// Lifecycle and per-frame state for the Temporal Anti-Aliasing resolve pass. +/// Texture creation lives in initTextureResources (RenderInitializer.swift). +/// The Halton jitter table lives in FuncUtils.swift. +/// GPU encoding is handled by taaRenderPass in RenderingSystem.swift. +final class TemporalAA: @unchecked Sendable { + static let shared = TemporalAA() + private init() {} + + private(set) var isSupported: Bool = false + private var frameIndex: Int = 0 + + /// Set to true on first use and after a history-invalidating event. + /// The resolve shader outputs current-only when this is true, preventing + /// garbage history from leaking into the first accumulated frame. + private(set) var needsReset: Bool = true + + // MARK: - Lifecycle + + /// Called by initTextureResources once TAA textures have been allocated. + /// Resets frameIndex so the Halton sequence always starts from sample 0, + /// making multi-frame renders deterministic across test runs. + func markReady() { + frameIndex = 0 + needsReset = true + isSupported = true + } + + // MARK: - Per-frame helpers + + func currentJitter() -> simd_float2 { + haltonJitterTable[frameIndex % haltonTableSize] + } + + func advanceFrame() { + frameIndex = (frameIndex + 1) % haltonTableSize + needsReset = false + } + + // MARK: - History invalidation + + /// Hard reset — flush history (teleport, scene cut, viewport resize). + func reset() { + needsReset = true + } + + private var lastCameraPosition: simd_float3 = .zero + + /// Soft reset — auto-invalidate when the camera jumps more than `threshold` metres. + func checkAndResetIfNeeded(cameraPosition: simd_float3, threshold: Float = 2.0) { + if simd_length(cameraPosition - lastCameraPosition) > threshold { reset() } + lastCameraPosition = cameraPosition + } +} diff --git a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift index 9ce0b23b..79edd83e 100644 --- a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift +++ b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift @@ -216,7 +216,8 @@ class BaseRenderSetup: XCTestCase { targetName == "Bloom" || targetName == "Vignette" || targetName == "ColorGrading" || - targetName == "FXAA" + targetName == "FXAA" || + targetName == "TAA" { mode = "rgb" } else { @@ -418,7 +419,8 @@ class BaseRenderSetup: XCTestCase { referenceName == "Bloom" || referenceName == "Vignette" || referenceName == "ColorGrading" || - referenceName == "FXAA" + referenceName == "FXAA" || + referenceName == "TAA" { chosenMode = "rgb" } else { diff --git a/Tests/UntoldEngineRenderTests/PostFXTests.swift b/Tests/UntoldEngineRenderTests/PostFXTests.swift index efa0fb11..82c9357f 100644 --- a/Tests/UntoldEngineRenderTests/PostFXTests.swift +++ b/Tests/UntoldEngineRenderTests/PostFXTests.swift @@ -36,6 +36,7 @@ final class PostFXTests: BaseRenderSetup { PostFX.enableChromaticAberration(false) PostFX.enableDepthOfField(false) FXAAParams.shared.enabled = false + TAAParams.shared.enabled = false } // MARK: - Parameter helpers diff --git a/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift b/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift index 7f2044bf..b5ed90c8 100644 --- a/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift +++ b/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift @@ -15,9 +15,14 @@ import XCTest final class RenderGraphBuilderTest: BaseRenderSetup { override func setUp() async throws { try await super.setUp() + // Explicit state — don't rely on global defaults so tests are self-contained. + TAAParams.shared.enabled = true + FXAAParams.shared.enabled = false } override func tearDown() async throws { + TAAParams.shared.enabled = true + FXAAParams.shared.enabled = false try await super.tearDown() } @@ -200,6 +205,13 @@ final class RenderGraphBuilderTest: BaseRenderSetup { XCTAssertNotNil(graph["look"], "Look pass should exist") XCTAssertNotNil(graph["outputTransform"], "Output transform pass should exist") + // TAA is enabled by default — verify TAA and velocity passes are present, + // and FXAA (mutually exclusive with TAA) is absent. + XCTAssertNotNil(graph["taa"], "TAA pass should exist when TAA is enabled") + XCTAssertNotNil(graph["velocity"], "Velocity pass should exist when TAA is enabled") + XCTAssertNotNil(graph["taaPostProcessSource"], "TAA source handoff pass should exist when TAA is enabled") + XCTAssertNil(graph["fxaa"], "FXAA pass must not exist when TAA takes priority") + // Verify final pass XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") } @@ -263,7 +275,11 @@ final class RenderGraphBuilderTest: BaseRenderSetup { ("model", "lightPass"), ("lightPass", "transparency"), ("transparency", "spatialDebug"), - ("spatialDebug", "depthOfField"), + ("spatialDebug", "taa"), + ("batchedModel", "velocity"), + ("velocity", "taa"), + ("taa", "taaPostProcessSource"), + ("taaPostProcessSource", "depthOfField"), ("depthOfField", "chromatic"), ("chromatic", "bloomThreshold"), ("bloomThreshold", "precomp"), @@ -273,41 +289,66 @@ final class RenderGraphBuilderTest: BaseRenderSetup { ]) } - func testBuildGameModeGraph_BypassPostProcessing_UsesBypassPass() { + func testBuildGameModeGraph_BypassPostProcessing_WithTAA() { renderInfo.immersionStyle = .none renderEnvironment = true bypassPostProcessing = true defer { bypassPostProcessing = false } + // TAA is enabled (setUp default) — verifies the bypass path with TAA active. let (graph, finalPassID) = buildGameModeGraph() XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") - XCTAssertNotNil(graph["spatialDebug"], "Spatial debug pass should exist") - XCTAssertEqual(graph["spatialDebug"]?.dependencies, ["transparency"], - "Spatial debug pass should depend on transparency") - XCTAssertNotNil(graph["postProcessBypass"], "Bypass pass should exist when bypassPostProcessing is enabled") - XCTAssertEqual(graph["postProcessBypass"]?.dependencies, ["spatialDebug"], - "Bypass pass should depend on spatialDebug") - XCTAssertNotNil(graph["look"], "Look pass should exist when bypassing post-processing") - XCTAssertNotNil(graph["fxaa"], "FXAA pass should exist when bypassing post-processing") - XCTAssertNotNil(graph["outputTransform"], "Output transform should exist when bypassing post-processing") - XCTAssertNil(graph["depthOfField"], "Depth of field pass should not exist when bypassing post-processing") - XCTAssertNil(graph["chromatic"], "Chromatic pass should not exist when bypassing post-processing") - XCTAssertNil(graph["bloomThreshold"], "Bloom threshold pass should not exist when bypassing post-processing") + // Post-processing chain replaced by bypass pass. + XCTAssertNotNil(graph["postProcessBypass"], "Bypass pass should exist when bypassPostProcessing is enabled") + XCTAssertEqual(graph["postProcessBypass"]?.dependencies, ["taaPostProcessSource"], + "Bypass pass should depend on the TAA post-process source handoff") + XCTAssertNil(graph["depthOfField"], "DepthOfField should not exist when bypassing post-processing") + XCTAssertNil(graph["chromatic"], "Chromatic should not exist when bypassing post-processing") + XCTAssertNil(graph["bloomThreshold"], "BloomThreshold should not exist when bypassing post-processing") + // Precomp still depends on both the bypass pass and gaussian. let precompDeps = graph["precomp"]?.dependencies.sorted() ?? [] - XCTAssertTrue(precompDeps.contains("postProcessBypass"), - "Precomp should depend on postProcessBypass when bypassing post-processing") - XCTAssertTrue(precompDeps.contains("gaussian"), - "Precomp should still depend on gaussian pass") - + XCTAssertTrue(precompDeps.contains("postProcessBypass"), "Precomp should depend on postProcessBypass") + XCTAssertTrue(precompDeps.contains("gaussian"), "Precomp should still depend on gaussian") + + // TAA output chain: spatialDebug + velocity -> taa -> postProcessBypass -> precomp -> look. + XCTAssertNotNil(graph["taa"], "TAA pass should exist") + XCTAssertNotNil(graph["velocity"], "Velocity pass should exist alongside TAA") + XCTAssertNotNil(graph["taaPostProcessSource"], "TAA source handoff pass should exist") + XCTAssertNil(graph["fxaa"], "FXAA must not exist when TAA takes priority") XCTAssertEqual(graph["look"]?.dependencies, ["precomp"], - "Look should depend on precomp when bypassing post-processing") + "Look should depend on precomp") + let taaDeps = graph["taa"]?.dependencies.sorted() ?? [] + XCTAssertTrue(taaDeps.contains("spatialDebug"), "TAA should depend on spatialDebug") + XCTAssertTrue(taaDeps.contains("velocity"), "TAA should depend on velocity") + XCTAssertEqual(graph["outputTransform"]?.dependencies, ["look"], + "Output transform should depend on look after TAA feeds post-processing") + } + + func testBuildGameModeGraph_BypassPostProcessing_WithFXAA() { + renderInfo.immersionStyle = .none + renderEnvironment = true + bypassPostProcessing = true + TAAParams.shared.enabled = false + FXAAParams.shared.enabled = true + defer { + bypassPostProcessing = false + TAAParams.shared.enabled = true + FXAAParams.shared.enabled = false + } + + let (graph, finalPassID) = buildGameModeGraph() + + XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") + XCTAssertNotNil(graph["fxaa"], "FXAA pass should exist when TAA is disabled and FXAA is enabled") + XCTAssertNil(graph["taa"], "TAA pass must not exist when TAA is disabled") + XCTAssertNil(graph["velocity"], "Velocity pass must not exist when TAA is disabled") XCTAssertEqual(graph["fxaa"]?.dependencies, ["look"], - "FXAA should depend on look when bypassing post-processing") + "FXAA should depend on look") XCTAssertEqual(graph["outputTransform"]?.dependencies, ["fxaa"], - "Output transform should depend on fxaa when bypassing post-processing") + "Output transform should depend on fxaa") } // MARK: - Gaussian Pass Integration Tests @@ -404,6 +445,115 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Gaussian pass must come before pre-composite pass") } + // MARK: - TAA in the render graph + + func testBuildGameModeGraph_TAAEnabled_CorrectDependencies() { + renderInfo.immersionStyle = .none + renderEnvironment = true + + let (graph, _) = buildGameModeGraph() + + // velocity reads the G-buffer position written by batchedModel. + XCTAssertEqual(graph["velocity"]?.dependencies, ["batchedModel"], + "Velocity pass should depend on batchedModel") + + // taa needs the lit scene before post-processing plus motion vectors. + let taaDeps = graph["taa"]?.dependencies.sorted() ?? [] + XCTAssertTrue(taaDeps.contains("spatialDebug"), "TAA should depend on spatialDebug") + XCTAssertTrue(taaDeps.contains("velocity"), "TAA should depend on velocity") + + XCTAssertEqual(graph["taaPostProcessSource"]?.dependencies, ["taa"], + "TAA handoff should depend on the resolved TAA output") + XCTAssertEqual(graph["postProcessDisabledBypass"]?.dependencies, ["taaPostProcessSource"], + "Post-processing should read from the TAA handoff when effects are disabled") + XCTAssertEqual(graph["outputTransform"]?.dependencies, ["look"], + "outputTransform should depend on look after TAA feeds post-processing") + } + + func testBuildGameModeGraph_TAAEnabled_TopologicalOrder() throws { + renderInfo.immersionStyle = .none + renderEnvironment = true + + let (graph, _) = buildGameModeGraph() + let sorted = try topologicalSortGraph(graph: graph) + let order = sorted.map(\.id) + + assertTopologicalConstraints(order: order, constraints: [ + ("batchedModel", "velocity"), + ("spatialDebug", "taa"), + ("velocity", "taa"), + ("taa", "taaPostProcessSource"), + ("taaPostProcessSource", "postProcessDisabledBypass"), + ("postProcessDisabledBypass", "precomp"), + ("precomp", "look"), + ("look", "outputTransform"), + ]) + } + + func testBuildGameModeGraph_TAADisabled_NoTAAOrVelocityPass() { + renderInfo.immersionStyle = .none + renderEnvironment = true + TAAParams.shared.enabled = false + defer { TAAParams.shared.enabled = true } + + let (graph, _) = buildGameModeGraph() + + XCTAssertNil(graph["taa"], "TAA pass must not exist when TAA is disabled") + XCTAssertNil(graph["velocity"], "Velocity pass must not exist when TAA is disabled") + } + + func testBuildGameModeGraph_TAADisabled_OutputDependsOnLook() { + renderInfo.immersionStyle = .none + renderEnvironment = true + TAAParams.shared.enabled = false + FXAAParams.shared.enabled = false + defer { + TAAParams.shared.enabled = true + FXAAParams.shared.enabled = false + } + + let (graph, _) = buildGameModeGraph() + + XCTAssertEqual(graph["outputTransform"]?.dependencies, ["look"], + "outputTransform should depend directly on look when both TAA and FXAA are off") + } + + func testBuildGameModeGraph_TAAAndFXAABothEnabled_TAAWins() { + renderInfo.immersionStyle = .none + renderEnvironment = true + TAAParams.shared.enabled = true + FXAAParams.shared.enabled = true + defer { FXAAParams.shared.enabled = false } + + let (graph, _) = buildGameModeGraph() + + XCTAssertNotNil(graph["taa"], "TAA pass should exist when both AA flags are enabled") + XCTAssertNil(graph["fxaa"], "FXAA pass must not exist when TAA takes priority") + XCTAssertEqual(graph["outputTransform"]?.dependencies, ["look"], + "outputTransform should depend on look because TAA feeds post-processing") + } + + func testBuildGameModeGraph_TAADisabled_FXAAEnabled_CreatesFXAAPass() { + renderInfo.immersionStyle = .none + renderEnvironment = true + TAAParams.shared.enabled = false + FXAAParams.shared.enabled = true + defer { + TAAParams.shared.enabled = true + FXAAParams.shared.enabled = false + } + + let (graph, _) = buildGameModeGraph() + + XCTAssertNotNil(graph["fxaa"], "FXAA pass should exist when TAA is off and FXAA is on") + XCTAssertNil(graph["taa"], "TAA pass must not exist when TAA is disabled") + XCTAssertNil(graph["velocity"], "Velocity pass must not exist when TAA is disabled") + XCTAssertEqual(graph["fxaa"]?.dependencies, ["look"], + "FXAA should depend on look") + XCTAssertEqual(graph["outputTransform"]?.dependencies, ["fxaa"], + "outputTransform should depend on fxaa") + } + // MARK: - Helper Methods func assertTopologicalConstraints( diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorTargetReference.png index f7d7115d..db97fb5f 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png index 2b585483..f64079d7 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthTargetReference.png index 83155b03..da5d0355 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png index 64b25d70..d4106891 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/NormalTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/NormalTargetReference.png index bed6b4fd..4be96c3b 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/NormalTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/NormalTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/PositionTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/PositionTargetReference.png index a618e776..90e580d8 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/PositionTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/PositionTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SSAOReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SSAOReference.png index 3e3f089b..c41e5914 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SSAOReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SSAOReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TAAReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TAAReference.png new file mode 100644 index 00000000..9259c96a Binary files /dev/null and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TAAReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png index 64b25d70..d4106891 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/TemporalAATests.swift b/Tests/UntoldEngineRenderTests/TemporalAATests.swift new file mode 100644 index 00000000..afc6c195 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/TemporalAATests.swift @@ -0,0 +1,190 @@ +// +// TemporalAATests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +final class TemporalAATests: BaseRenderSetup { + override func setUp() async throws { + try await super.setUp() + TAAParams.shared.enabled = false + FXAAParams.shared.enabled = false + } + + override func tearDown() async throws { + TAAParams.shared.enabled = false + FXAAParams.shared.enabled = false + try await super.tearDown() + } + + // MARK: - Halton jitter table + + func testHaltonTableHasCorrectSize() { + XCTAssertEqual(haltonJitterTable.count, haltonTableSize, + "Halton table must have exactly haltonTableSize entries") + } + + func testHaltonTableValuesAreInRange() { + for (i, sample) in haltonJitterTable.enumerated() { + XCTAssertGreaterThanOrEqual(sample.x, -0.5, "Sample \(i) x is below -0.5") + XCTAssertLessThanOrEqual(sample.x, 0.5, "Sample \(i) x is above 0.5") + XCTAssertGreaterThanOrEqual(sample.y, -0.5, "Sample \(i) y is below -0.5") + XCTAssertLessThanOrEqual(sample.y, 0.5, "Sample \(i) y is above 0.5") + } + } + + func testHaltonTableHasNoRepeats() { + let keys = haltonJitterTable.map { "\($0.x),\($0.y)" } + XCTAssertEqual(Set(keys).count, haltonJitterTable.count, + "Halton table should not contain duplicate samples") + } + + func testCurrentJitterCyclesAfterFullTable() { + let first = TemporalAA.shared.currentJitter() + for _ in 0 ..< haltonTableSize { + TemporalAA.shared.advanceFrame() + } + let afterCycle = TemporalAA.shared.currentJitter() + XCTAssertEqual(first.x, afterCycle.x, accuracy: 1e-6, + "Jitter X should return to the same value after one full cycle") + XCTAssertEqual(first.y, afterCycle.y, accuracy: 1e-6, + "Jitter Y should return to the same value after one full cycle") + } + + // MARK: - TemporalAA state machine + + func testMarkReadySetsIsSupported() { + // initSizeableResources (called from setUp) already invokes markReady. + XCTAssertTrue(TemporalAA.shared.isSupported, + "isSupported must be true after initSizeableResources") + } + + func testMarkReadySetsNeedsReset() { + TemporalAA.shared.markReady() + XCTAssertTrue(TemporalAA.shared.needsReset, + "markReady should arm needsReset so the first frame skips history") + } + + func testAdvanceFrameClearsNeedsReset() { + TemporalAA.shared.markReady() + XCTAssertTrue(TemporalAA.shared.needsReset) + TemporalAA.shared.advanceFrame() + XCTAssertFalse(TemporalAA.shared.needsReset, + "advanceFrame should clear needsReset after the first frame") + } + + func testResetSetsNeedsReset() { + TemporalAA.shared.markReady() + TemporalAA.shared.advanceFrame() + XCTAssertFalse(TemporalAA.shared.needsReset) + TemporalAA.shared.reset() + XCTAssertTrue(TemporalAA.shared.needsReset, + "reset() must re-arm needsReset to flush stale history") + } + + // MARK: - Camera-jump auto-reset + + func testCheckAndResetTriggersOnLargeJump() { + // Establish a known baseline position then clear the reset flag. + TemporalAA.shared.checkAndResetIfNeeded(cameraPosition: .zero) + TemporalAA.shared.advanceFrame() + XCTAssertFalse(TemporalAA.shared.needsReset) + + // Jump 3 m — exceeds the 2 m default threshold. + TemporalAA.shared.checkAndResetIfNeeded(cameraPosition: simd_float3(0, 0, 3)) + XCTAssertTrue(TemporalAA.shared.needsReset, + "A camera jump > 2 m should trigger a history reset") + } + + func testCheckAndResetIgnoresSmallMovement() { + // Establish a known baseline position then clear the reset flag. + TemporalAA.shared.checkAndResetIfNeeded(cameraPosition: .zero) + TemporalAA.shared.advanceFrame() + XCTAssertFalse(TemporalAA.shared.needsReset) + + // Move 0.5 m — below the 2 m threshold. + TemporalAA.shared.checkAndResetIfNeeded(cameraPosition: simd_float3(0, 0, 0.5)) + XCTAssertFalse(TemporalAA.shared.needsReset, + "A camera move < 2 m must not trigger a history reset") + } + + // MARK: - GPU: texture allocation + + func testTAATexturesExistAfterInit() { + XCTAssertNotNil(textureResources.taaOutputTexture, + "taaOutputTexture must be allocated after initSizeableResources") + XCTAssertNotNil(textureResources.taaHistoryTexture, + "taaHistoryTexture must be allocated after initSizeableResources") + XCTAssertNotNil(textureResources.taaPositionHistoryTexture, + "taaPositionHistoryTexture must be allocated after initSizeableResources") + XCTAssertNotNil(textureResources.velocityTexture, + "velocityTexture must be allocated after initSizeableResources") + } + + func testTAATexturesMatchViewportDimensions() { + guard let out = textureResources.taaOutputTexture, + let hist = textureResources.taaHistoryTexture, + let pos = textureResources.taaPositionHistoryTexture + else { + XCTFail("TAA textures must exist before checking dimensions") + return + } + for (label, tex) in [("output", out), ("history", hist), ("positionHistory", pos)] { + XCTAssertEqual(tex.width, windowWidth, "\(label) width must match viewport") + XCTAssertEqual(tex.height, windowHeight, "\(label) height must match viewport") + } + } + + // MARK: - Reference image generation (uncomment to regenerate) + + func generateTAAReferenceImage() { + XCTAssertNotNil(renderer) + TAAParams.shared.enabled = true + for _ in 0 ..< haltonTableSize { + renderer.draw(in: renderer.metalView) + } + let exp = expectation(description: "TAA ref") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + if let tex = textureResources.taaOutputTexture { + self.testGenerateRenderTarget(targetName: "TAA", texture: tex) + } + exp.fulfill() + } + wait(for: [exp], timeout: TimeInterval(timeoutFactor)) + } + + // MARK: - GPU: PSNR convergence + + func testTAAOutput() { + XCTAssertNotNil(renderer, "Renderer must be initialized") + TAAParams.shared.enabled = true + + // Render one full Halton cycle (16 frames) so the history is converged + // before we sample the output for comparison. A single-frame test is + // not meaningful for TAA because frame 0 always outputs current-only + // (needsReset = true skips history blending). + for _ in 0 ..< haltonTableSize { + renderer.draw(in: renderer.metalView) + } + + let exp = expectation(description: "TAA PSNR") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + guard let tex = textureResources.taaOutputTexture else { + XCTFail("taaOutputTexture must exist after enabling TAA") + exp.fulfill() + return + } + self.psnrTest(targetName: "TAA", texture: tex) + exp.fulfill() + } + wait(for: [exp], timeout: TimeInterval(timeoutFactor)) + } +} diff --git a/Tests/UntoldEngineRenderTests/TransparencyTests.swift b/Tests/UntoldEngineRenderTests/TransparencyTests.swift index a2b4e43d..edbc6c6f 100644 --- a/Tests/UntoldEngineRenderTests/TransparencyTests.swift +++ b/Tests/UntoldEngineRenderTests/TransparencyTests.swift @@ -291,7 +291,12 @@ final class TransparencyRenderGraphTests: BaseRenderSetup { renderInfo.immersionStyle = .none renderEnvironment = true bypassPostProcessing = true - defer { bypassPostProcessing = false } + let savedTAA = TAAParams.shared.enabled + TAAParams.shared.enabled = false + defer { + bypassPostProcessing = false + TAAParams.shared.enabled = savedTAA + } let (graph, _) = buildGameModeGraph()