diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index 1fafa3b38..dc2aa0739 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -299,6 +299,7 @@ if(BUILD_SDL3_APP) src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp src/services/impl/workflow/rendering/workflow_postfx_setup_step.cpp src/services/impl/workflow/rendering/workflow_postfx_ssao_step.cpp + src/services/impl/workflow/rendering/workflow_postfx_taa_step.cpp src/services/impl/workflow/rendering/workflow_render_grid_draw_step.cpp src/services/impl/workflow/rendering/workflow_render_grid_setup_step.cpp src/services/impl/workflow/rendering/workflow_render_prepare_step.cpp diff --git a/gameengine/packages/seed/shaders/spirv/postfx_taa.frag.glsl b/gameengine/packages/seed/shaders/spirv/postfx_taa.frag.glsl new file mode 100644 index 000000000..d503e3908 --- /dev/null +++ b/gameengine/packages/seed/shaders/spirv/postfx_taa.frag.glsl @@ -0,0 +1,67 @@ +#version 450 + +// Temporal Anti-Aliasing resolve +// Blends current frame with history using neighborhood clamping (UE4/Karis style) + +layout(set = 2, binding = 0) uniform sampler2D currentFrame; +layout(set = 2, binding = 1) uniform sampler2D historyFrame; + +layout(set = 3, binding = 0) uniform TAAParams { + vec4 u_params; // x = blend factor (0.05 = 95% history), y = 1/width, z = 1/height, w = frame count +}; + +layout(location = 0) in vec2 v_uv; +layout(location = 0) out vec4 o_color; + +// Tonemap for stable neighborhood clamping (Karis 2014) +vec3 tonemap(vec3 c) { + return c / (1.0 + max(c.r, max(c.g, c.b))); +} + +vec3 untonemap(vec3 c) { + return c / (1.0 - max(c.r, max(c.g, c.b))); +} + +void main() { + vec2 texelSize = u_params.yz; + float blendFactor = u_params.x; + float frameCount = u_params.w; + + vec3 current = texture(currentFrame, v_uv).rgb; + + // First frame: no history, just output current + if (frameCount < 1.5) { + o_color = vec4(current, 1.0); + return; + } + + // Sample 3x3 neighborhood of current frame for clamping + vec3 nMin = current; + vec3 nMax = current; + + for (int y = -1; y <= 1; ++y) { + for (int x = -1; x <= 1; ++x) { + if (x == 0 && y == 0) continue; + vec3 s = texture(currentFrame, v_uv + vec2(float(x), float(y)) * texelSize).rgb; + nMin = min(nMin, s); + nMax = max(nMax, s); + } + } + + // Slightly expand the bounding box to reduce flickering + vec3 nCenter = (nMin + nMax) * 0.5; + vec3 nExtent = (nMax - nMin) * 0.5; + nMin = nCenter - nExtent * 1.25; + nMax = nCenter + nExtent * 1.25; + + // Sample history and clamp to neighborhood (prevents ghosting) + vec3 history = texture(historyFrame, v_uv).rgb; + vec3 historyTM = tonemap(history); + vec3 clampedTM = clamp(historyTM, tonemap(nMin), tonemap(nMax)); + vec3 clamped = untonemap(clampedTM); + + // Blend: low factor = more history = smoother but more ghosting + vec3 result = mix(clamped, current, blendFactor); + + o_color = vec4(result, 1.0); +} diff --git a/gameengine/packages/seed/shaders/spirv/postfx_taa.frag.spv b/gameengine/packages/seed/shaders/spirv/postfx_taa.frag.spv new file mode 100644 index 000000000..ebf87da6b Binary files /dev/null and b/gameengine/packages/seed/shaders/spirv/postfx_taa.frag.spv differ diff --git a/gameengine/packages/seed/workflows/frame_tick.json b/gameengine/packages/seed/workflows/frame_tick.json index 5ac228c36..871b1d0c7 100644 --- a/gameengine/packages/seed/workflows/frame_tick.json +++ b/gameengine/packages/seed/workflows/frame_tick.json @@ -440,6 +440,16 @@ "typeVersion": 1, "position": [1200, 0] }, + { + "id": "postfx_taa", + "name": "TAA Resolve", + "type": "postfx.taa", + "typeVersion": 1, + "position": [1250, 0], + "parameters": { + "blend_factor": 0.02 + } + }, { "id": "postfx_ssao", "name": "SSAO Pass", @@ -631,6 +641,11 @@ } }, "end_scene": { + "main": { + "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] + } + }, + "postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } diff --git a/gameengine/packages/seed/workflows/seed_game.json b/gameengine/packages/seed/workflows/seed_game.json index f9ddbf324..c67d8abc3 100644 --- a/gameengine/packages/seed/workflows/seed_game.json +++ b/gameengine/packages/seed/workflows/seed_game.json @@ -115,6 +115,11 @@ "name": "postfx_bloom_blur_frag_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/postfx_bloom_blur.frag.metal" + }, + "postfx_taa_frag_path": { + "name": "postfx_taa_frag_path", + "type": "string", + "defaultValue": "packages/seed/shaders/spirv/postfx_taa.frag.spv" } }, "nodes": [ @@ -1056,6 +1061,22 @@ "shader_path": "postfx_bloom_blur_frag_path" } }, + { + "id": "compile_taa_frag", + "name": "Compile TAA Fragment", + "type": "graphics.gpu.shader.compile", + "typeVersion": 1, + "position": [1315, 200], + "parameters": { + "stage": "fragment", + "output_key": "taa_fragment_shader", + "num_uniform_buffers": 1, + "num_samplers": 2 + }, + "inputs": { + "shader_path": "postfx_taa_frag_path" + } + }, { "id": "create_ssao_pipeline", "name": "Create SSAO Pipeline", @@ -1452,6 +1473,11 @@ } }, "compile_postfx_bloom_blur_frag": { + "main": { + "0": [{ "node": "compile_taa_frag", "type": "main", "index": 0 }] + } + }, + "compile_taa_frag": { "main": { "0": [{ "node": "create_ssao_pipeline", "type": "main", "index": 0 }] } diff --git a/gameengine/src/services/impl/workflow/graphics/workflow_texture_load_step.cpp b/gameengine/src/services/impl/workflow/graphics/workflow_texture_load_step.cpp index 28912cab4..63fc65e84 100644 --- a/gameengine/src/services/impl/workflow/graphics/workflow_texture_load_step.cpp +++ b/gameengine/src/services/impl/workflow/graphics/workflow_texture_load_step.cpp @@ -53,15 +53,21 @@ void WorkflowTextureLoadStep::Execute(const WorkflowStepDefinition& step, Workfl throw std::runtime_error("texture.load: GPU device not found in context"); } - // Create GPU texture + // Calculate mip levels: floor(log2(max(w,h))) + 1 + int maxDim = std::max(w, h); + Uint32 numLevels = 1; + while (maxDim > 1) { maxDim >>= 1; numLevels++; } + + // Create GPU texture with mip chain SDL_GPUTextureCreateInfo tex_info = {}; tex_info.type = SDL_GPU_TEXTURETYPE_2D; tex_info.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; tex_info.width = static_cast(w); tex_info.height = static_cast(h); tex_info.layer_count_or_depth = 1; - tex_info.num_levels = 1; - tex_info.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER; + tex_info.num_levels = numLevels; + tex_info.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER | + (numLevels > 1 ? SDL_GPU_TEXTUREUSAGE_COLOR_TARGET : 0); SDL_GPUTexture* texture = SDL_CreateGPUTexture(device, &tex_info); if (!texture) { @@ -105,6 +111,12 @@ void WorkflowTextureLoadStep::Execute(const WorkflowStepDefinition& step, Workfl SDL_UploadToGPUTexture(copy_pass, &src, &dst, false); SDL_EndGPUCopyPass(copy_pass); + + // Generate mipmaps from the uploaded base level + if (numLevels > 1) { + SDL_GenerateMipmapsForGPUTexture(cmd, texture); + } + SDL_SubmitGPUCommandBuffer(cmd); SDL_ReleaseGPUTransferBuffer(device, transfer); @@ -116,6 +128,11 @@ void WorkflowTextureLoadStep::Execute(const WorkflowStepDefinition& step, Workfl samp_info.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_REPEAT; samp_info.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_REPEAT; samp_info.address_mode_w = SDL_GPU_SAMPLERADDRESSMODE_REPEAT; + samp_info.enable_anisotropy = true; + samp_info.max_anisotropy = 16.0f; + samp_info.mip_lod_bias = 0.5f; // bias toward higher mip = less aliasing at distance + samp_info.min_lod = 0.0f; + samp_info.max_lod = static_cast(numLevels); SDL_GPUSampler* sampler = SDL_CreateGPUSampler(device, &samp_info); if (!sampler) { diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_postfx_taa_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_postfx_taa_step.cpp new file mode 100644 index 000000000..dcd860c6d --- /dev/null +++ b/gameengine/src/services/impl/workflow/rendering/workflow_postfx_taa_step.cpp @@ -0,0 +1,165 @@ +#include "services/interfaces/workflow/rendering/workflow_postfx_taa_step.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +// Halton sequence for sub-pixel jitter (low discrepancy, covers pixel well) +static float halton(int index, int base) { + float f = 1.0f; + float r = 0.0f; + int i = index; + while (i > 0) { + f /= static_cast(base); + r += f * (i % base); + i /= base; + } + return r; +} + +WorkflowPostfxTaaStep::WorkflowPostfxTaaStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowPostfxTaaStep::GetPluginId() const { + return "postfx.taa"; +} + +void WorkflowPostfxTaaStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + if (context.GetBool("frame_skip", false)) return; + + auto* cmd = context.Get("gpu_command_buffer", nullptr); + auto* device = context.Get("gpu_device", nullptr); + auto* hdrTex = context.Get("postfx_hdr_texture", nullptr); + if (!cmd || !device || !hdrTex) return; + + WorkflowStepParameterResolver params; + auto getNum = [&](const char* name, float def) -> float { + const auto* p = params.FindParameter(step, name); + return (p && p->type == WorkflowParameterValue::Type::Number) ? static_cast(p->numberValue) : def; + }; + + float blendFactor = getNum("blend_factor", 0.05f); + + uint32_t w = context.Get("postfx_hdr_width", 0u); + uint32_t h = context.Get("postfx_hdr_height", 0u); + if (w == 0 || h == 0) return; + + // Track frame count for jitter sequence + double frameCount = context.Get("taa_frame_count", 0.0); + frameCount += 1.0; + context.Set("taa_frame_count", frameCount); + int frameIdx = static_cast(frameCount); + + // --- Apply jitter to projection matrix for NEXT frame --- + // This ensures the current frame was rendered with jitter applied last frame + auto projMatrix = context.Get("render.proj_matrix", glm::mat4(1.0f)); + float jitterX = (halton(frameIdx % 16 + 1, 2) - 0.5f) / static_cast(w); + float jitterY = (halton(frameIdx % 16 + 1, 3) - 0.5f) / static_cast(h); + glm::mat4 jitteredProj = projMatrix; + jitteredProj[2][0] += jitterX * 2.0f; + jitteredProj[2][1] += jitterY * 2.0f; + context.Set("render.proj_matrix", jitteredProj); + + // --- Create or get TAA pipeline --- + auto* taaPipeline = context.Get("postfx_taa_pipeline", nullptr); + if (!taaPipeline) { + // Need TAA shader - look for compiled shader in context + auto* taaShader = context.Get("taa_fragment_shader", nullptr); + auto* fullscreenVert = context.Get("postfx_vertex_shader", nullptr); + if (!taaShader || !fullscreenVert) { + // Shaders not compiled yet - skip TAA this frame + return; + } + + SDL_GPUGraphicsPipelineCreateInfo pipelineInfo = {}; + pipelineInfo.vertex_shader = fullscreenVert; + pipelineInfo.fragment_shader = taaShader; + pipelineInfo.primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + + SDL_GPUColorTargetDescription colorDesc = {}; + colorDesc.format = SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT; + pipelineInfo.target_info.num_color_targets = 1; + pipelineInfo.target_info.color_target_descriptions = &colorDesc; + + taaPipeline = SDL_CreateGPUGraphicsPipeline(device, &pipelineInfo); + if (taaPipeline) { + context.Set("postfx_taa_pipeline", taaPipeline); + } else { + return; + } + } + + // --- Create or get history textures (ping-pong) --- + auto* historyA = context.Get("taa_history_a", nullptr); + auto* historyB = context.Get("taa_history_b", nullptr); + auto taaW = context.Get("taa_width", 0u); + auto taaH = context.Get("taa_height", 0u); + + if (!historyA || !historyB || taaW != w || taaH != h) { + if (historyA) SDL_ReleaseGPUTexture(device, historyA); + if (historyB) SDL_ReleaseGPUTexture(device, historyB); + + SDL_GPUTextureCreateInfo texInfo = {}; + texInfo.type = SDL_GPU_TEXTURETYPE_2D; + texInfo.format = SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT; + texInfo.width = w; + texInfo.height = h; + texInfo.layer_count_or_depth = 1; + texInfo.num_levels = 1; + texInfo.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER; + + historyA = SDL_CreateGPUTexture(device, &texInfo); + historyB = SDL_CreateGPUTexture(device, &texInfo); + context.Set("taa_history_a", historyA); + context.Set("taa_history_b", historyB); + context.Set("taa_width", w); + context.Set("taa_height", h); + context.Set("taa_ping", true); + } + + // Ping-pong: read from one history, write to the other + bool ping = context.GetBool("taa_ping", true); + SDL_GPUTexture* historyRead = ping ? historyA : historyB; + SDL_GPUTexture* historyWrite = ping ? historyB : historyA; + context.Set("taa_ping", !ping); + + auto* sampler = context.Get("postfx_linear_sampler", nullptr); + if (!sampler) return; + + // --- TAA resolve pass: current HDR + history → historyWrite --- + SDL_GPUColorTargetInfo colorTarget = {}; + colorTarget.texture = historyWrite; + colorTarget.load_op = SDL_GPU_LOADOP_DONT_CARE; + colorTarget.store_op = SDL_GPU_STOREOP_STORE; + + SDL_GPURenderPass* pass = SDL_BeginGPURenderPass(cmd, &colorTarget, 1, nullptr); + if (!pass) return; + + SDL_BindGPUGraphicsPipeline(pass, taaPipeline); + + SDL_GPUTextureSamplerBinding bindings[2] = {}; + bindings[0].texture = hdrTex; + bindings[0].sampler = sampler; + bindings[1].texture = historyRead; + bindings[1].sampler = sampler; + SDL_BindGPUFragmentSamplers(pass, 0, bindings, 2); + + struct { float params[4]; } uniforms; + uniforms.params[0] = blendFactor; + uniforms.params[1] = 1.0f / static_cast(w); + uniforms.params[2] = 1.0f / static_cast(h); + uniforms.params[3] = static_cast(frameCount); + SDL_PushGPUFragmentUniformData(cmd, 0, &uniforms, sizeof(uniforms)); + + SDL_DrawGPUPrimitives(pass, 3, 1, 0, 0); + SDL_EndGPURenderPass(pass); + + // Replace the HDR texture with the TAA result for downstream post-FX + context.Set("postfx_hdr_texture", historyWrite); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/workflow_registrar.cpp b/gameengine/src/services/impl/workflow/workflow_registrar.cpp index 5066cc021..6d2a7c638 100644 --- a/gameengine/src/services/impl/workflow/workflow_registrar.cpp +++ b/gameengine/src/services/impl/workflow/workflow_registrar.cpp @@ -43,6 +43,7 @@ #include "services/interfaces/workflow/rendering/workflow_geometry_create_flashlight_step.hpp" #include "services/interfaces/workflow/rendering/workflow_map_load_step.hpp" #include "services/interfaces/workflow/rendering/workflow_draw_map_step.hpp" +#include "services/interfaces/workflow/rendering/workflow_postfx_taa_step.hpp" #include "services/interfaces/workflow/rendering/workflow_draw_textured_box_step.hpp" #include "services/interfaces/workflow/rendering/workflow_shadow_setup_step.hpp" #include "services/interfaces/workflow/rendering/workflow_shadow_pass_step.hpp" @@ -280,6 +281,7 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr reg registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); diff --git a/gameengine/src/services/interfaces/workflow/rendering/workflow_postfx_taa_step.hpp b/gameengine/src/services/interfaces/workflow/rendering/workflow_postfx_taa_step.hpp new file mode 100644 index 000000000..6776ce5c0 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/rendering/workflow_postfx_taa_step.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +class WorkflowPostfxTaaStep : public IWorkflowStep { +public: + explicit WorkflowPostfxTaaStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl