Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions openspec/changes/add-prechain-downsample/proposal.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions openspec/changes/add-prechain-downsample/tasks.md
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions shaders/internal/downsample.frag.slang
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions src/app/application.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
16 changes: 4 additions & 12 deletions src/app/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>(ErrorCode::parse_error,
"--app-width and --app-height must be provided together");
}
}
if (options.app_command.empty()) {
return make_error<void>(ErrorCode::parse_error,
"missing target app command (use '--detach' for viewer-only mode, "
Expand All @@ -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 <app> to avoid app args (e.g. '--config') being parsed as Goggles options.
- --app-width/--app-height apply only in default mode.)");
- '--' is required before <app> to avoid app args (e.g. '--config') being parsed as Goggles options.)");

CliOptions options;
register_options(app, options);
Expand Down
6 changes: 6 additions & 0 deletions src/app/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
12 changes: 8 additions & 4 deletions src/render/backend/vulkan_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -889,8 +890,9 @@ auto VulkanBackend::init_filter_chain() -> Result<void> {
.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 {};
}

Expand Down Expand Up @@ -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<void> {
GOGGLES_PROFILE_SCOPE("AsyncShaderLoad");
Expand All @@ -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<void>(chain_result.error().code, chain_result.error().message);
Expand Down
3 changes: 3 additions & 0 deletions src/render/backend/vulkan_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions src/render/backend/vulkan_debug.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/render/chain/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading