diff --git a/openspec/changes/add-prechain-downsample/proposal.md b/openspec/changes/add-prechain-downsample/proposal.md new file mode 100644 index 00000000..efb4e10c --- /dev/null +++ b/openspec/changes/add-prechain-downsample/proposal.md @@ -0,0 +1,38 @@ +# Change: Introduce pre-chain stage with area downsampling pass + +## Why + +The `--app-width` / `--app-height` CLI options currently only work with WSI proxy mode. Users need a way to control the source resolution fed into the RetroArch filter chain regardless of capture mode. A pre-chain stage enables resolution control and other preprocessing for shader effects that benefit from modified input (CRT simulation, pixel art upscalers) without requiring WSI proxy overhead. + +## What Changes + +### Pre-Chain Infrastructure (generic, extensible) + +- Introduce pre-chain as a **vector of passes** (`m_prechain_passes`) in `FilterChain`, analogous to `m_passes` for RetroArch +- Add corresponding **vector of framebuffers** (`m_prechain_framebuffers`) for intermediate results +- Pre-chain executes before RetroArch passes; its final output becomes `original_view` for the RetroArch chain +- Pre-chain passes can be added/configured independently (future: sharpening, color correction, etc.) + +### Downsample Pass (first pre-chain pass) + +- Add `downsample.frag.slang` shader in `shaders/internal/` using area filtering +- Create `DownsamplePass` class implementing the pass interface +- Add `DownsamplePass` to pre-chain when `--app-width`/`--app-height` are specified +- Support single-dimension specification with aspect-ratio preservation + +### CLI Semantics + +- Change `--app-width`/`--app-height` semantics: set source resolution for filter chain input (all capture modes) +- Support single-dimension: other dimension calculated from captured frame's aspect ratio +- Store configured resolution in `Config::Render` + +## Impact + +- Affected specs: render-pipeline +- Affected code: + - `shaders/internal/downsample.frag.slang` (new) + - `src/render/chain/downsample_pass.hpp/cpp` (new) + - `src/render/chain/filter_chain.hpp` - add pre-chain vectors + - `src/render/chain/filter_chain.cpp` - implement generic pre-chain recording + - `src/app/cli.cpp` - update option descriptions + - `src/util/config.hpp` - add source resolution fields diff --git a/openspec/changes/add-prechain-downsample/specs/render-pipeline/spec.md b/openspec/changes/add-prechain-downsample/specs/render-pipeline/spec.md new file mode 100644 index 00000000..c1016e6c --- /dev/null +++ b/openspec/changes/add-prechain-downsample/specs/render-pipeline/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: Pre-Chain Stage Infrastructure + +The filter chain SHALL support a generic pre-chain stage that processes captured frames before the RetroArch shader passes. The pre-chain is a vector of passes, analogous to the RetroArch pass vector, allowing multiple preprocessing steps. + +#### Scenario: Pre-chain as extensible pass vector + +- **GIVEN** `FilterChain` is initialized +- **WHEN** pre-chain passes are configured +- **THEN** `m_prechain_passes` SHALL be a vector capable of holding multiple passes +- **AND** `m_prechain_framebuffers` SHALL be a vector of corresponding framebuffers +- **AND** passes SHALL execute in vector order + +#### Scenario: Pre-chain disabled by default + +- **GIVEN** no pre-chain passes are configured +- **WHEN** `FilterChain::record()` executes +- **THEN** captured frames SHALL pass directly to RetroArch passes (or OutputPass in passthrough mode) + +#### Scenario: Pre-chain output becomes Original for RetroArch chain + +- **GIVEN** pre-chain contains one or more passes +- **WHEN** `FilterChain::record()` executes +- **THEN** pre-chain passes SHALL execute first in vector order +- **AND** the final pre-chain output SHALL be used as `original_view` for RetroArch passes +- **AND** `OriginalSize` semantic SHALL reflect final pre-chain output dimensions + +#### Scenario: Generic pre-chain recording + +- **GIVEN** pre-chain contains N passes +- **WHEN** `record_prechain()` executes +- **THEN** each pass SHALL receive the previous pass's output as input +- **AND** image barriers SHALL be inserted between passes +- **AND** the loop SHALL NOT be hardcoded to a specific pass type + +### Requirement: Downsample Pass + +The internal pass library SHALL include an area-filter downsampling pass that can be added to the pre-chain. + +#### Scenario: Area filter downsampling + +- **GIVEN** source image at 1920x1080 and target resolution 640x480 +- **WHEN** downsample pass executes +- **THEN** each output pixel SHALL be computed as a weighted average of covered source pixels +- **AND** the result SHALL exhibit minimal aliasing compared to point sampling + +#### Scenario: Downsample added to pre-chain when configured + +- **GIVEN** source resolution is configured via `--app-width` and/or `--app-height` +- **WHEN** `FilterChain` is created +- **THEN** a `DownsamplePass` SHALL be added to `m_prechain_passes` +- **AND** a framebuffer sized to target resolution SHALL be added to `m_prechain_framebuffers` + +#### Scenario: Identity passthrough at same resolution + +- **GIVEN** source and target resolution are identical +- **WHEN** downsample pass executes +- **THEN** output SHALL exactly match input +- **AND** no blurring or aliasing SHALL occur + +### Requirement: Source Resolution CLI Semantics + +The `--app-width` and `--app-height` CLI options SHALL configure the downsample pass in the pre-chain. Either option may be specified alone, with the other dimension calculated to preserve aspect ratio. + +#### Scenario: Both dimensions specified + +- **GIVEN** user specifies `--app-width 640 --app-height 480` +- **WHEN** Goggles starts +- **THEN** `DownsamplePass` SHALL be added to pre-chain with target 640x480 + +#### Scenario: Only width specified preserves aspect ratio + +- **GIVEN** user specifies `--app-width 640` without `--app-height` +- **AND** captured frame is 1920x1080 (16:9 aspect ratio) +- **WHEN** first frame is processed +- **THEN** height SHALL be computed as `round(640 * 1080 / 1920) = 360` +- **AND** downsample pass target SHALL be 640x360 + +#### Scenario: Only height specified preserves aspect ratio + +- **GIVEN** user specifies `--app-height 480` without `--app-width` +- **AND** captured frame is 1920x1080 (16:9 aspect ratio) +- **WHEN** first frame is processed +- **THEN** width SHALL be computed as `round(480 * 1920 / 1080) = 853` +- **AND** downsample pass target SHALL be 853x480 + +#### Scenario: Options still set environment variables + +- **GIVEN** user specifies `--app-width` and/or `--app-height` +- **WHEN** target app is launched +- **THEN** `GOGGLES_WIDTH` and `GOGGLES_HEIGHT` environment variables SHALL be set for specified dimensions +- **AND** WSI proxy (if enabled) SHALL use these values for virtual surface sizing diff --git a/openspec/changes/add-prechain-downsample/tasks.md b/openspec/changes/add-prechain-downsample/tasks.md new file mode 100644 index 00000000..7482b119 --- /dev/null +++ b/openspec/changes/add-prechain-downsample/tasks.md @@ -0,0 +1,41 @@ +## 1. Create Area Downsample Shader + +- [x] 1.1 Create `shaders/internal/downsample.frag.slang` with area filter algorithm +- [x] 1.2 Add push constant for source/target dimensions to calculate sample weights +- [x] 1.3 Verify shader compiles with Slang in HLSL mode + +## 2. Create DownsamplePass Class + +- [x] 2.1 Create `downsample_pass.hpp/cpp` with pass interface +- [x] 2.2 Implement pipeline creation, descriptor sets, push constants +- [x] 2.3 Implement `record()` method for command buffer recording + +## 3. Implement Generic Pre-Chain Infrastructure + +- [x] 3.1 Add `m_prechain_passes` vector to `FilterChain` (not single `m_downsample_pass`) +- [x] 3.2 Add `m_prechain_framebuffers` vector to `FilterChain` (not single `m_downsample_framebuffer`) +- [x] 3.3 Implement `add_prechain_pass()` method to append passes to pre-chain +- [x] 3.4 Implement `record_prechain()` to iterate all pre-chain passes (not hardcoded downsample) +- [x] 3.5 Ensure pre-chain output becomes `original_view` for RetroArch chain + +## 4. Integrate Downsample Pass into Pre-Chain + +- [x] 4.1 Add `DownsamplePass` to pre-chain when source resolution configured +- [x] 4.2 Size final pre-chain framebuffer to configured resolution +- [x] 4.3 Implement lazy initialization for single-dimension (aspect-ratio calculation) + +## 5. Update CLI and Config + +- [x] 5.1 Update `--app-width`/`--app-height` help text to describe new semantics +- [x] 5.2 Add `source_width`/`source_height` to `Config::Render` +- [x] 5.3 Remove validation requiring both dimensions (allow single-dimension specification) +- [x] 5.4 Pass configured resolution through to `FilterChain::create()` + +## 6. Integration and Testing + +- [x] 6.1 Test downsampling with high-res capture (e.g., 1920x1080 -> 640x480) +- [x] 6.2 Verify RetroArch shaders receive correct `OriginalSize` after downsampling +- [x] 6.3 Test with `--app-width 320 --app-height 240` to simulate retro resolution +- [x] 6.4 Verify existing behavior unchanged when options not provided +- [x] 6.5 Test single-dimension: `--app-width 640` with 1920x1080 source -> 640x360 +- [x] 6.6 Test single-dimension: `--app-height 480` with 1920x1080 source -> 854x480 diff --git a/shaders/internal/downsample.frag.slang b/shaders/internal/downsample.frag.slang new file mode 100644 index 00000000..385ff3dd --- /dev/null +++ b/shaders/internal/downsample.frag.slang @@ -0,0 +1,73 @@ +// Area filter downsampling shader for pre-chain resolution control. +// Computes weighted average of source pixels covered by each output pixel. + +struct PushConstants { + float4 source_size; // [width, height, 1/width, 1/height] + float4 target_size; // [width, height, 1/width, 1/height] +}; + +[[vk::push_constant]] +PushConstants pc; + +[[vk::binding(0, 0)]] +Sampler2D source_texture; + +[shader("pixel")] +float4 main(float2 texcoord : TEXCOORD0) : SV_Target0 { + // Calculate the scale ratio (how many source pixels per output pixel) + float2 scale = pc.source_size.xy / pc.target_size.xy; + + // If not downsampling (scale <= 1), just sample directly + if (scale.x <= 1.0 && scale.y <= 1.0) { + return source_texture.Sample(texcoord); + } + + // Calculate the source pixel region this output pixel covers + float2 src_coord = texcoord * pc.source_size.xy; + float2 box_min = src_coord - scale * 0.5; + float2 box_max = src_coord + scale * 0.5; + + // Clamp to valid source coordinates + box_min = max(box_min, float2(0.0, 0.0)); + box_max = min(box_max, pc.source_size.xy); + + // Integer bounds for iteration + int2 i_min = int2(floor(box_min)); + int2 i_max = int2(ceil(box_max)); + + // Limit sample count for performance (max 8x8 = 64 samples) + int max_samples = 8; + int2 sample_range = min(i_max - i_min, int2(max_samples, max_samples)); + i_max = i_min + sample_range; + + float4 accum = float4(0.0, 0.0, 0.0, 0.0); + float total_weight = 0.0; + + // Area filter: weight each source pixel by its coverage + for (int y = i_min.y; y < i_max.y; y++) { + for (int x = i_min.x; x < i_max.x; x++) { + // Calculate this pixel's contribution (overlap with the box) + float2 pixel_min = float2(x, y); + float2 pixel_max = float2(x + 1, y + 1); + + float2 overlap_min = max(pixel_min, box_min); + float2 overlap_max = min(pixel_max, box_max); + float2 overlap_size = max(overlap_max - overlap_min, float2(0.0, 0.0)); + + float weight = overlap_size.x * overlap_size.y; + if (weight > 0.0) { + // Sample at pixel center + float2 sample_uv = (float2(x, y) + 0.5) * pc.source_size.zw; + accum += source_texture.Sample(sample_uv) * weight; + total_weight += weight; + } + } + } + + if (total_weight > 0.0) { + return accum / total_weight; + } + + // Fallback to direct sample + return source_texture.Sample(texcoord); +} diff --git a/src/app/application.cpp b/src/app/application.cpp index 5806860a..51b8fd95 100644 --- a/src/app/application.cpp +++ b/src/app/application.cpp @@ -45,6 +45,8 @@ auto Application::create(const Config& config, const util::AppDirs& app_dirs, render_settings.scale_mode = config.render.scale_mode; render_settings.integer_scale = config.render.integer_scale; render_settings.target_fps = config.render.target_fps; + render_settings.source_width = config.render.source_width; + render_settings.source_height = config.render.source_height; app->m_scale_mode = config.render.scale_mode; GOGGLES_LOG_INFO("Scale mode: {}", to_string(app->m_scale_mode)); diff --git a/src/app/cli.cpp b/src/app/cli.cpp index fe04b500..cc671cff 100644 --- a/src/app/cli.cpp +++ b/src/app/cli.cpp @@ -38,11 +38,10 @@ auto register_options(CLI::App& app, CliOptions& options) -> void { "Default mode only: enable WSI proxy mode (sets GOGGLES_WSI_PROXY=1 for launched " "app; virtualizes window and swapchain)"); app.add_option("--app-width", options.app_width, - "Default mode only: virtual surface width (sets GOGGLES_WIDTH for launched app)") + "Source resolution width (also sets GOGGLES_WIDTH for launched app)") ->check(CLI::Range(1u, 16384u)); - app.add_option( - "--app-height", options.app_height, - "Default mode only: virtual surface height (sets GOGGLES_HEIGHT for launched app)") + app.add_option("--app-height", options.app_height, + "Source resolution height (also sets GOGGLES_HEIGHT for launched app)") ->check(CLI::Range(1u, 16384u)); app.add_option( "--dump-dir", options.dump_dir, @@ -103,12 +102,6 @@ auto register_options(CLI::App& app, CliOptions& options) -> void { "for viewer-only mode)"); } - if (options.app_width != 0 || options.app_height != 0) { - if (options.app_width == 0 || options.app_height == 0) { - return make_error(ErrorCode::parse_error, - "--app-width and --app-height must be provided together"); - } - } if (options.app_command.empty()) { return make_error(ErrorCode::parse_error, "missing target app command (use '--detach' for viewer-only mode, " @@ -134,8 +127,7 @@ auto parse_cli(int argc, char** argv) -> CliResult { Notes: - Default mode (no --detach) launches the target app with capture + input forwarding enabled. - - '--' is required before to avoid app args (e.g. '--config') being parsed as Goggles options. - - --app-width/--app-height apply only in default mode.)"); + - '--' is required before to avoid app args (e.g. '--config') being parsed as Goggles options.)"); CliOptions options; register_options(app, options); diff --git a/src/app/main.cpp b/src/app/main.cpp index 3cf182be..e3e3a525 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -372,6 +372,12 @@ static auto run_app(int argc, char** argv) -> int { config.render.target_fps = *cli_opts.target_fps; GOGGLES_LOG_INFO("Target FPS overridden by CLI: {}", config.render.target_fps); } + if (cli_opts.app_width != 0 || cli_opts.app_height != 0) { + config.render.source_width = cli_opts.app_width; + config.render.source_height = cli_opts.app_height; + GOGGLES_LOG_INFO("Source resolution: {}x{}", config.render.source_width, + config.render.source_height); + } if (!config.shader.preset.empty()) { std::filesystem::path preset_path{config.shader.preset}; if (preset_path.is_relative()) { diff --git a/src/render/backend/vulkan_backend.cpp b/src/render/backend/vulkan_backend.cpp index 6dd95e97..060bb77e 100644 --- a/src/render/backend/vulkan_backend.cpp +++ b/src/render/backend/vulkan_backend.cpp @@ -196,6 +196,7 @@ auto VulkanBackend::create(SDL_Window* window, bool enable_validation, } backend->m_scale_mode = settings.scale_mode; backend->m_integer_scale = settings.integer_scale; + backend->m_source_resolution = vk::Extent2D{settings.source_width, settings.source_height}; backend->update_target_fps(settings.target_fps); int width = 0; @@ -889,8 +890,9 @@ auto VulkanBackend::init_filter_chain() -> Result { .physical_device = m_physical_device, .command_pool = *m_command_pool, .graphics_queue = m_graphics_queue}; - m_filter_chain = GOGGLES_TRY(FilterChain::create( - vk_ctx, m_swapchain_format, MAX_FRAMES_IN_FLIGHT, *m_shader_runtime, m_shader_dir)); + m_filter_chain = + GOGGLES_TRY(FilterChain::create(vk_ctx, m_swapchain_format, MAX_FRAMES_IN_FLIGHT, + *m_shader_runtime, m_shader_dir, m_source_resolution)); return {}; } @@ -1536,6 +1538,7 @@ auto VulkanBackend::reload_shader_preset(const std::filesystem::path& preset_pat auto physical_device = m_physical_device; auto command_pool = *m_command_pool; auto graphics_queue = m_graphics_queue; + auto source_resolution = m_source_resolution; m_pending_load_future = util::JobSystem::submit([=, this]() -> Result { GOGGLES_PROFILE_SCOPE("AsyncShaderLoad"); @@ -1554,8 +1557,9 @@ auto VulkanBackend::reload_shader_preset(const std::filesystem::path& preset_pat .graphics_queue = graphics_queue, }; - auto chain_result = FilterChain::create(vk_ctx, swapchain_format, MAX_FRAMES_IN_FLIGHT, - *runtime_result.value(), shader_dir); + auto chain_result = + FilterChain::create(vk_ctx, swapchain_format, MAX_FRAMES_IN_FLIGHT, + *runtime_result.value(), shader_dir, source_resolution); if (!chain_result) { GOGGLES_LOG_ERROR("Failed to create filter chain: {}", chain_result.error().message); return make_error(chain_result.error().code, chain_result.error().message); diff --git a/src/render/backend/vulkan_backend.hpp b/src/render/backend/vulkan_backend.hpp index 5779026c..68a2ce4d 100644 --- a/src/render/backend/vulkan_backend.hpp +++ b/src/render/backend/vulkan_backend.hpp @@ -25,6 +25,8 @@ struct RenderSettings { ScaleMode scale_mode = ScaleMode::stretch; uint32_t integer_scale = 0; uint32_t target_fps = 60; + uint32_t source_width = 0; + uint32_t source_height = 0; }; /// @brief Vulkan renderer for presenting captured frames. @@ -194,6 +196,7 @@ class VulkanBackend { uint32_t m_integer_scale = 0; vk::Extent2D m_swapchain_extent; vk::Extent2D m_import_extent; + vk::Extent2D m_source_resolution; bool m_enable_validation = false; ScaleMode m_scale_mode = ScaleMode::stretch; bool m_needs_resize = false; diff --git a/src/render/backend/vulkan_debug.cpp b/src/render/backend/vulkan_debug.cpp index 0cbd28ca..61485e83 100644 --- a/src/render/backend/vulkan_debug.cpp +++ b/src/render/backend/vulkan_debug.cpp @@ -19,13 +19,13 @@ debug_callback(vk::DebugUtilsMessageSeverityFlagBitsEXT message_severity, const char* message = callback_data->pMessage != nullptr ? callback_data->pMessage : ""; if (message_severity & vk::DebugUtilsMessageSeverityFlagBitsEXT::eError) { - GOGGLES_LOG_ERROR("[Vulkan] {}", message); + GOGGLES_LOG_ERROR("[VVL] {}", message); } else if (message_severity & vk::DebugUtilsMessageSeverityFlagBitsEXT::eWarning) { - GOGGLES_LOG_WARN("[Vulkan] {}", message); + GOGGLES_LOG_WARN("[VVL] {}", message); } else if (message_severity & vk::DebugUtilsMessageSeverityFlagBitsEXT::eInfo) { - GOGGLES_LOG_DEBUG("[Vulkan] {}", message); + GOGGLES_LOG_DEBUG("[VVL] {}", message); } else { - GOGGLES_LOG_TRACE("[Vulkan] {}", message); + GOGGLES_LOG_TRACE("[VVL] {}", message); } return VK_FALSE; diff --git a/src/render/chain/CMakeLists.txt b/src/render/chain/CMakeLists.txt index fa87d5e2..8cfa5bed 100644 --- a/src/render/chain/CMakeLists.txt +++ b/src/render/chain/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(goggles_render_chain_obj OBJECT framebuffer.cpp filter_chain.cpp frame_history.cpp + downsample_pass.cpp ) target_include_directories(goggles_render_chain_obj PUBLIC diff --git a/src/render/chain/downsample_pass.cpp b/src/render/chain/downsample_pass.cpp new file mode 100644 index 00000000..5c716cf3 --- /dev/null +++ b/src/render/chain/downsample_pass.cpp @@ -0,0 +1,344 @@ +#include "downsample_pass.hpp" + +#include +#include +#include +#include + +namespace goggles::render { + +namespace { + +/// @brief Push constants for the downsample shader. +struct DownsamplePushConstants { + float source_width; + float source_height; + float source_inv_width; + float source_inv_height; + float target_width; + float target_height; + float target_inv_width; + float target_inv_height; +}; + +} // namespace + +DownsamplePass::~DownsamplePass() { + DownsamplePass::shutdown(); +} + +auto DownsamplePass::create(const VulkanContext& vk_ctx, ShaderRuntime& shader_runtime, + const DownsamplePassConfig& config) -> ResultPtr { + GOGGLES_PROFILE_FUNCTION(); + + auto pass = std::unique_ptr(new DownsamplePass()); + + pass->m_device = vk_ctx.device; + pass->m_target_format = config.target_format; + pass->m_num_sync_indices = config.num_sync_indices; + + GOGGLES_TRY(pass->create_sampler()); + GOGGLES_TRY(pass->create_descriptor_resources()); + GOGGLES_TRY(pass->create_pipeline_layout()); + GOGGLES_TRY(pass->create_pipeline(shader_runtime, config.shader_dir)); + + GOGGLES_LOG_DEBUG("DownsamplePass initialized"); + return make_result_ptr(std::move(pass)); +} + +void DownsamplePass::shutdown() { + m_pipeline.reset(); + m_pipeline_layout.reset(); + m_descriptor_pool.reset(); + m_descriptor_layout.reset(); + m_sampler.reset(); + m_descriptor_sets.clear(); + m_target_format = vk::Format::eUndefined; + m_device = nullptr; + m_num_sync_indices = 0; + + GOGGLES_LOG_DEBUG("DownsamplePass shutdown"); +} + +void DownsamplePass::update_descriptor(uint32_t frame_index, vk::ImageView source_view) { + vk::DescriptorImageInfo image_info{}; + image_info.sampler = *m_sampler; + image_info.imageView = source_view; + image_info.imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + + vk::WriteDescriptorSet write{}; + write.dstSet = m_descriptor_sets[frame_index]; + write.dstBinding = 0; + write.dstArrayElement = 0; + write.descriptorCount = 1; + write.descriptorType = vk::DescriptorType::eCombinedImageSampler; + write.pImageInfo = &image_info; + + m_device.updateDescriptorSets(write, {}); +} + +void DownsamplePass::record(vk::CommandBuffer cmd, const PassContext& ctx) { + GOGGLES_PROFILE_FUNCTION(); + + update_descriptor(ctx.frame_index, ctx.source_texture); + + DownsamplePushConstants pc{}; + pc.source_width = static_cast(ctx.source_extent.width); + pc.source_height = static_cast(ctx.source_extent.height); + pc.source_inv_width = 1.0F / pc.source_width; + pc.source_inv_height = 1.0F / pc.source_height; + pc.target_width = static_cast(ctx.output_extent.width); + pc.target_height = static_cast(ctx.output_extent.height); + pc.target_inv_width = 1.0F / pc.target_width; + pc.target_inv_height = 1.0F / pc.target_height; + + GOGGLES_LOG_TRACE("DownsamplePass: source={}x{} -> target={}x{}", ctx.source_extent.width, + ctx.source_extent.height, ctx.output_extent.width, ctx.output_extent.height); + + vk::RenderingAttachmentInfo color_attachment{}; + color_attachment.imageView = ctx.target_image_view; + color_attachment.imageLayout = vk::ImageLayout::eColorAttachmentOptimal; + color_attachment.loadOp = vk::AttachmentLoadOp::eDontCare; + color_attachment.storeOp = vk::AttachmentStoreOp::eStore; + + vk::RenderingInfo rendering_info{}; + rendering_info.renderArea.offset = vk::Offset2D{0, 0}; + rendering_info.renderArea.extent = ctx.output_extent; + rendering_info.layerCount = 1; + rendering_info.colorAttachmentCount = 1; + rendering_info.pColorAttachments = &color_attachment; + + cmd.beginRendering(rendering_info); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, *m_pipeline); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, *m_pipeline_layout, 0, + m_descriptor_sets[ctx.frame_index], {}); + + cmd.pushConstants(*m_pipeline_layout, + vk::ShaderStageFlagBits::eFragment, 0, pc); + + vk::Viewport viewport{}; + viewport.x = 0.0F; + viewport.y = 0.0F; + viewport.width = static_cast(ctx.output_extent.width); + viewport.height = static_cast(ctx.output_extent.height); + viewport.minDepth = 0.0F; + viewport.maxDepth = 1.0F; + cmd.setViewport(0, viewport); + + vk::Rect2D scissor{}; + scissor.offset = vk::Offset2D{0, 0}; + scissor.extent = ctx.output_extent; + cmd.setScissor(0, scissor); + + cmd.draw(3, 1, 0, 0); + cmd.endRendering(); +} + +auto DownsamplePass::create_sampler() -> Result { + vk::SamplerCreateInfo create_info{}; + create_info.magFilter = vk::Filter::eLinear; + create_info.minFilter = vk::Filter::eLinear; + create_info.mipmapMode = vk::SamplerMipmapMode::eNearest; + create_info.addressModeU = vk::SamplerAddressMode::eClampToEdge; + create_info.addressModeV = vk::SamplerAddressMode::eClampToEdge; + create_info.addressModeW = vk::SamplerAddressMode::eClampToEdge; + create_info.mipLodBias = 0.0F; + create_info.anisotropyEnable = VK_FALSE; + create_info.compareEnable = VK_FALSE; + create_info.minLod = 0.0F; + create_info.maxLod = 0.0F; + create_info.borderColor = vk::BorderColor::eFloatOpaqueBlack; + create_info.unnormalizedCoordinates = VK_FALSE; + + auto [result, sampler] = m_device.createSamplerUnique(create_info); + if (result != vk::Result::eSuccess) { + return make_error(ErrorCode::vulkan_init_failed, + "Failed to create sampler: " + vk::to_string(result)); + } + + m_sampler = std::move(sampler); + return {}; +} + +auto DownsamplePass::create_descriptor_resources() -> Result { + vk::DescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = vk::DescriptorType::eCombinedImageSampler; + binding.descriptorCount = 1; + binding.stageFlags = vk::ShaderStageFlagBits::eFragment; + + vk::DescriptorSetLayoutCreateInfo layout_info{}; + layout_info.bindingCount = 1; + layout_info.pBindings = &binding; + + auto [layout_result, layout] = m_device.createDescriptorSetLayoutUnique(layout_info); + if (layout_result != vk::Result::eSuccess) { + return make_error(ErrorCode::vulkan_init_failed, + "Failed to create descriptor set layout: " + + vk::to_string(layout_result)); + } + m_descriptor_layout = std::move(layout); + + vk::DescriptorPoolSize pool_size{}; + pool_size.type = vk::DescriptorType::eCombinedImageSampler; + pool_size.descriptorCount = m_num_sync_indices; + + vk::DescriptorPoolCreateInfo pool_info{}; + pool_info.maxSets = m_num_sync_indices; + pool_info.poolSizeCount = 1; + pool_info.pPoolSizes = &pool_size; + + auto [pool_result, pool] = m_device.createDescriptorPoolUnique(pool_info); + if (pool_result != vk::Result::eSuccess) { + return make_error(ErrorCode::vulkan_init_failed, + "Failed to create descriptor pool: " + vk::to_string(pool_result)); + } + m_descriptor_pool = std::move(pool); + + std::vector layouts(m_num_sync_indices, *m_descriptor_layout); + + vk::DescriptorSetAllocateInfo alloc_info{}; + alloc_info.descriptorPool = *m_descriptor_pool; + alloc_info.descriptorSetCount = m_num_sync_indices; + alloc_info.pSetLayouts = layouts.data(); + + auto [alloc_result, sets] = m_device.allocateDescriptorSets(alloc_info); + if (alloc_result != vk::Result::eSuccess) { + return make_error(ErrorCode::vulkan_init_failed, + "Failed to allocate descriptor sets: " + + vk::to_string(alloc_result)); + } + m_descriptor_sets = std::move(sets); + + return {}; +} + +auto DownsamplePass::create_pipeline_layout() -> Result { + vk::PushConstantRange push_constant{}; + push_constant.stageFlags = vk::ShaderStageFlagBits::eFragment; + push_constant.offset = 0; + push_constant.size = sizeof(DownsamplePushConstants); + + vk::PipelineLayoutCreateInfo create_info{}; + create_info.setLayoutCount = 1; + create_info.pSetLayouts = &*m_descriptor_layout; + create_info.pushConstantRangeCount = 1; + create_info.pPushConstantRanges = &push_constant; + + auto [result, layout] = m_device.createPipelineLayoutUnique(create_info); + if (result != vk::Result::eSuccess) { + return make_error(ErrorCode::vulkan_init_failed, + "Failed to create pipeline layout: " + vk::to_string(result)); + } + + m_pipeline_layout = std::move(layout); + return {}; +} + +auto DownsamplePass::create_pipeline(ShaderRuntime& shader_runtime, + const std::filesystem::path& shader_dir) -> Result { + // Internal shaders - abort on failure since they're bundled with the app + auto vert_compiled = + GOGGLES_MUST(shader_runtime.compile_shader(shader_dir / "internal/blit.vert.slang")); + auto frag_compiled = + GOGGLES_MUST(shader_runtime.compile_shader(shader_dir / "internal/downsample.frag.slang")); + + vk::ShaderModuleCreateInfo vert_module_info{}; + vert_module_info.codeSize = vert_compiled.spirv.size() * sizeof(uint32_t); + vert_module_info.pCode = vert_compiled.spirv.data(); + + auto [vert_mod_result, vert_module] = m_device.createShaderModuleUnique(vert_module_info); + if (vert_mod_result != vk::Result::eSuccess) { + return make_error(ErrorCode::vulkan_init_failed, + "Failed to create vertex shader module: " + + vk::to_string(vert_mod_result)); + } + + vk::ShaderModuleCreateInfo frag_module_info{}; + frag_module_info.codeSize = frag_compiled.spirv.size() * sizeof(uint32_t); + frag_module_info.pCode = frag_compiled.spirv.data(); + + auto [frag_mod_result, frag_module] = m_device.createShaderModuleUnique(frag_module_info); + if (frag_mod_result != vk::Result::eSuccess) { + return make_error(ErrorCode::vulkan_init_failed, + "Failed to create fragment shader module: " + + vk::to_string(frag_mod_result)); + } + + std::array stages{}; + stages[0].stage = vk::ShaderStageFlagBits::eVertex; + stages[0].module = *vert_module; + stages[0].pName = "main"; + stages[1].stage = vk::ShaderStageFlagBits::eFragment; + stages[1].module = *frag_module; + stages[1].pName = "main"; + + vk::PipelineVertexInputStateCreateInfo vertex_input{}; + + vk::PipelineInputAssemblyStateCreateInfo input_assembly{}; + input_assembly.topology = vk::PrimitiveTopology::eTriangleList; + input_assembly.primitiveRestartEnable = VK_FALSE; + + vk::PipelineViewportStateCreateInfo viewport_state{}; + viewport_state.viewportCount = 1; + viewport_state.scissorCount = 1; + + vk::PipelineRasterizationStateCreateInfo rasterization{}; + rasterization.depthClampEnable = VK_FALSE; + rasterization.rasterizerDiscardEnable = VK_FALSE; + rasterization.polygonMode = vk::PolygonMode::eFill; + rasterization.cullMode = vk::CullModeFlagBits::eNone; + rasterization.frontFace = vk::FrontFace::eCounterClockwise; + rasterization.depthBiasEnable = VK_FALSE; + rasterization.lineWidth = 1.0F; + + vk::PipelineMultisampleStateCreateInfo multisample{}; + multisample.rasterizationSamples = vk::SampleCountFlagBits::e1; + multisample.sampleShadingEnable = VK_FALSE; + + vk::PipelineColorBlendAttachmentState blend_attachment{}; + blend_attachment.blendEnable = VK_FALSE; + blend_attachment.colorWriteMask = + vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | + vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA; + + vk::PipelineColorBlendStateCreateInfo color_blend{}; + color_blend.logicOpEnable = VK_FALSE; + color_blend.attachmentCount = 1; + color_blend.pAttachments = &blend_attachment; + + std::array dynamic_states = {vk::DynamicState::eViewport, vk::DynamicState::eScissor}; + vk::PipelineDynamicStateCreateInfo dynamic_state{}; + dynamic_state.dynamicStateCount = static_cast(dynamic_states.size()); + dynamic_state.pDynamicStates = dynamic_states.data(); + + vk::PipelineRenderingCreateInfo rendering_info{}; + rendering_info.colorAttachmentCount = 1; + rendering_info.pColorAttachmentFormats = &m_target_format; + rendering_info.depthAttachmentFormat = vk::Format::eUndefined; + rendering_info.stencilAttachmentFormat = vk::Format::eUndefined; + + vk::GraphicsPipelineCreateInfo create_info{}; + create_info.pNext = &rendering_info; + create_info.stageCount = static_cast(stages.size()); + create_info.pStages = stages.data(); + create_info.pVertexInputState = &vertex_input; + create_info.pInputAssemblyState = &input_assembly; + create_info.pViewportState = &viewport_state; + create_info.pRasterizationState = &rasterization; + create_info.pMultisampleState = &multisample; + create_info.pColorBlendState = &color_blend; + create_info.pDynamicState = &dynamic_state; + create_info.layout = *m_pipeline_layout; + + auto [result, pipelines] = m_device.createGraphicsPipelinesUnique(nullptr, create_info); + if (result != vk::Result::eSuccess) { + return make_error(ErrorCode::vulkan_init_failed, + "Failed to create graphics pipeline: " + vk::to_string(result)); + } + + m_pipeline = std::move(pipelines[0]); + return {}; +} + +} // namespace goggles::render diff --git a/src/render/chain/downsample_pass.hpp b/src/render/chain/downsample_pass.hpp new file mode 100644 index 00000000..969e4455 --- /dev/null +++ b/src/render/chain/downsample_pass.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "pass.hpp" + +#include + +namespace goggles::render { + +/// @brief Configuration for creating a `DownsamplePass`. +struct DownsamplePassConfig { + vk::Format target_format = vk::Format::eR8G8B8A8Unorm; + uint32_t num_sync_indices = 2; + std::filesystem::path shader_dir; +}; + +/// @brief Pre-chain pass that downsamples captured frames using area filtering. +class DownsamplePass : public Pass { +public: + /// @brief Creates a downsample pass for the given target format. + /// @return A pass or an error. + [[nodiscard]] static auto create(const VulkanContext& vk_ctx, ShaderRuntime& shader_runtime, + const DownsamplePassConfig& config) + -> ResultPtr; + + ~DownsamplePass() override; + + DownsamplePass(const DownsamplePass&) = delete; + DownsamplePass& operator=(const DownsamplePass&) = delete; + DownsamplePass(DownsamplePass&&) = delete; + DownsamplePass& operator=(DownsamplePass&&) = delete; + + /// @brief Releases GPU resources owned by this pass. + void shutdown() override; + /// @brief Records commands to downsample the source texture. + void record(vk::CommandBuffer cmd, const PassContext& ctx) override; + +private: + DownsamplePass() = default; + [[nodiscard]] auto create_descriptor_resources() -> Result; + [[nodiscard]] auto create_pipeline_layout() -> Result; + [[nodiscard]] auto create_pipeline(ShaderRuntime& shader_runtime, + const std::filesystem::path& shader_dir) -> Result; + [[nodiscard]] auto create_sampler() -> Result; + + void update_descriptor(uint32_t frame_index, vk::ImageView source_view); + + vk::Device m_device; + vk::Format m_target_format = vk::Format::eUndefined; + uint32_t m_num_sync_indices = 0; + + vk::UniquePipelineLayout m_pipeline_layout; + vk::UniquePipeline m_pipeline; + + vk::UniqueDescriptorSetLayout m_descriptor_layout; + vk::UniqueDescriptorPool m_descriptor_pool; + std::vector m_descriptor_sets; + + vk::UniqueSampler m_sampler; +}; + +} // namespace goggles::render diff --git a/src/render/chain/filter_chain.cpp b/src/render/chain/filter_chain.cpp index 63230cc5..688b04cf 100644 --- a/src/render/chain/filter_chain.cpp +++ b/src/render/chain/filter_chain.cpp @@ -1,5 +1,7 @@ #include "filter_chain.hpp" +#include "downsample_pass.hpp" + #include #include #include @@ -69,7 +71,8 @@ FilterChain::~FilterChain() { auto FilterChain::create(const VulkanContext& vk_ctx, vk::Format swapchain_format, uint32_t num_sync_indices, ShaderRuntime& shader_runtime, - const std::filesystem::path& shader_dir) -> ResultPtr { + const std::filesystem::path& shader_dir, vk::Extent2D source_resolution) + -> ResultPtr { GOGGLES_PROFILE_FUNCTION(); auto chain = std::unique_ptr(new FilterChain()); @@ -79,6 +82,7 @@ auto FilterChain::create(const VulkanContext& vk_ctx, vk::Format swapchain_forma chain->m_num_sync_indices = num_sync_indices; chain->m_shader_runtime = &shader_runtime; chain->m_shader_dir = shader_dir; + chain->m_source_resolution = source_resolution; OutputPassConfig output_config{ .target_format = swapchain_format, @@ -90,6 +94,25 @@ auto FilterChain::create(const VulkanContext& vk_ctx, vk::Format swapchain_forma chain->m_texture_loader = std::make_unique( vk_ctx.device, vk_ctx.physical_device, vk_ctx.command_pool, vk_ctx.graphics_queue); + if (source_resolution.width > 0 && source_resolution.height > 0) { + DownsamplePassConfig downsample_config{ + .target_format = vk::Format::eR8G8B8A8Unorm, + .num_sync_indices = num_sync_indices, + .shader_dir = shader_dir, + }; + chain->m_prechain_passes.push_back( + GOGGLES_TRY(DownsamplePass::create(vk_ctx, shader_runtime, downsample_config))); + + chain->m_prechain_framebuffers.push_back(GOGGLES_TRY(Framebuffer::create( + vk_ctx.device, vk_ctx.physical_device, vk::Format::eR8G8B8A8Unorm, source_resolution))); + + GOGGLES_LOG_INFO("FilterChain pre-chain enabled: {}x{}", source_resolution.width, + source_resolution.height); + } else if (source_resolution.width > 0 || source_resolution.height > 0) { + GOGGLES_LOG_INFO("FilterChain pre-chain pending: width={}, height={}", + source_resolution.width, source_resolution.height); + } + GOGGLES_LOG_DEBUG("FilterChain initialized (passthrough mode)"); return make_result_ptr(std::move(chain)); } @@ -322,6 +345,71 @@ void FilterChain::copy_feedback_framebuffers(vk::CommandBuffer cmd) { } } +auto FilterChain::record_prechain(vk::CommandBuffer cmd, vk::ImageView original_view, + vk::Extent2D original_extent, uint32_t frame_index) + -> PreChainResult { + if (m_prechain_passes.empty() || m_prechain_framebuffers.empty()) { + return {.view = original_view, .extent = original_extent}; + } + + vk::ImageView current_view = original_view; + vk::Extent2D current_extent = original_extent; + + for (size_t i = 0; i < m_prechain_passes.size(); ++i) { + auto& pass = m_prechain_passes[i]; + auto& framebuffer = m_prechain_framebuffers[i]; + auto output_extent = framebuffer->extent(); + + vk::ImageMemoryBarrier pre_barrier{}; + pre_barrier.srcAccessMask = vk::AccessFlagBits::eShaderRead; + pre_barrier.dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite; + pre_barrier.oldLayout = vk::ImageLayout::eUndefined; + pre_barrier.newLayout = vk::ImageLayout::eColorAttachmentOptimal; + pre_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + pre_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + pre_barrier.image = framebuffer->image(); + pre_barrier.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}; + + cmd.pipelineBarrier(vk::PipelineStageFlagBits::eFragmentShader, + vk::PipelineStageFlagBits::eColorAttachmentOutput, {}, {}, {}, + pre_barrier); + + PassContext ctx{}; + ctx.frame_index = frame_index; + ctx.source_extent = current_extent; + ctx.output_extent = output_extent; + ctx.target_image_view = framebuffer->view(); + ctx.target_format = framebuffer->format(); + ctx.source_texture = current_view; + ctx.original_texture = original_view; + ctx.scale_mode = ScaleMode::stretch; + ctx.integer_scale = 0; + + pass->record(cmd, ctx); + + vk::ImageMemoryBarrier post_barrier{}; + post_barrier.srcAccessMask = vk::AccessFlagBits::eColorAttachmentWrite; + post_barrier.dstAccessMask = vk::AccessFlagBits::eShaderRead; + post_barrier.oldLayout = vk::ImageLayout::eColorAttachmentOptimal; + post_barrier.newLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + post_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + post_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + post_barrier.image = framebuffer->image(); + post_barrier.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}; + + cmd.pipelineBarrier(vk::PipelineStageFlagBits::eColorAttachmentOutput, + vk::PipelineStageFlagBits::eFragmentShader, {}, {}, {}, post_barrier); + + GOGGLES_LOG_TRACE("Pre-chain pass {}: {}x{} -> {}x{}", i, current_extent.width, + current_extent.height, output_extent.width, output_extent.height); + + current_view = framebuffer->view(); + current_extent = output_extent; + } + + return {.view = current_view, .extent = current_extent}; +} + void FilterChain::record(vk::CommandBuffer cmd, vk::Image original_image, vk::ImageView original_view, vk::Extent2D original_extent, vk::ImageView swapchain_view, vk::Extent2D viewport_extent, @@ -332,17 +420,22 @@ void FilterChain::record(vk::CommandBuffer cmd, vk::Image original_image, m_last_integer_scale = integer_scale; m_last_source_extent = original_extent; - GOGGLES_MUST(ensure_frame_history(original_extent)); + GOGGLES_MUST(ensure_prechain_passes(original_extent)); + + auto [effective_original_view, effective_original_extent] = + record_prechain(cmd, original_view, original_extent, frame_index); + + GOGGLES_MUST(ensure_frame_history(effective_original_extent)); if (m_passes.empty() || m_bypass_enabled.load(std::memory_order_relaxed)) { PassContext ctx{}; ctx.frame_index = frame_index; ctx.output_extent = viewport_extent; - ctx.source_extent = original_extent; + ctx.source_extent = effective_original_extent; ctx.target_image_view = swapchain_view; ctx.target_format = m_swapchain_format; - ctx.source_texture = original_view; - ctx.original_texture = original_view; + ctx.source_texture = effective_original_view; + ctx.original_texture = effective_original_view; ctx.scale_mode = scale_mode; ctx.integer_scale = integer_scale; @@ -351,11 +444,11 @@ void FilterChain::record(vk::CommandBuffer cmd, vk::Image original_image, return; } - auto vp = - calculate_viewport(original_extent.width, original_extent.height, viewport_extent.width, - viewport_extent.height, scale_mode, integer_scale); - GOGGLES_MUST(ensure_framebuffers({.viewport = viewport_extent, .source = original_extent}, - {vp.width, vp.height})); + auto vp = calculate_viewport(effective_original_extent.width, effective_original_extent.height, + viewport_extent.width, viewport_extent.height, scale_mode, + integer_scale); + GOGGLES_MUST(ensure_framebuffers( + {.viewport = viewport_extent, .source = effective_original_extent}, {vp.width, vp.height})); for (auto& [pass_idx, feedback_fb] : m_feedback_framebuffers) { if (feedback_fb && m_frame_count == 0) { @@ -374,8 +467,8 @@ void FilterChain::record(vk::CommandBuffer cmd, vk::Image original_image, } } - vk::ImageView source_view = original_view; - vk::Extent2D source_extent = original_extent; + vk::ImageView source_view = effective_original_view; + vk::Extent2D source_extent = effective_original_extent; for (size_t i = 0; i < m_passes.size(); ++i) { auto& pass = m_passes[i]; @@ -404,12 +497,13 @@ void FilterChain::record(vk::CommandBuffer cmd, vk::Image original_image, pass->set_source_size(source_extent.width, source_extent.height); pass->set_output_size(target_extent.width, target_extent.height); - pass->set_original_size(original_extent.width, original_extent.height); + pass->set_original_size(effective_original_extent.width, effective_original_extent.height); pass->set_frame_count(m_frame_count, m_preset.passes[i].frame_count_mod); pass->set_final_viewport_size(vp.width, vp.height); pass->set_rotation(0); - bind_pass_textures(*pass, i, original_view, original_extent, source_view); + bind_pass_textures(*pass, i, effective_original_view, effective_original_extent, + source_view); PassContext ctx{}; ctx.frame_index = frame_index; @@ -418,7 +512,7 @@ void FilterChain::record(vk::CommandBuffer cmd, vk::Image original_image, ctx.target_image_view = target_view; ctx.target_format = target_format; ctx.source_texture = source_view; - ctx.original_texture = original_view; + ctx.original_texture = effective_original_view; ctx.scale_mode = scale_mode; ctx.integer_scale = integer_scale; @@ -448,18 +542,21 @@ void FilterChain::record(vk::CommandBuffer cmd, vk::Image original_image, PassContext output_ctx{}; output_ctx.frame_index = frame_index; output_ctx.output_extent = viewport_extent; - output_ctx.source_extent = original_extent; + output_ctx.source_extent = effective_original_extent; output_ctx.target_image_view = swapchain_view; output_ctx.target_format = m_swapchain_format; output_ctx.source_texture = source_view; - output_ctx.original_texture = original_view; + output_ctx.original_texture = effective_original_view; output_ctx.scale_mode = scale_mode; output_ctx.integer_scale = integer_scale; m_output_pass->record(cmd, output_ctx); if (m_frame_history.is_initialized()) { - m_frame_history.push(cmd, original_image, original_extent); + auto history_image = !m_prechain_framebuffers.empty() + ? m_prechain_framebuffers.back()->image() + : original_image; + m_frame_history.push(cmd, history_image, effective_original_extent); } copy_feedback_framebuffers(cmd); @@ -518,6 +615,13 @@ void FilterChain::shutdown() { m_preset = PresetConfig{}; m_frame_count = 0; m_required_history_depth = 0; + + // Cleanup pre-chain resources + for (auto& pass : m_prechain_passes) { + pass->shutdown(); + } + m_prechain_passes.clear(); + m_prechain_framebuffers.clear(); } auto FilterChain::get_all_parameters() const -> std::vector { @@ -631,6 +735,49 @@ auto FilterChain::ensure_frame_history(vk::Extent2D extent) -> Result { return {}; } +auto FilterChain::ensure_prechain_passes(vk::Extent2D captured_extent) -> Result { + if (!m_prechain_passes.empty() && !m_prechain_framebuffers.empty()) { + return {}; + } + + if (m_source_resolution.width == 0 && m_source_resolution.height == 0) { + return {}; + } + + vk::Extent2D target_resolution = m_source_resolution; + if (target_resolution.width == 0) { + target_resolution.width = + static_cast(std::round(static_cast(target_resolution.height) * + static_cast(captured_extent.width) / + static_cast(captured_extent.height))); + target_resolution.width = std::max(1U, target_resolution.width); + } else if (target_resolution.height == 0) { + target_resolution.height = + static_cast(std::round(static_cast(target_resolution.width) * + static_cast(captured_extent.height) / + static_cast(captured_extent.width))); + target_resolution.height = std::max(1U, target_resolution.height); + } + + m_source_resolution = target_resolution; + + DownsamplePassConfig downsample_config{ + .target_format = vk::Format::eR8G8B8A8Unorm, + .num_sync_indices = m_num_sync_indices, + .shader_dir = m_shader_dir, + }; + m_prechain_passes.push_back( + GOGGLES_TRY(DownsamplePass::create(m_vk_ctx, *m_shader_runtime, downsample_config))); + + m_prechain_framebuffers.push_back(GOGGLES_TRY(Framebuffer::create( + m_vk_ctx.device, m_vk_ctx.physical_device, vk::Format::eR8G8B8A8Unorm, target_resolution))); + + GOGGLES_LOG_INFO("FilterChain pre-chain initialized (aspect-ratio): {}x{} (from {}x{})", + target_resolution.width, target_resolution.height, captured_extent.width, + captured_extent.height); + return {}; +} + auto FilterChain::calculate_pass_output_size(const ShaderPassConfig& pass_config, vk::Extent2D source_extent, vk::Extent2D viewport_extent) -> vk::Extent2D { diff --git a/src/render/chain/filter_chain.hpp b/src/render/chain/filter_chain.hpp index 649f3078..8f5d83ab 100644 --- a/src/render/chain/filter_chain.hpp +++ b/src/render/chain/filter_chain.hpp @@ -4,6 +4,7 @@ #include "frame_history.hpp" #include "framebuffer.hpp" #include "output_pass.hpp" +#include "pass.hpp" #include "preset_parser.hpp" #include @@ -41,10 +42,12 @@ struct FramebufferExtents { class FilterChain { public: /// @brief Creates a filter chain and its passes for the given swapchain format. + /// @param source_resolution Optional pre-chain target resolution (0,0 = disabled). /// @return A filter chain or an error. [[nodiscard]] static auto create(const VulkanContext& vk_ctx, vk::Format swapchain_format, uint32_t num_sync_indices, ShaderRuntime& shader_runtime, - const std::filesystem::path& shader_dir) + const std::filesystem::path& shader_dir, + vk::Extent2D source_resolution = {0, 0}) -> ResultPtr; ~FilterChain(); @@ -105,6 +108,15 @@ class FilterChain { vk::Extent2D original_extent, vk::ImageView source_view); void copy_feedback_framebuffers(vk::CommandBuffer cmd); + [[nodiscard]] auto ensure_prechain_passes(vk::Extent2D captured_extent) -> Result; + + struct PreChainResult { + vk::ImageView view; + vk::Extent2D extent; + }; + auto record_prechain(vk::CommandBuffer cmd, vk::ImageView original_view, + vk::Extent2D original_extent, uint32_t frame_index) -> PreChainResult; + VulkanContext m_vk_ctx; vk::Format m_swapchain_format = vk::Format::eUndefined; uint32_t m_num_sync_indices = 0; @@ -130,6 +142,11 @@ class FilterChain { FrameHistory m_frame_history; uint32_t m_required_history_depth = 0; std::atomic m_bypass_enabled{false}; + + // Pre-chain stage (generic, extensible) + vk::Extent2D m_source_resolution; // 0,0 = disabled + std::vector> m_prechain_passes; + std::vector> m_prechain_framebuffers; }; } // namespace goggles::render diff --git a/src/util/config.hpp b/src/util/config.hpp index 1b3065f6..1984109f 100644 --- a/src/util/config.hpp +++ b/src/util/config.hpp @@ -64,6 +64,8 @@ struct Config { bool enable_validation = false; ScaleMode scale_mode = ScaleMode::fill; uint32_t integer_scale = 0; + uint32_t source_width = 0; + uint32_t source_height = 0; } render; struct Logging { diff --git a/tests/app/test_cli.cpp b/tests/app/test_cli.cpp index eda704f4..437a52a9 100644 --- a/tests/app/test_cli.cpp +++ b/tests/app/test_cli.cpp @@ -136,13 +136,24 @@ TEST_CASE("parse_cli: app args may include options that collide with viewer flag REQUIRE(result->options.app_command[1] == "--config"); } -TEST_CASE("parse_cli: app width/height must be provided together", "[cli]") { +TEST_CASE("parse_cli: single-dimension app width/height is allowed", "[cli]") { auto cfg = default_config_path(); - ArgvBuilder args({"goggles", "--config", cfg, "--app-width", "640", "--", "vkcube"}); - auto result = goggles::app::parse_cli(args.argc(), args.argv.data()); - REQUIRE(!result); - REQUIRE(result.error().code == ErrorCode::parse_error); + SECTION("width only") { + ArgvBuilder args({"goggles", "--config", cfg, "--app-width", "640", "--", "vkcube"}); + auto result = goggles::app::parse_cli(args.argc(), args.argv.data()); + REQUIRE(result); + REQUIRE(result->options.app_width == 640); + REQUIRE(result->options.app_height == 0); + } + + SECTION("height only") { + ArgvBuilder args({"goggles", "--config", cfg, "--app-height", "480", "--", "vkcube"}); + auto result = goggles::app::parse_cli(args.argc(), args.argv.data()); + REQUIRE(result); + REQUIRE(result->options.app_width == 0); + REQUIRE(result->options.app_height == 480); + } } TEST_CASE("parse_cli: --help returns exit_ok", "[cli]") {