diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index a3f3c9c6e..86342106e 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -305,6 +305,8 @@ if(BUILD_SDL3_APP) src/services/impl/workflow/rendering/workflow_model_load_step.cpp src/services/impl/workflow/rendering/workflow_postfx_bloom_blur_step.cpp src/services/impl/workflow/rendering/workflow_postfx_bloom_extract_step.cpp + src/services/impl/workflow/rendering/workflow_debug_screenshot_step.cpp + src/services/impl/workflow/rendering/workflow_overlay_fps_step.cpp 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 diff --git a/gameengine/packages/bootstrap_windows/workflows/boot.json b/gameengine/packages/bootstrap_windows/workflows/boot.json index f4a466b8f..a493c485c 100644 --- a/gameengine/packages/bootstrap_windows/workflows/boot.json +++ b/gameengine/packages/bootstrap_windows/workflows/boot.json @@ -16,7 +16,9 @@ "type": "graphics.gpu.init_viewport", "typeVersion": 1, "position": [-390, 0], - "parameters": {} + "parameters": { + "present_mode": "auto" + } }, { "id": "init_gpu_renderer", diff --git a/gameengine/packages/quake3/shaders/spirv/overlay.frag.glsl b/gameengine/packages/quake3/shaders/spirv/overlay.frag.glsl new file mode 100644 index 000000000..d71b1ec73 --- /dev/null +++ b/gameengine/packages/quake3/shaders/spirv/overlay.frag.glsl @@ -0,0 +1,10 @@ +#version 450 + +layout(location = 0) in vec2 v_uv; +layout(location = 0) out vec4 out_color; + +layout(set = 2, binding = 0) uniform sampler2D font_tex; + +void main() { + out_color = texture(font_tex, v_uv); +} diff --git a/gameengine/packages/quake3/shaders/spirv/overlay.frag.spv b/gameengine/packages/quake3/shaders/spirv/overlay.frag.spv new file mode 100644 index 000000000..9bcedad17 Binary files /dev/null and b/gameengine/packages/quake3/shaders/spirv/overlay.frag.spv differ diff --git a/gameengine/packages/quake3/shaders/spirv/overlay.vert.glsl b/gameengine/packages/quake3/shaders/spirv/overlay.vert.glsl new file mode 100644 index 000000000..43302f4e9 --- /dev/null +++ b/gameengine/packages/quake3/shaders/spirv/overlay.vert.glsl @@ -0,0 +1,11 @@ +#version 450 + +layout(location = 0) in vec3 in_pos; +layout(location = 1) in vec2 in_uv; + +layout(location = 0) out vec2 v_uv; + +void main() { + gl_Position = vec4(in_pos, 1.0); + v_uv = in_uv; +} diff --git a/gameengine/packages/quake3/shaders/spirv/overlay.vert.spv b/gameengine/packages/quake3/shaders/spirv/overlay.vert.spv new file mode 100644 index 000000000..cbde274f4 Binary files /dev/null and b/gameengine/packages/quake3/shaders/spirv/overlay.vert.spv differ diff --git a/gameengine/packages/quake3/workflows/q3_frame.json b/gameengine/packages/quake3/workflows/q3_frame.json index e3311e674..5c5bb45a7 100644 --- a/gameengine/packages/quake3/workflows/q3_frame.json +++ b/gameengine/packages/quake3/workflows/q3_frame.json @@ -14,14 +14,15 @@ "typeVersion": 1, "position": [200, 0], "parameters": { - "move_speed": 4.5, + "move_speed": 6.5, "sprint_multiplier": 1.5, "crouch_multiplier": 0.45, "jump_velocity": 5.5, "air_control": 0.25, - "gravity_scale": 0.55, + "gravity_scale": 0.5, "ground_accel": 30.0, "ground_friction": 24.0, + "step_height": 0.6, "crouch_height": 0.5, "stand_height": 1.4 } @@ -86,6 +87,12 @@ "typeVersion": 1, "position": [1200, 0] }, + { + "id": "overlay_fps", + "type": "overlay.fps", + "typeVersion": 1, + "position": [1225, 0] + }, { "id": "postfx_taa", "type": "postfx.taa", @@ -126,6 +133,12 @@ "camera_update": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } }, "render_prepare": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } }, "frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } }, - "draw_map": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } } + "draw_map": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } }, + "end_scene": { "main": { "0": [{ "node": "overlay_fps", "type": "main", "index": 0 }] } }, + "overlay_fps": { "main": { "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] } }, + "postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } }, + "postfx_ssao": { "main": { "0": [{ "node": "bloom_extract", "type": "main", "index": 0 }] } }, + "bloom_extract": { "main": { "0": [{ "node": "bloom_blur", "type": "main", "index": 0 }] } }, + "bloom_blur": { "main": { "0": [{ "node": "postfx_composite", "type": "main", "index": 0 }] } } } } diff --git a/gameengine/packages/quake3/workflows/q3_game.json b/gameengine/packages/quake3/workflows/q3_game.json index 59d203ac9..38416448d 100644 --- a/gameengine/packages/quake3/workflows/q3_game.json +++ b/gameengine/packages/quake3/workflows/q3_game.json @@ -7,6 +7,7 @@ "window_height": { "name": "window_height", "type": "number", "defaultValue": 960 }, "window_title": { "name": "window_title", "type": "string", "defaultValue": "Quake 3 - Map Viewer" }, "renderer_type": { "name": "renderer_type", "type": "string", "defaultValue": "auto" }, + "present_mode": { "name": "present_mode", "type": "string", "defaultValue": "mailbox" }, "shader_vertex_path": { "name": "shader_vertex_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.vert.metal" }, "shader_fragment_path": { "name": "shader_fragment_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.frag.metal" }, "shader_textured_vert_path": { "name": "shader_textured_vert_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/textured.vert.metal" }, @@ -19,7 +20,7 @@ { "id": "sdl_init", "type": "sdl.init", "typeVersion": 1, "position": [0, 0] }, { "id": "sdl_window", "type": "sdl.window.create", "typeVersion": 1, "position": [200, 0] }, { "id": "gpu_init_viewport", "type": "graphics.gpu.init_viewport", "typeVersion": 1, "position": [400, 0], - "parameters": { "inputs": { "width": "window_width", "height": "window_height" }, "outputs": { "viewport_config": "viewport_config" } } }, + "parameters": { "present_mode": "auto", "inputs": { "width": "window_width", "height": "window_height" }, "outputs": { "viewport_config": "viewport_config" } } }, { "id": "gpu_init_renderer", "type": "graphics.gpu.init_renderer", "typeVersion": 1, "position": [600, 0], "parameters": { "inputs": { "renderer_type": "renderer_type" }, "outputs": { "selected_renderer": "selected_renderer" } } }, { "id": "gpu_init", "type": "graphics.gpu.init", "typeVersion": 1, "position": [800, 0], diff --git a/gameengine/python/dev_commands.py b/gameengine/python/dev_commands.py index f9cf66573..9ae224642 100755 --- a/gameengine/python/dev_commands.py +++ b/gameengine/python/dev_commands.py @@ -575,45 +575,23 @@ def msvc_quick(args: argparse.Namespace) -> None: def _sync_assets(build_dir: str, dry_run: bool) -> None: """ - Sync asset files (scripts, shaders, models) from the project root to the - build directory before running the application. + Sync asset files (packages/, shaders, workflows, MaterialX) from the project + root to the build directory before running the application. Uses copytree + so subdirectories and binary assets (*.spv, *.dxil, textures) are included. """ import shutil build_path = Path(build_dir) project_root = Path(".") - # Define asset directories to sync - asset_dirs = [ - ("packages", ["*.json"]), - ] asset_trees = [ + "packages", "MaterialX/libraries", "MaterialX/resources", ] print("\n=== Syncing Assets ===") - for src_dir, patterns in asset_dirs: - src_path = project_root / src_dir - dst_path = build_path / src_dir - - if not src_path.exists(): - continue - - # Create destination directory if needed - if not dry_run: - dst_path.mkdir(parents=True, exist_ok=True) - - # Sync files matching patterns - for pattern in patterns: - for src_file in src_path.glob(pattern): - if src_file.is_file() and src_file.name != "dev_commands.py": - dst_file = dst_path / src_file.name - print(f" {src_file} -> {dst_file}") - if not dry_run: - shutil.copy2(src_file, dst_file) - for src_dir in asset_trees: src_path = project_root / src_dir dst_path = build_path / src_dir @@ -753,13 +731,54 @@ def gui(args: argparse.Namespace) -> None: self.init_ui() + def _project_root(self) -> "Path": + return Path(__file__).resolve().parent.parent + + def _candidate_build_dirs(self) -> "list[Path]": + """All plausible build directories, ordered newest-configured first.""" + root = self._project_root() + build_types = ["Release", "Debug", "RelWithDebInfo", "MinSizeRel"] + generator_dirs = list(GENERATOR_DEFAULT_DIR.values()) + ["build"] + candidates: list[tuple[float, Path]] = [] + for gen_dir in dict.fromkeys(generator_dirs): + base = root / gen_dir + # Conan nested layout: /build// + for bt in build_types: + nested = base / "build" / bt + cache = nested / "CMakeCache.txt" + if cache.is_file(): + candidates.append((cache.stat().st_mtime, nested)) + # Flat layout: / + cache = base / "CMakeCache.txt" + if cache.is_file(): + candidates.append((cache.stat().st_mtime, base)) + candidates.sort(key=lambda x: x[0], reverse=True) + return [p for _, p in candidates] + + def _find_build_dir(self) -> "Path | None": + """Return the most recently configured build directory.""" + candidates = self._candidate_build_dirs() + return candidates[0] if candidates else None + + def _find_binary(self) -> "str | None": + """Return the path to the most recently built sdl3_app binary, or None.""" + exe_name = "sdl3_app.exe" if IS_WINDOWS else "sdl3_app" + best: tuple[float, str] | None = None + for build_dir in self._candidate_build_dirs(): + exe = build_dir / exe_name + if exe.is_file(): + mtime = exe.stat().st_mtime + if best is None or mtime > best[0]: + best = (mtime, str(exe)) + return best[1] if best else None + def load_bootloader_packages(self): """Load bootloader packages from packages/ directory""" import json from pathlib import Path bootloaders = [] - packages_dir = Path("packages") + packages_dir = Path(__file__).resolve().parent.parent / "packages" if not packages_dir.exists(): return [] @@ -799,7 +818,7 @@ def gui(args: argparse.Namespace) -> None: from pathlib import Path games = [] - packages_dir = Path("packages") + packages_dir = Path(__file__).resolve().parent.parent / "packages" if not packages_dir.exists(): return [] @@ -1262,28 +1281,20 @@ def gui(args: argparse.Namespace) -> None: if not self.current_game: return - base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) - # Check if using Conan nested layout - nested_dir = Path(base_dir) / "build" / self.build_type - if nested_dir.exists(): - build_dir = str(nested_dir) - else: - build_dir = base_dir - - exe_name = "sdl3_app.exe" if IS_WINDOWS else "sdl3_app" - binary = str(Path(build_dir) / exe_name) + binary = self._find_binary() + if not binary: + self.log("❌ Could not find sdl3_app binary. Build the project first (Developer → Build Project).") + return + self.log(f"Binary: {binary}") cmd = [binary] - - # Add bootloader and game package parameters if self.current_bootloader: cmd.extend(["--bootstrap", self.current_bootloader["id"]]) if self.current_game: cmd.extend(["--game", self.current_game["id"]]) - self.log(f"Launching with bootloader: {self.current_bootloader.get('name', 'default') if self.current_bootloader else 'default'}") - self.log(f"Launching with game package: {self.current_game_package.get('name', 'default') if self.current_game_package else 'default'}") - + self.log(f"Bootloader: {self.current_bootloader.get('name', 'default') if self.current_bootloader else 'default'}") + self.log(f"Game: {self.current_game_package.get('name', 'default') if self.current_game_package else 'default'}") self.run_command(cmd) def stop_process(self): @@ -1382,54 +1393,41 @@ def gui(args: argparse.Namespace) -> None: def run_build(self): """Run build command""" - base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) - # Check if using Conan nested layout (has build// subdirectory) - nested_dir = Path(base_dir) / "build" / self.build_type - if nested_dir.exists() and (nested_dir / "CMakeCache.txt").exists(): - build_dir = str(nested_dir) - elif (Path(base_dir) / "CMakeCache.txt").exists(): - build_dir = base_dir - else: - # Default to nested layout (Conan 2.x standard) - build_dir = str(nested_dir) + build_dir = self._find_build_dir() + if not build_dir: + # No configured build found — default to Conan nested layout for chosen generator + root = self._project_root() + build_dir = root / GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) / "build" / self.build_type cmd = [ sys.executable, __file__, "build", - "--build-dir", build_dir, - "--target", self.target + "--build-dir", str(build_dir), + "--target", self.target, ] self.run_command(cmd) def run_tests(self): - """Build (optional) and run tests""" - base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) - # Check if using Conan nested layout - nested_dir = Path(base_dir) / "build" / self.build_type - if nested_dir.exists() and (nested_dir / "CMakeCache.txt").exists(): - build_dir = str(nested_dir) - elif (Path(base_dir) / "CMakeCache.txt").exists(): - build_dir = base_dir - else: - build_dir = str(nested_dir) + """Build and run tests""" + build_dir = self._find_build_dir() + if not build_dir: + root = self._project_root() + build_dir = root / GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) / "build" / self.build_type cmd = [ sys.executable, __file__, "tests", - "--build-dir", build_dir, + "--build-dir", str(build_dir), "--config", self.build_type, - "--target", "all" + "--target", "all", ] self.run_command(cmd) def sync_assets(self): """Sync assets into the active build directory""" - base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) - # Check if using Conan nested layout - nested_dir = Path(base_dir) / "build" / self.build_type - if nested_dir.exists(): - build_dir = str(nested_dir) - else: - build_dir = base_dir + build_dir = self._find_build_dir() + if not build_dir: + self.log("⚠️ No configured build directory found. Run Configure CMake first.") + return self.console.clear() self.log("=== Syncing Assets ===\n") - _sync_assets(build_dir, dry_run=False) + _sync_assets(str(build_dir), dry_run=False) self.log("\n✓ Asset sync completed") app = QApplication(sys.argv) diff --git a/gameengine/src/services/impl/workflow/graphics/workflow_graphics_gpu_init_step.cpp b/gameengine/src/services/impl/workflow/graphics/workflow_graphics_gpu_init_step.cpp index c3751df58..829959790 100644 --- a/gameengine/src/services/impl/workflow/graphics/workflow_graphics_gpu_init_step.cpp +++ b/gameengine/src/services/impl/workflow/graphics/workflow_graphics_gpu_init_step.cpp @@ -86,6 +86,46 @@ void WorkflowGraphicsGpuInitStep::Execute(const WorkflowStepDefinition& step, Wo std::string(SDL_GetError())); } + // Optional present-mode override. Default SDL claim is VSYNC (caps at + // monitor refresh, usually 60Hz). For high-refresh displays we expose: + // "vsync" — capped at refresh, no tearing (safe default) + // "mailbox" — uncapped, no tearing (triple-buffered) + // "immediate" — uncapped, can tear (lowest input latency) + // Read from viewport_config first so this stays JSON-driven without + // needing a new step. + if (viewport_config->contains("present_mode")) { + std::string mode = (*viewport_config)["present_mode"]; + + if (mode == "auto") { + // Query actual monitor refresh rate and pick the best present mode. + // High-refresh displays (≥120 Hz) get VSYNC so frames are delivered + // right on the scanout boundary — smooth and GPU-efficient. + // Low-refresh displays (<120 Hz) get MAILBOX so we aren't capped at + // 60 fps and can stay responsive with a shallow frame queue. + int refresh_hz = 60; + SDL_DisplayID disp = SDL_GetDisplayForWindow(window); + if (disp) { + const SDL_DisplayMode* dm = SDL_GetCurrentDisplayMode(disp); + if (dm && dm->refresh_rate > 0.0f) + refresh_hz = static_cast(dm->refresh_rate); + } + mode = (refresh_hz >= 120) ? "vsync" : "mailbox"; + if (logger_) logger_->Info("graphics.gpu.init: auto present_mode → " + mode + + " (display " + std::to_string(refresh_hz) + " Hz)"); + } + + SDL_GPUPresentMode pm = SDL_GPU_PRESENTMODE_VSYNC; + if (mode == "mailbox") pm = SDL_GPU_PRESENTMODE_MAILBOX; + else if (mode == "immediate") pm = SDL_GPU_PRESENTMODE_IMMEDIATE; + + if (SDL_WindowSupportsGPUPresentMode(device, window, pm)) { + SDL_SetGPUSwapchainParameters(device, window, SDL_GPU_SWAPCHAINCOMPOSITION_SDR, pm); + if (logger_) logger_->Info("graphics.gpu.init: present_mode=" + mode); + } else if (logger_) { + logger_->Warn("graphics.gpu.init: present_mode '" + mode + "' unsupported, falling back to vsync"); + } + } + const char* device_driver = SDL_GetGPUDeviceDriver(device); if (logger_) { logger_->Trace("WorkflowGraphicsGpuInitStep", "Execute", diff --git a/gameengine/src/services/impl/workflow/graphics/workflow_graphics_init_viewport_step.cpp b/gameengine/src/services/impl/workflow/graphics/workflow_graphics_init_viewport_step.cpp index 7ba211490..66e074d81 100644 --- a/gameengine/src/services/impl/workflow/graphics/workflow_graphics_init_viewport_step.cpp +++ b/gameengine/src/services/impl/workflow/graphics/workflow_graphics_init_viewport_step.cpp @@ -1,5 +1,6 @@ #include "services/interfaces/workflow/graphics/workflow_graphics_init_viewport_step.hpp" #include "services/interfaces/workflow/workflow_step_io_resolver.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" #include #include @@ -23,6 +24,16 @@ void WorkflowGraphicsInitViewportStep::Execute(const WorkflowStepDefinition& ste const auto* width = context.TryGet(widthKey); const auto* height = context.TryGet(heightKey); + // Optional present_mode: check step parameters first, then context fallback. + std::string presentMode; + WorkflowStepParameterResolver paramResolver; + if (const auto* p = paramResolver.FindParameter(step, "present_mode")) { + if (p->type == WorkflowParameterValue::Type::String) presentMode = p->stringValue; + } + if (presentMode.empty()) { + if (const auto* p = context.TryGet("present_mode")) presentMode = *p; + } + if (!width || !height) { throw std::runtime_error("graphics.gpu.init_viewport requires width and height inputs"); } @@ -47,6 +58,9 @@ void WorkflowGraphicsInitViewportStep::Execute(const WorkflowStepDefinition& ste {"height", h}, {"aspect_ratio", static_cast(w) / static_cast(h)} }; + if (!presentMode.empty()) { + viewport_config["present_mode"] = presentMode; + } context.Set(outputViewportKey, viewport_config); } diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_debug_screenshot_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_debug_screenshot_step.cpp new file mode 100644 index 000000000..e2485deb8 --- /dev/null +++ b/gameengine/src/services/impl/workflow/rendering/workflow_debug_screenshot_step.cpp @@ -0,0 +1,65 @@ +#include "services/interfaces/workflow/rendering/workflow_debug_screenshot_step.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" + +#include +#include + +#include + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowDebugScreenshotStep::WorkflowDebugScreenshotStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowDebugScreenshotStep::GetPluginId() const { + return "debug.screenshot"; +} + +void WorkflowDebugScreenshotStep::Execute( + const WorkflowStepDefinition& step, WorkflowContext& context) { + + if (saved_) return; + saved_ = true; + + // Write status file so we can see exactly what state everything is in + { + std::ofstream f("debug_screenshot_status.txt"); + f << "frame_skip=" << context.GetBool("frame_skip", false) << "\n"; + f << "overlay_fps_ready=" << context.GetBool("overlay_fps_ready", false) << "\n"; + f << "has_cmd=" << (context.Get("gpu_command_buffer", nullptr) != nullptr) << "\n"; + f << "has_swapchain=" << (context.Get("gpu_swapchain_texture", nullptr) != nullptr) << "\n"; + f << "has_device=" << (context.Get("gpu_device", nullptr) != nullptr) << "\n"; + f << "has_window=" << (context.Get("sdl_window", nullptr) != nullptr) << "\n"; + + auto* device = context.Get("gpu_device", nullptr); + if (device) { + const char* drv = SDL_GetGPUDeviceDriver(device); + f << "gpu_driver=" << (drv ? drv : "null") << "\n"; + } + + f << "frame_width=" << context.Get("frame_width", 0u) << "\n"; + f << "frame_height=" << context.Get("frame_height", 0u) << "\n"; + } + + // Save the overlay SDL surface as BMP — confirms text rasterisation is working + auto* surface = context.Get("overlay_fps_surface", nullptr); + if (surface) { + SDL_SaveBMP(surface, "debug_fps_overlay.bmp"); + if (logger_) logger_->Info("debug.screenshot: saved debug_fps_overlay.bmp"); + } else { + if (logger_) logger_->Warn("debug.screenshot: overlay_fps_surface not in context"); + } + + if (logger_) logger_->Info("debug.screenshot: wrote debug_screenshot_status.txt"); + + // Push a quit event so the game exits automatically after saving + SDL_Event quit; + quit.type = SDL_EVENT_QUIT; + SDL_PushEvent(&quit); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_frame_begin_gpu_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_frame_begin_gpu_step.cpp index 777110d41..3e6f8a58f 100644 --- a/gameengine/src/services/impl/workflow/rendering/workflow_frame_begin_gpu_step.cpp +++ b/gameengine/src/services/impl/workflow/rendering/workflow_frame_begin_gpu_step.cpp @@ -107,6 +107,7 @@ void WorkflowFrameBeginGpuStep::Execute( // Store for subsequent steps context.Set("gpu_command_buffer", cmd); context.Set("gpu_render_pass", pass); + context.Set("gpu_swapchain_texture", swapchainTex); context.Set("frame_skip", false); context.Set("frame_width", sw); context.Set("frame_height", sh); diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_overlay_fps_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_overlay_fps_step.cpp new file mode 100644 index 000000000..7db9fc7cb --- /dev/null +++ b/gameengine/src/services/impl/workflow/rendering/workflow_overlay_fps_step.cpp @@ -0,0 +1,316 @@ +#include "services/interfaces/workflow/rendering/workflow_overlay_fps_step.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowOverlayFpsStep::WorkflowOverlayFpsStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +WorkflowOverlayFpsStep::~WorkflowOverlayFpsStep() { + if (renderer_) { SDL_DestroyRenderer(renderer_); renderer_ = nullptr; } + if (surface_) { SDL_DestroySurface(surface_); surface_ = nullptr; } + if (device_) { + if (sampler_) SDL_ReleaseGPUSampler(device_, sampler_); + if (vtx_buf_) SDL_ReleaseGPUBuffer(device_, vtx_buf_); + if (transfer_) SDL_ReleaseGPUTransferBuffer(device_, transfer_); + if (tex_) SDL_ReleaseGPUTexture(device_, tex_); + if (pipeline_) SDL_ReleaseGPUGraphicsPipeline(device_, pipeline_); + } +} + +std::string WorkflowOverlayFpsStep::GetPluginId() const { + return "overlay.fps"; +} + +void WorkflowOverlayFpsStep::TryInit(SDL_GPUDevice* device, SDL_Window* window) { + device_ = device; + + auto loadSpv = [](const char* path) -> std::vector { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f.is_open()) return {}; + auto sz = f.tellg(); + std::vector buf(static_cast(sz)); + f.seekg(0); + f.read(reinterpret_cast(buf.data()), sz); + return buf; + }; + + auto vert_spv = loadSpv("packages/quake3/shaders/spirv/overlay.vert.spv"); + auto frag_spv = loadSpv("packages/quake3/shaders/spirv/overlay.frag.spv"); + if (vert_spv.empty() || frag_spv.empty()) { + if (logger_) logger_->Warn("overlay.fps: shaders not found — FPS display disabled"); + return; + } + + SDL_GPUShaderCreateInfo vs_info = {}; + vs_info.code = vert_spv.data(); + vs_info.code_size = vert_spv.size(); + vs_info.entrypoint = "main"; + vs_info.format = SDL_GPU_SHADERFORMAT_SPIRV; + vs_info.stage = SDL_GPU_SHADERSTAGE_VERTEX; + + SDL_GPUShaderCreateInfo fs_info = {}; + fs_info.code = frag_spv.data(); + fs_info.code_size = frag_spv.size(); + fs_info.entrypoint = "main"; + fs_info.format = SDL_GPU_SHADERFORMAT_SPIRV; + fs_info.stage = SDL_GPU_SHADERSTAGE_FRAGMENT; + fs_info.num_samplers = 1; + + SDL_GPUShader* vs = SDL_CreateGPUShader(device, &vs_info); + SDL_GPUShader* fs = SDL_CreateGPUShader(device, &fs_info); + if (!vs || !fs) { + if (vs) SDL_ReleaseGPUShader(device, vs); + if (fs) SDL_ReleaseGPUShader(device, fs); + return; + } + + SDL_GPUVertexBufferDescription vbuf_desc = {}; + vbuf_desc.slot = 0; + vbuf_desc.pitch = sizeof(float) * 5; + vbuf_desc.input_rate = SDL_GPU_VERTEXINPUTRATE_VERTEX; + + SDL_GPUVertexAttribute attrs[2] = {}; + attrs[0] = { 0, 0, SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, 0 }; + attrs[1] = { 1, 0, SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, sizeof(float) * 3 }; + + SDL_GPUVertexInputState vis = {}; + vis.vertex_buffer_descriptions = &vbuf_desc; + vis.num_vertex_buffers = 1; + vis.vertex_attributes = attrs; + vis.num_vertex_attributes = 2; + + SDL_GPUTextureFormat sc_fmt = SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; + if (window) sc_fmt = SDL_GetGPUSwapchainTextureFormat(device, window); + + SDL_GPUColorTargetDescription ctd = {}; + ctd.format = sc_fmt; + ctd.blend_state.enable_blend = true; + ctd.blend_state.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA; + ctd.blend_state.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + ctd.blend_state.color_blend_op = SDL_GPU_BLENDOP_ADD; + ctd.blend_state.src_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE; + ctd.blend_state.dst_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ZERO; + ctd.blend_state.alpha_blend_op = SDL_GPU_BLENDOP_ADD; + + SDL_GPUGraphicsPipelineCreateInfo pci = {}; + pci.vertex_shader = vs; + pci.fragment_shader = fs; + pci.vertex_input_state = vis; + pci.primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + pci.rasterizer_state.fill_mode = SDL_GPU_FILLMODE_FILL; + pci.rasterizer_state.cull_mode = SDL_GPU_CULLMODE_NONE; + pci.rasterizer_state.front_face = SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE; + pci.depth_stencil_state.enable_depth_test = false; + pci.depth_stencil_state.enable_depth_write = false; + pci.target_info.num_color_targets = 1; + pci.target_info.color_target_descriptions = &ctd; + pci.target_info.has_depth_stencil_target = false; + + pipeline_ = SDL_CreateGPUGraphicsPipeline(device, &pci); + SDL_ReleaseGPUShader(device, vs); + SDL_ReleaseGPUShader(device, fs); + if (!pipeline_) return; + + SDL_GPUTextureCreateInfo tci = {}; + tci.type = SDL_GPU_TEXTURETYPE_2D; + tci.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; + tci.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER; + tci.width = static_cast(kW); + tci.height = static_cast(kH); + tci.layer_count_or_depth = 1; + tci.num_levels = 1; + tex_ = SDL_CreateGPUTexture(device, &tci); + if (!tex_) { SDL_ReleaseGPUGraphicsPipeline(device, pipeline_); pipeline_ = nullptr; return; } + + SDL_GPUTransferBufferCreateInfo tbci = {}; + tbci.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; + tbci.size = static_cast(kW * kH * 4); + transfer_ = SDL_CreateGPUTransferBuffer(device, &tbci); + if (!transfer_) { SDL_ReleaseGPUTexture(device, tex_); tex_ = nullptr; return; } + + SDL_GPUBufferCreateInfo bci = {}; + bci.usage = SDL_GPU_BUFFERUSAGE_VERTEX; + bci.size = 6u * 5u * static_cast(sizeof(float)); + vtx_buf_ = SDL_CreateGPUBuffer(device, &bci); + if (!vtx_buf_) { SDL_ReleaseGPUTransferBuffer(device, transfer_); transfer_ = nullptr; return; } + + SDL_GPUSamplerCreateInfo sci = {}; + sci.min_filter = SDL_GPU_FILTER_NEAREST; + sci.mag_filter = SDL_GPU_FILTER_NEAREST; + sci.mipmap_mode = SDL_GPU_SAMPLERMIPMAPMODE_NEAREST; + sci.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + sci.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + sampler_ = SDL_CreateGPUSampler(device, &sci); + if (!sampler_) { SDL_ReleaseGPUBuffer(device, vtx_buf_); vtx_buf_ = nullptr; return; } + + surface_ = SDL_CreateSurface(kW, kH, SDL_PIXELFORMAT_RGBA32); + if (!surface_) { SDL_ReleaseGPUSampler(device, sampler_); sampler_ = nullptr; return; } + + renderer_ = SDL_CreateSoftwareRenderer(surface_); + if (!renderer_) { + SDL_DestroySurface(surface_); surface_ = nullptr; + SDL_ReleaseGPUSampler(device, sampler_); sampler_ = nullptr; + return; + } + + ready_ = true; + if (logger_) logger_->Info("overlay.fps: FPS overlay initialised (" + std::string(SDL_GetGPUDeviceDriver(device)) + ")"); +} + +void WorkflowOverlayFpsStep::Render(SDL_GPUCommandBuffer* cmd, + SDL_GPUTexture* swapchain_tex, + SDL_GPUDevice* device, + uint32_t frame_w, uint32_t frame_h) { + // FPS measurement — exponential moving average + const uint64_t now_ns = SDL_GetTicksNS(); + if (fps_last_ns_ != 0) { + const float raw_dt = static_cast( + static_cast(now_ns - fps_last_ns_) / 1e9); + if (raw_dt > 0.0f) { + const float raw_fps = 1.0f / raw_dt; + fps_smooth_ = fps_smooth_ * 0.9f + raw_fps * 0.1f; + } + } + fps_last_ns_ = now_ns; + + // Rasterise FPS text onto SDL surface + char buf[32]; + std::snprintf(buf, sizeof(buf), "FPS: %.0f", fps_smooth_); + + SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 0); + SDL_RenderClear(renderer_); + SDL_SetRenderDrawColor(renderer_, 255, 220, 50, 255); + SDL_RenderDebugText(renderer_, 5.0f, 2.0f, buf); + SDL_RenderPresent(renderer_); // flush batch to surface_->pixels + + // Upload surface pixels to GPU texture + void* mapped = SDL_MapGPUTransferBuffer(device, transfer_, false); + if (!mapped) return; + std::memcpy(mapped, surface_->pixels, static_cast(kW * kH * 4)); + SDL_UnmapGPUTransferBuffer(device, transfer_); + + SDL_GPUCopyPass* copy_pass = SDL_BeginGPUCopyPass(cmd); + if (copy_pass) { + SDL_GPUTextureTransferInfo src = {}; + src.transfer_buffer = transfer_; + src.pixels_per_row = static_cast(kW); + src.rows_per_layer = static_cast(kH); + + SDL_GPUTextureRegion dst = {}; + dst.texture = tex_; + dst.w = static_cast(kW); + dst.h = static_cast(kH); + dst.d = 1; + + SDL_UploadToGPUTexture(copy_pass, &src, &dst, false); + SDL_EndGPUCopyPass(copy_pass); + } + + // Upload vertex buffer once (quad in top-right corner) + if (!vbuf_uploaded_) { + const float vw = static_cast(frame_w > 0 ? frame_w : 1280); + const float vh = static_cast(frame_h > 0 ? frame_h : 960); + + const float px_l = vw - static_cast(kW) - 10.0f; + const float px_r = vw - 10.0f; + const float py_t = 10.0f; + const float py_b = 10.0f + static_cast(kH); + + // Pixel → NDC (y=0 is top → NDC y=+1) + const float x0 = px_l / vw * 2.0f - 1.0f; + const float x1 = px_r / vw * 2.0f - 1.0f; + const float y1 = 1.0f - py_t / vh * 2.0f; + const float y0 = 1.0f - py_b / vh * 2.0f; + + const float verts[6][5] = { + { x0, y1, 0.0f, 0.0f, 0.0f }, + { x1, y1, 0.0f, 1.0f, 0.0f }, + { x1, y0, 0.0f, 1.0f, 1.0f }, + { x0, y1, 0.0f, 0.0f, 0.0f }, + { x1, y0, 0.0f, 1.0f, 1.0f }, + { x0, y0, 0.0f, 0.0f, 1.0f }, + }; + + const Uint32 vbuf_size = 6u * 5u * static_cast(sizeof(float)); + SDL_GPUTransferBufferCreateInfo vtbci = {}; + vtbci.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; + vtbci.size = vbuf_size; + SDL_GPUTransferBuffer* vtb = SDL_CreateGPUTransferBuffer(device, &vtbci); + if (vtb) { + void* vptr = SDL_MapGPUTransferBuffer(device, vtb, false); + if (vptr) { + std::memcpy(vptr, verts, vbuf_size); + SDL_UnmapGPUTransferBuffer(device, vtb); + } + SDL_GPUCopyPass* vcp = SDL_BeginGPUCopyPass(cmd); + if (vcp) { + SDL_GPUTransferBufferLocation btinfo = { vtb, 0 }; + SDL_GPUBufferRegion bregion = { vtx_buf_, 0, vbuf_size }; + SDL_UploadToGPUBuffer(vcp, &btinfo, &bregion, false); + SDL_EndGPUCopyPass(vcp); + } + SDL_ReleaseGPUTransferBuffer(device, vtb); + } + vbuf_uploaded_ = true; + } + + // Overlay render pass — LOADOP_LOAD preserves existing scene + SDL_GPUColorTargetInfo ov_target = {}; + ov_target.texture = swapchain_tex; + ov_target.load_op = SDL_GPU_LOADOP_LOAD; + ov_target.store_op = SDL_GPU_STOREOP_STORE; + + SDL_GPURenderPass* ov_pass = SDL_BeginGPURenderPass(cmd, &ov_target, 1, nullptr); + if (!ov_pass) return; + + SDL_BindGPUGraphicsPipeline(ov_pass, pipeline_); + + SDL_GPUBufferBinding vb = { vtx_buf_, 0 }; + SDL_BindGPUVertexBuffers(ov_pass, 0, &vb, 1); + + SDL_GPUTextureSamplerBinding tsb = { tex_, sampler_ }; + SDL_BindGPUFragmentSamplers(ov_pass, 0, &tsb, 1); + + SDL_DrawGPUPrimitives(ov_pass, 6, 1, 0, 0); + SDL_EndGPURenderPass(ov_pass); + // Command buffer NOT submitted here — postfx.composite handles final submit +} + +void WorkflowOverlayFpsStep::Execute( + const WorkflowStepDefinition& step, WorkflowContext& context) { + + if (context.GetBool("frame_skip", false)) return; + + auto* cmd = context.Get("gpu_command_buffer", nullptr); + auto* swapchain = context.Get("gpu_swapchain_texture", nullptr); + auto* device = context.Get("gpu_device", nullptr); + + if (!cmd || !swapchain || !device) return; + + if (!ready_) { + auto* window = context.Get("sdl_window", nullptr); + TryInit(device, window); + } + + // Expose init status for debug.screenshot + context.Set("overlay_fps_ready", ready_); + context.Set("overlay_fps_surface", surface_); + + if (!ready_) return; + + const auto fw = context.Get("frame_width", 1280u); + const auto fh = context.Get("frame_height", 960u); + Render(cmd, swapchain, device, fw, fh); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp index d9e88b8eb..e91305551 100644 --- a/gameengine/src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp +++ b/gameengine/src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp @@ -2,31 +2,332 @@ #include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" #include +#include +#include #include +#include namespace sdl3cpp::services::impl { WorkflowPostfxCompositeStep::WorkflowPostfxCompositeStep(std::shared_ptr logger) : logger_(std::move(logger)) {} +WorkflowPostfxCompositeStep::~WorkflowPostfxCompositeStep() { + if (fps_renderer_) { SDL_DestroyRenderer(fps_renderer_); fps_renderer_ = nullptr; } + if (fps_surface_) { SDL_DestroySurface(fps_surface_); fps_surface_ = nullptr; } + if (overlay_device_) { + if (overlay_sampler_) SDL_ReleaseGPUSampler(overlay_device_, overlay_sampler_); + if (overlay_vtx_buf_) SDL_ReleaseGPUBuffer(overlay_device_, overlay_vtx_buf_); + if (overlay_transfer_) SDL_ReleaseGPUTransferBuffer(overlay_device_, overlay_transfer_); + if (overlay_tex_) SDL_ReleaseGPUTexture(overlay_device_, overlay_tex_); + if (overlay_pipeline_) SDL_ReleaseGPUGraphicsPipeline(overlay_device_, overlay_pipeline_); + } +} + std::string WorkflowPostfxCompositeStep::GetPluginId() const { return "postfx.composite"; } +// --------------------------------------------------------------------------- +// Overlay initialisation — called once on the first frame where a GPU device +// is available. Silent no-op if shaders aren't found (debug feature). +// --------------------------------------------------------------------------- +void WorkflowPostfxCompositeStep::TryInitOverlay(SDL_GPUDevice* device, + WorkflowContext& context) { + overlay_device_ = device; + + // Load overlay SPIR-V shaders from disk + auto loadSpv = [](const char* path) -> std::vector { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f.is_open()) return {}; + auto sz = f.tellg(); + std::vector buf(static_cast(sz)); + f.seekg(0); + f.read(reinterpret_cast(buf.data()), sz); + return buf; + }; + + auto vert_spv = loadSpv("packages/quake3/shaders/spirv/overlay.vert.spv"); + auto frag_spv = loadSpv("packages/quake3/shaders/spirv/overlay.frag.spv"); + if (vert_spv.empty() || frag_spv.empty()) { + if (logger_) logger_->Warn("postfx.composite: overlay shaders not found — FPS display disabled"); + return; + } + + // Only SPIRV supported (D3D12/Metal paths would need DXIL/MSL variants) + SDL_GPUShaderFormat fmt = SDL_GPU_SHADERFORMAT_SPIRV; + const char* drv = SDL_GetGPUDeviceDriver(device); + if (drv && std::string(drv) != "vulkan") { + if (logger_) logger_->Warn("postfx.composite: overlay only supported on Vulkan — FPS display disabled"); + return; + } + + // Vertex shader — no uniforms, no samplers + SDL_GPUShaderCreateInfo vs_info = {}; + vs_info.code = vert_spv.data(); vs_info.code_size = vert_spv.size(); + vs_info.entrypoint = "main"; vs_info.format = fmt; + vs_info.stage = SDL_GPU_SHADERSTAGE_VERTEX; + + // Fragment shader — 1 sampler (set=2, binding=0) + SDL_GPUShaderCreateInfo fs_info = {}; + fs_info.code = frag_spv.data(); fs_info.code_size = frag_spv.size(); + fs_info.entrypoint = "main"; fs_info.format = fmt; + fs_info.stage = SDL_GPU_SHADERSTAGE_FRAGMENT; + fs_info.num_samplers = 1; + + SDL_GPUShader* vs = SDL_CreateGPUShader(device, &vs_info); + SDL_GPUShader* fs = SDL_CreateGPUShader(device, &fs_info); + if (!vs || !fs) { + if (vs) SDL_ReleaseGPUShader(device, vs); + if (fs) SDL_ReleaseGPUShader(device, fs); + return; + } + + // Vertex format: float3 pos + float2 uv = 20 bytes per vertex + SDL_GPUVertexBufferDescription vbuf = {}; + vbuf.slot = 0; + vbuf.pitch = sizeof(float) * 5; + vbuf.input_rate = SDL_GPU_VERTEXINPUTRATE_VERTEX; + + SDL_GPUVertexAttribute attrs[2] = {}; + attrs[0] = { 0, 0, SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, 0 }; + attrs[1] = { 1, 0, SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, sizeof(float) * 3 }; + + SDL_GPUVertexInputState vis = {}; + vis.vertex_buffer_descriptions = &vbuf; + vis.num_vertex_buffers = 1; + vis.vertex_attributes = attrs; + vis.num_vertex_attributes = 2; + + // Query swapchain format from window for the blend target + SDL_GPUTextureFormat sc_fmt = SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; + auto* window = context.Get("sdl_window", nullptr); + if (window) sc_fmt = SDL_GetGPUSwapchainTextureFormat(device, window); + + SDL_GPUColorTargetDescription ctd = {}; + ctd.format = sc_fmt; + ctd.blend_state.enable_blend = true; + ctd.blend_state.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA; + ctd.blend_state.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + ctd.blend_state.color_blend_op = SDL_GPU_BLENDOP_ADD; + ctd.blend_state.src_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE; + ctd.blend_state.dst_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ZERO; + ctd.blend_state.alpha_blend_op = SDL_GPU_BLENDOP_ADD; + + SDL_GPUGraphicsPipelineCreateInfo pci = {}; + pci.vertex_shader = vs; + pci.fragment_shader = fs; + pci.vertex_input_state = vis; + pci.primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + pci.rasterizer_state.fill_mode = SDL_GPU_FILLMODE_FILL; + pci.rasterizer_state.cull_mode = SDL_GPU_CULLMODE_NONE; + pci.rasterizer_state.front_face = SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE; + pci.depth_stencil_state.enable_depth_test = false; + pci.depth_stencil_state.enable_depth_write = false; + pci.target_info.num_color_targets = 1; + pci.target_info.color_target_descriptions = &ctd; + pci.target_info.has_depth_stencil_target = false; + + overlay_pipeline_ = SDL_CreateGPUGraphicsPipeline(device, &pci); + SDL_ReleaseGPUShader(device, vs); + SDL_ReleaseGPUShader(device, fs); + if (!overlay_pipeline_) return; + + // Font texture (RGBA8 — matches SDL_PIXELFORMAT_RGBA32 memory layout) + SDL_GPUTextureCreateInfo tci = {}; + tci.type = SDL_GPU_TEXTURETYPE_2D; + tci.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; + tci.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER; + tci.width = kOvW; + tci.height = kOvH; + tci.layer_count_or_depth = 1; + tci.num_levels = 1; + overlay_tex_ = SDL_CreateGPUTexture(device, &tci); + if (!overlay_tex_) { SDL_ReleaseGPUGraphicsPipeline(device, overlay_pipeline_); overlay_pipeline_ = nullptr; return; } + + // Transfer buffer for CPU→GPU texture upload each frame + SDL_GPUTransferBufferCreateInfo tbci = {}; + tbci.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; + tbci.size = static_cast(kOvW * kOvH * 4); + overlay_transfer_ = SDL_CreateGPUTransferBuffer(device, &tbci); + if (!overlay_transfer_) { SDL_ReleaseGPUTexture(device, overlay_tex_); overlay_tex_ = nullptr; return; } + + // Vertex buffer: 6 vertices × 5 floats = 120 bytes (static quad, uploaded once) + SDL_GPUBufferCreateInfo bci = {}; + bci.usage = SDL_GPU_BUFFERUSAGE_VERTEX; + bci.size = 6u * 5u * static_cast(sizeof(float)); + overlay_vtx_buf_ = SDL_CreateGPUBuffer(device, &bci); + if (!overlay_vtx_buf_) { SDL_ReleaseGPUTransferBuffer(device, overlay_transfer_); overlay_transfer_ = nullptr; return; } + + // Linear sampler for the font texture + SDL_GPUSamplerCreateInfo sci = {}; + sci.min_filter = SDL_GPU_FILTER_NEAREST; + sci.mag_filter = SDL_GPU_FILTER_NEAREST; + sci.mipmap_mode = SDL_GPU_SAMPLERMIPMAPMODE_NEAREST; + sci.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + sci.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + overlay_sampler_ = SDL_CreateGPUSampler(device, &sci); + if (!overlay_sampler_) { SDL_ReleaseGPUBuffer(device, overlay_vtx_buf_); overlay_vtx_buf_ = nullptr; return; } + + // SDL software renderer for rasterising the FPS string + fps_surface_ = SDL_CreateSurface(kOvW, kOvH, SDL_PIXELFORMAT_RGBA32); + if (!fps_surface_) { SDL_ReleaseGPUSampler(device, overlay_sampler_); overlay_sampler_ = nullptr; return; } + + fps_renderer_ = SDL_CreateSoftwareRenderer(fps_surface_); + if (!fps_renderer_) { + SDL_DestroySurface(fps_surface_); fps_surface_ = nullptr; + SDL_ReleaseGPUSampler(device, overlay_sampler_); overlay_sampler_ = nullptr; + return; + } + + overlay_ready_ = true; + if (logger_) logger_->Info("postfx.composite: FPS overlay initialised"); +} + +// --------------------------------------------------------------------------- +// Per-frame overlay render — called after the composite pass ends but before +// the command buffer is submitted. Uses a second render pass with LOADOP_LOAD +// so the composite output is preserved underneath the text quad. +// --------------------------------------------------------------------------- +void WorkflowPostfxCompositeStep::RenderOverlay(SDL_GPUCommandBuffer* cmd, + SDL_GPUTexture* swapchain_tex, + SDL_GPUDevice* device, + WorkflowContext& context) { + // --- FPS measurement (exponential moving average) --- + const uint64_t now_ns = SDL_GetTicksNS(); + if (fps_last_ns_ != 0) { + const float raw_dt = static_cast( + static_cast(now_ns - fps_last_ns_) / 1e9); + if (raw_dt > 0.0f) { + const float raw_fps = 1.0f / raw_dt; + fps_smooth_ = fps_smooth_ * 0.9f + raw_fps * 0.1f; + } + } + fps_last_ns_ = now_ns; + + // --- Rasterise FPS text onto SDL surface --- + char buf[32]; + std::snprintf(buf, sizeof(buf), "FPS: %.0f", fps_smooth_); + + SDL_ClearSurface(fps_surface_, 0.0f, 0.0f, 0.0f, 0.0f); + SDL_SetRenderDrawColor(fps_renderer_, 255, 220, 50, 255); + SDL_RenderDebugText(fps_renderer_, 5.0f, 2.0f, buf); + + // --- Upload surface pixels to GPU overlay texture --- + void* mapped = SDL_MapGPUTransferBuffer(device, overlay_transfer_, false); + if (!mapped) return; + std::memcpy(mapped, fps_surface_->pixels, + static_cast(kOvW * kOvH * 4)); + SDL_UnmapGPUTransferBuffer(device, overlay_transfer_); + + SDL_GPUCopyPass* copy_pass = SDL_BeginGPUCopyPass(cmd); + if (copy_pass) { + SDL_GPUTextureTransferInfo src = {}; + src.transfer_buffer = overlay_transfer_; + src.pixels_per_row = static_cast(kOvW); + src.rows_per_layer = static_cast(kOvH); + + SDL_GPUTextureRegion dst = {}; + dst.texture = overlay_tex_; + dst.w = static_cast(kOvW); + dst.h = static_cast(kOvH); + dst.d = 1; + + SDL_UploadToGPUTexture(copy_pass, &src, &dst, false); + SDL_EndGPUCopyPass(copy_pass); + } + + // --- Upload vertex buffer (once — positions don't change) --- + if (!vbuf_uploaded_) { + // Read viewport dimensions for NDC calculation + const float vw = static_cast( + context.Get("viewport_width", 1280)); + const float vh = static_cast( + context.Get("viewport_height", 960)); + + // Top-right corner: 10px margin from edges + const float px_l = vw - static_cast(kOvW) - 10.0f; + const float px_r = vw - 10.0f; + const float py_t = 10.0f; + const float py_b = 10.0f + static_cast(kOvH); + + // Convert to NDC (y flipped: pixel y=0 is top → NDC y=+1) + const float x0 = px_l / vw * 2.0f - 1.0f; + const float x1 = px_r / vw * 2.0f - 1.0f; + const float y1 = 1.0f - py_t / vh * 2.0f; + const float y0 = 1.0f - py_b / vh * 2.0f; + + // Two triangles: {x,y,z,u,v} + const float verts[6][5] = { + { x0, y1, 0.0f, 0.0f, 0.0f }, // top-left + { x1, y1, 0.0f, 1.0f, 0.0f }, // top-right + { x1, y0, 0.0f, 1.0f, 1.0f }, // bottom-right + { x0, y1, 0.0f, 0.0f, 0.0f }, // top-left + { x1, y0, 0.0f, 1.0f, 1.0f }, // bottom-right + { x0, y0, 0.0f, 0.0f, 1.0f }, // bottom-left + }; + + const Uint32 vbuf_size = 6u * 5u * static_cast(sizeof(float)); + SDL_GPUTransferBufferCreateInfo vtbci = {}; + vtbci.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; + vtbci.size = vbuf_size; + SDL_GPUTransferBuffer* vtb = SDL_CreateGPUTransferBuffer(device, &vtbci); + if (vtb) { + void* vptr = SDL_MapGPUTransferBuffer(device, vtb, false); + if (vptr) { + std::memcpy(vptr, verts, vbuf_size); + SDL_UnmapGPUTransferBuffer(device, vtb); + } + SDL_GPUCopyPass* vcp = SDL_BeginGPUCopyPass(cmd); + if (vcp) { + SDL_GPUTransferBufferLocation btinfo = { vtb, 0 }; + SDL_GPUBufferRegion bregion = { overlay_vtx_buf_, 0, vbuf_size }; + SDL_UploadToGPUBuffer(vcp, &btinfo, &bregion, false); + SDL_EndGPUCopyPass(vcp); + } + SDL_ReleaseGPUTransferBuffer(device, vtb); + } + vbuf_uploaded_ = true; + } + + // --- Second render pass: LOADOP_LOAD preserves the composite output --- + SDL_GPUColorTargetInfo ov_target = {}; + ov_target.texture = swapchain_tex; + ov_target.load_op = SDL_GPU_LOADOP_LOAD; + ov_target.store_op = SDL_GPU_STOREOP_STORE; + + SDL_GPURenderPass* ov_pass = SDL_BeginGPURenderPass(cmd, &ov_target, 1, nullptr); + if (!ov_pass) return; + + SDL_BindGPUGraphicsPipeline(ov_pass, overlay_pipeline_); + + SDL_GPUBufferBinding vb = { overlay_vtx_buf_, 0 }; + SDL_BindGPUVertexBuffers(ov_pass, 0, &vb, 1); + + SDL_GPUTextureSamplerBinding tsb = { overlay_tex_, overlay_sampler_ }; + SDL_BindGPUFragmentSamplers(ov_pass, 0, &tsb, 1); + + SDL_DrawGPUPrimitives(ov_pass, 6, 1, 0, 0); + SDL_EndGPURenderPass(ov_pass); +} + +// --------------------------------------------------------------------------- +// Execute +// --------------------------------------------------------------------------- void WorkflowPostfxCompositeStep::Execute( const WorkflowStepDefinition& step, WorkflowContext& context) { if (context.GetBool("frame_skip", false)) return; - auto* cmd = context.Get("gpu_command_buffer", nullptr); - auto* pipeline = context.Get("postfx_composite_pipeline", nullptr); - auto* hdr_texture = context.Get("postfx_hdr_texture", nullptr); - auto* sampler = context.Get("postfx_linear_sampler", nullptr); + auto* cmd = context.Get("gpu_command_buffer", nullptr); + auto* pipeline = context.Get("postfx_composite_pipeline", nullptr); + auto* hdr_texture = context.Get("postfx_hdr_texture", nullptr); + auto* sampler = context.Get("postfx_linear_sampler", nullptr); auto* swapchain_tex = context.Get("postfx_swapchain_texture", nullptr); + auto* device = context.Get("gpu_device", nullptr); if (!cmd || !pipeline || !hdr_texture || !sampler || !swapchain_tex) { if (logger_) logger_->Warn("postfx.composite: Missing required resources"); - // Fall through to submit whatever we have if (cmd) { SDL_SubmitGPUCommandBuffer(cmd); context.Remove("gpu_command_buffer"); @@ -34,10 +335,15 @@ void WorkflowPostfxCompositeStep::Execute( return; } - // Begin render pass targeting swapchain + // Lazy-init FPS overlay (first frame only) + if (!overlay_ready_ && device) { + TryInitOverlay(device, context); + } + + // --- Composite pass: HDR → swapchain --- SDL_GPUColorTargetInfo colorTarget = {}; - colorTarget.texture = swapchain_tex; - colorTarget.load_op = SDL_GPU_LOADOP_DONT_CARE; + colorTarget.texture = swapchain_tex; + colorTarget.load_op = SDL_GPU_LOADOP_DONT_CARE; colorTarget.store_op = SDL_GPU_STOREOP_STORE; SDL_GPURenderPass* pass = SDL_BeginGPURenderPass(cmd, &colorTarget, 1, nullptr); @@ -47,33 +353,30 @@ void WorkflowPostfxCompositeStep::Execute( return; } - // Bind composite pipeline (fullscreen triangle, no vertex buffer) SDL_BindGPUGraphicsPipeline(pass, pipeline); - // Bind HDR + SSAO + Bloom textures for sampling - auto* ssao_texture = context.Get("postfx_ssao_texture", nullptr); + auto* ssao_texture = context.Get("postfx_ssao_texture", nullptr); auto* bloom_texture = context.Get("postfx_bloom_result_texture", nullptr); SDL_GPUTextureSamplerBinding bindings[3] = {}; - bindings[0].texture = hdr_texture; - bindings[0].sampler = sampler; - bindings[1].texture = ssao_texture ? ssao_texture : hdr_texture; - bindings[1].sampler = sampler; - bindings[2].texture = bloom_texture ? bloom_texture : hdr_texture; - bindings[2].sampler = sampler; + bindings[0].texture = hdr_texture; bindings[0].sampler = sampler; + bindings[1].texture = ssao_texture ? ssao_texture : hdr_texture; bindings[1].sampler = sampler; + bindings[2].texture = bloom_texture ? bloom_texture : hdr_texture; bindings[2].sampler = sampler; SDL_BindGPUFragmentSamplers(pass, 0, bindings, 3); - // Draw fullscreen triangle (3 vertices, no vertex buffer) SDL_DrawGPUPrimitives(pass, 3, 1, 0, 0); - SDL_EndGPURenderPass(pass); - // Submit command buffer and clean up + // --- FPS overlay: second pass on swapchain with LOADOP_LOAD --- + if (overlay_ready_ && device) { + RenderOverlay(cmd, swapchain_tex, device, context); + } + + // Submit and clean up SDL_SubmitGPUCommandBuffer(cmd); context.Remove("gpu_command_buffer"); context.Remove("postfx_swapchain_texture"); - // Increment frame counter (same responsibility as frame.gpu.end) auto frameNum = context.Get("frame_number", 0u); context.Set("frame_number", frameNum + 1); } diff --git a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_fps_move_step.cpp b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_fps_move_step.cpp index 7d85ce814..d1eb8d6ec 100644 --- a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_fps_move_step.cpp +++ b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_fps_move_step.cpp @@ -141,10 +141,111 @@ void WorkflowPhysicsFpsMoveStep::Execute( // Per-player gravity scaling (applies symmetrically in air, both rising // and falling). gravityScale > 1.0 = heavier; < 1.0 = floatier; 1.0 = - // Earth. Implemented as an additive force on top of the world's -9.81 - // so the rest of the world keeps Earth gravity untouched. + // Earth. Implemented as an impulse (force * dt) rather than applyCentralForce + // so the integrated effect is identical regardless of framerate. Bullet + // accumulates forces across frames between substeps, so a per-frame force + // call at 240Hz would otherwise multiply 4×. if (!grounded && gravityScale != 1.0f) { - body->applyCentralForce(btVector3(0, -9.81f * body->getMass() * (gravityScale - 1.0f), 0)); + const float gravImpulse = -9.81f * body->getMass() * (gravityScale - 1.0f) * dtMove; + body->applyCentralImpulse(btVector3(0, gravImpulse, 0)); + } + + // Step-up: capsule rigid bodies can't roll over Q3 stairs (~0.5m tall vs + // 0.3m capsule radius). Each frame, while grounded and trying to move + // forward, do three raycasts to detect a low obstacle with a walkable + // surface on top, then teleport the body up. This is the "step trace" + // pattern Q3's PM_StepSlideMove uses, simplified to rays. + const float stepHeight = getNum("step_height", 0.55f); + // Rate-limit step-up to ~60Hz. At 240Hz, calling step-up every frame would + // multi-teleport (forward nudge stacks, ditto vertical snap), making the + // player either rocket past stairs or stutter-stick on the last few steps. + // Accumulate dt and only attempt step-up once we've banked a 60Hz tick. + constexpr float kStepInterval = 1.0f / 60.0f; + step_accumulator_s_ += dtMove; + const bool stepReady = step_accumulator_s_ >= kStepInterval; + + btVector3 dir(moveX, 0.0f, moveZ); + const float dirMag = dir.length(); + if (stepReady && grounded && dirMag > 0.0001f && world && stepHeight > 0.0f) { + step_accumulator_s_ = 0.0f; + dir /= dirMag; + const float capsuleRadius = 0.3f; // matches q3_game.json player shape + const float capsuleHalfH = 0.5f; // height/2 + btTransform xform; + body->getMotionState()->getWorldTransform(xform); + const btVector3 origin = xform.getOrigin(); + const float feetY = origin.y() - capsuleHalfH - capsuleRadius; + + // Start probes just outside the capsule shell so they don't hit it. + // probeReach is the lookahead — bigger = detect stairs sooner so the + // snap-up happens before the capsule is fully wedged against a riser. + const float probeStart = capsuleRadius + 0.05f; + const float probeReach = 0.70f; + + // 1. Low probe — is something blocking us at shin height? + const btVector3 lowFrom = origin + dir * probeStart - btVector3(0, capsuleHalfH + capsuleRadius - 0.10f, 0); + const btVector3 lowTo = lowFrom + dir * probeReach; + btCollisionWorld::ClosestRayResultCallback lowHit(lowFrom, lowTo); + world->rayTest(lowFrom, lowTo, lowHit); + + if (lowHit.hasHit()) { + // 2. High probe — is the path clear at step_height? + const btVector3 highFrom = lowFrom + btVector3(0, stepHeight + 0.05f, 0); + const btVector3 highTo = highFrom + dir * probeReach; + btCollisionWorld::ClosestRayResultCallback highHit(highFrom, highTo); + world->rayTest(highFrom, highTo, highHit); + + if (!highHit.hasHit()) { + // 3. Down probe — find the top surface to step onto. + const btVector3 downFrom = lowTo + btVector3(0, stepHeight, 0); + const btVector3 downTo = btVector3(downFrom.x(), feetY - 0.05f, downFrom.z()); + btCollisionWorld::ClosestRayResultCallback downHit(downFrom, downTo); + world->rayTest(downFrom, downTo, downHit); + + if (downHit.hasHit()) { + const float newFeetY = downHit.m_hitPointWorld.y(); + const float deltaY = newFeetY - feetY; + if (deltaY > 0.05f && deltaY < stepHeight) { + // Snap up AND forward. The forward nudge places the + // capsule fully onto the new tread so it doesn't + // immediately snag the next riser. Without it, each + // step costs a frame of solver-fighting and feels + // staircase-shaped instead of ramp-shaped. + const float forwardNudge = capsuleRadius + 0.05f; + xform.setOrigin(origin + + btVector3(0, deltaY + 0.02f, 0) + + dir * forwardNudge); + body->setWorldTransform(xform); + body->getMotionState()->setWorldTransform(xform); + // Preserve horizontal velocity so we keep moving up + // the staircase smoothly instead of stalling each step. + } + } + } + } + } + + // Jam detection: if the player is pressing movement but the Bullet solver + // didn't actually move them (hVel << target), they're wedged against a + // stair riser that the step-up probe missed. After 100ms of being stuck, + // apply a small upward nudge to lift the capsule clear of the riser. + // currentVel reflects last frame's realized velocity — the right signal. + { + const float hVel = btVector3(currentVel.x(), 0.0f, currentVel.z()).length(); + const bool wantsMove = len > 0.001f; + if (wantsMove && grounded && hVel < moveSpeed * 0.2f) { + jam_time_s_ += dtMove; + if (jam_time_s_ > 0.10f) { + btTransform xform; + body->getMotionState()->getWorldTransform(xform); + xform.setOrigin(xform.getOrigin() + btVector3(0, 0.10f, 0)); + body->setWorldTransform(xform); + body->getMotionState()->setWorldTransform(xform); + jam_time_s_ = 0.0f; + } + } else { + jam_time_s_ = 0.0f; + } } // Quake-style impulse jump: set upward velocity once on press while diff --git a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_step_step.cpp b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_step_step.cpp index 345caea8d..73d256618 100644 --- a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_step_step.cpp +++ b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_step_step.cpp @@ -3,6 +3,8 @@ #include "services/interfaces/workflow_step_definition.hpp" #include "services/interfaces/workflow_context.hpp" +#include +#include #include #include #include @@ -25,7 +27,7 @@ void WorkflowPhysicsStepStep::Execute( } WorkflowStepParameterResolver paramResolver; - float dt = 1.0f / 60.0f; + float dt = 0.0f; // sentinel: 0 means "compute real dt" int maxSubSteps = 10; if (const auto* p = paramResolver.FindParameter(step, "delta_time")) { @@ -35,6 +37,27 @@ void WorkflowPhysicsStepStep::Execute( if (p->type == WorkflowParameterValue::Type::Number) maxSubSteps = static_cast(p->numberValue); } + // When delta_time isn't pinned in the workflow, derive it from the real + // wall clock so high-refresh displays don't run physics 4× faster than + // intended. First call falls back to 1/60. Clamp to [1/600, 1/30] so a + // hitch (debugger break, alt-tab) can't blow up Bullet's solver. + if (dt <= 0.0f) { + const Uint64 now = SDL_GetTicksNS(); + if (last_tick_ns_ != 0) { + dt = static_cast(static_cast(now - last_tick_ns_) / 1e9); + dt = std::clamp(dt, 1.0f / 600.0f, 1.0f / 30.0f); + } else { + dt = 1.0f / 60.0f; + } + last_tick_ns_ = now; + } + + // Publish to context so physics.fps.move and other steps use the same dt. + context.Set("physics_dt", dt); + + // Bullet's stepSimulation interpolates substeps internally at fixedTimeStep + // (default 1/60) so passing a small dt at 240Hz won't destabilize physics — + // it just lets Bullet skip a substep when not enough wall time has elapsed. world->stepSimulation(dt, maxSubSteps); } diff --git a/gameengine/src/services/impl/workflow/workflow_registrar.cpp b/gameengine/src/services/impl/workflow/workflow_registrar.cpp index 4800d4476..a653179e0 100644 --- a/gameengine/src/services/impl/workflow/workflow_registrar.cpp +++ b/gameengine/src/services/impl/workflow/workflow_registrar.cpp @@ -60,6 +60,8 @@ #include "services/interfaces/workflow/rendering/workflow_frame_begin_offscreen_step.hpp" #include "services/interfaces/workflow/rendering/workflow_frame_end_scene_step.hpp" #include "services/interfaces/workflow/rendering/workflow_postfx_composite_step.hpp" +#include "services/interfaces/workflow/rendering/workflow_overlay_fps_step.hpp" +#include "services/interfaces/workflow/rendering/workflow_debug_screenshot_step.hpp" #include "services/interfaces/workflow/rendering/workflow_postfx_ssao_step.hpp" #include "services/interfaces/workflow/rendering/workflow_postfx_bloom_extract_step.hpp" #include "services/interfaces/workflow/rendering/workflow_postfx_bloom_blur_step.hpp" @@ -305,6 +307,8 @@ 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_)); registry->RegisterStep(std::make_shared(logger_)); diff --git a/gameengine/src/services/interfaces/workflow/rendering/workflow_debug_screenshot_step.hpp b/gameengine/src/services/interfaces/workflow/rendering/workflow_debug_screenshot_step.hpp new file mode 100644 index 000000000..a08a36258 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/rendering/workflow_debug_screenshot_step.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include + +namespace sdl3cpp::services::impl { + +class WorkflowDebugScreenshotStep final : public IWorkflowStep { +public: + explicit WorkflowDebugScreenshotStep(std::shared_ptr logger); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr logger_; + bool saved_ = false; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_fps_step.hpp b/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_fps_step.hpp new file mode 100644 index 000000000..414ebbd5b --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_fps_step.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +class WorkflowOverlayFpsStep final : public IWorkflowStep { +public: + explicit WorkflowOverlayFpsStep(std::shared_ptr logger); + ~WorkflowOverlayFpsStep(); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + void TryInit(SDL_GPUDevice* device, SDL_Window* window); + void Render(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchain_tex, + SDL_GPUDevice* device, uint32_t frame_w, uint32_t frame_h); + + std::shared_ptr logger_; + + bool ready_ = false; + bool vbuf_uploaded_ = false; + SDL_GPUGraphicsPipeline* pipeline_ = nullptr; + SDL_GPUTexture* tex_ = nullptr; + SDL_GPUTransferBuffer* transfer_ = nullptr; + SDL_GPUBuffer* vtx_buf_ = nullptr; + SDL_GPUSampler* sampler_ = nullptr; + SDL_Surface* surface_ = nullptr; + SDL_Renderer* renderer_ = nullptr; + SDL_GPUDevice* device_ = nullptr; + + float fps_smooth_ = 0.0f; + uint64_t fps_last_ns_ = 0; + + static constexpr int kW = 160; + static constexpr int kH = 12; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/rendering/workflow_postfx_composite_step.hpp b/gameengine/src/services/interfaces/workflow/rendering/workflow_postfx_composite_step.hpp index 534395fd6..62a6fa423 100644 --- a/gameengine/src/services/interfaces/workflow/rendering/workflow_postfx_composite_step.hpp +++ b/gameengine/src/services/interfaces/workflow/rendering/workflow_postfx_composite_step.hpp @@ -2,7 +2,12 @@ #include "services/interfaces/i_workflow_step.hpp" #include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include +#include +#include +#include #include namespace sdl3cpp::services::impl { @@ -10,12 +15,36 @@ namespace sdl3cpp::services::impl { class WorkflowPostfxCompositeStep final : public IWorkflowStep { public: explicit WorkflowPostfxCompositeStep(std::shared_ptr logger); + ~WorkflowPostfxCompositeStep(); std::string GetPluginId() const override; void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; private: + void TryInitOverlay(SDL_GPUDevice* device, WorkflowContext& context); + void RenderOverlay(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchain_tex, + SDL_GPUDevice* device, WorkflowContext& context); + std::shared_ptr logger_; + + // FPS overlay — all null until TryInitOverlay succeeds + bool overlay_ready_ = false; + bool vbuf_uploaded_ = false; + SDL_GPUGraphicsPipeline* overlay_pipeline_ = nullptr; + SDL_GPUTexture* overlay_tex_ = nullptr; + SDL_GPUTransferBuffer* overlay_transfer_ = nullptr; // text pixels + SDL_GPUBuffer* overlay_vtx_buf_ = nullptr; + SDL_GPUSampler* overlay_sampler_ = nullptr; + SDL_Surface* fps_surface_ = nullptr; + SDL_Renderer* fps_renderer_ = nullptr; + SDL_GPUDevice* overlay_device_ = nullptr; // for destructor cleanup + + // FPS smoothing + float fps_smooth_ = 0.0f; + uint64_t fps_last_ns_ = 0; + + static constexpr int kOvW = 160; + static constexpr int kOvH = 12; }; } // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_physics_fps_move_step.hpp b/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_physics_fps_move_step.hpp index ca92572f7..0cb1b9638 100644 --- a/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_physics_fps_move_step.hpp +++ b/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_physics_fps_move_step.hpp @@ -16,6 +16,10 @@ public: private: std::shared_ptr logger_; + // Accumulator that gates step-up to ~60Hz so high-refresh frames don't + // multi-teleport the player up a single staircase. + float step_accumulator_s_ = 0.0f; + float jam_time_s_ = 0.0f; }; } // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_physics_step_step.hpp b/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_physics_step_step.hpp index 70fb5e2e2..45f39c7b7 100644 --- a/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_physics_step_step.hpp +++ b/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_physics_step_step.hpp @@ -3,6 +3,7 @@ #include "services/interfaces/i_workflow_step.hpp" #include "services/interfaces/i_logger.hpp" +#include #include namespace sdl3cpp::services::impl { @@ -16,6 +17,7 @@ public: private: std::shared_ptr logger_; + std::uint64_t last_tick_ns_ = 0; // wall-clock anchor for real-dt computation }; } // namespace sdl3cpp::services::impl