Post-Processing Effects
Post-processing effects are applied to the entire screen after the main scene is rendered. Brine2D includes a GPU-accelerated pipeline that handles render target management and texture ping-ponging automatically.
Built-in effects:
- Grayscale — adjustable intensity desaturation
- Blur — two-pass Gaussian blur (horizontal + vertical)
- Letterbox — fixed-aspect-ratio/pillarbox scaling, decoupling game-world rendering resolution from window resolution
Custom effects are supported via the ISDL3PostProcessEffect interface.
How It Works
graph LR
A[Scene] --> B[Main Render Target]
B --> C[Effect 1]
C --> D[Effect 2]
D --> E[Effect N]
E --> F[Swapchain]
style B fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style C fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style D fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style E fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style F fill:#1e3a5f,stroke:#569cd6,stroke-width:2px,color:#fff
- The scene renders to an off-screen render target instead of the swapchain
- The pipeline runs each enabled effect in order, ping-ponging between textures
- The final effect writes directly to the swapchain
The pipeline manages the render targets and ping-pong buffers — you don't need to create or manage them yourself.
Setup
1. Register Post-Processing
In Program.cs, call AddPostProcessing and enable it:
2. Add Effects
Chain effect registration after AddPostProcessing:
builder.Services.AddPostProcessing(options =>
{
options.Enabled = true;
});
builder.Services.AddGrayscaleEffect(intensity: 0.8f);
builder.Services.AddBlurEffect(blurRadius: 2.0f);
That's it. The pipeline picks up registered effects automatically when the first frame renders.
Built-in Effects
Grayscale
Desaturates the image using luminance weighting (0.299R + 0.587G + 0.114B).
| Parameter | Default | Description |
|---|---|---|
width |
1280 | Initial render width (updated on resize) |
height |
720 | Initial render height (updated on resize) |
intensity |
1.0f | 0.0 = original color, 1.0 = full grayscale |
Intensity can be changed at runtime via the effect instance.
Blur
Two-pass Gaussian blur — horizontal then vertical — using a single shader with direction uniforms.
| Parameter | Default | Description |
|---|---|---|
width |
1280 | Initial render width (updated on resize) |
height |
720 | Initial render height (updated on resize) |
blurRadius |
2.0f | 1.0 = subtle, 5.0 = heavy blur |
BlurRadius can be changed at runtime via the effect instance.
Letterbox
Fixes your game's rendering to a specific design resolution, regardless of the actual window size, and scales the result to fit the window — centered, aspect-ratio preserved, with bars filling the remainder (letterbox bars on top/bottom, or pillarbox bars on left/right, depending on the window's aspect ratio vs. the design resolution).
builder.Services.AddPostProcessing(options => options.Enabled = true);
builder.Services.AddLetterbox(designWidth: 1280, designHeight: 720);
| Parameter | Default | Description |
|---|---|---|
designWidth |
required | Fixed game-world rendering width |
designHeight |
required | Fixed game-world rendering height |
barColor |
opaque black | Color used to fill the letterbox/pillarbox bars |
Unlike AddGrayscaleEffect/AddBlurEffect, registering AddLetterbox(...) changes how the entire rendering pipeline is set up, not just what happens in one shader pass:
- The main render target (and all ping-pong targets used by other post-processing effects) are sized to the design resolution, not the live window size. All game-world drawing — sprites, particles, primitives — happens at that fixed resolution. Window resizes no longer recreate these targets.
- The letterbox effect always runs last in the pipeline (
Order = int.MaxValue), regardless of what other effects you've registered or whatOrderyou assign them. - Every frame, it computes a centered, aspect-preserving fit rect for the design resolution within the current window/swapchain size, clears the swapchain to
BarColor, then blits the design-resolution image into that centered rect.
This replaces the older pattern of hand-rolling a pair of custom IRenderSystems to coordinate a fixed-size render target and a manual blit — that workaround is no longer necessary.
Default camera automatically matches the design resolution. If you're using the framework-provided default ICamera (registered by AddBrine2D(), not one you construct yourself), it automatically detects when AddLetterbox(...) is configured and sizes itself (ViewportWidth/ViewportHeight) and self-centers Position to the design resolution rather than the live window size. You don't need to manually reconfigure the camera after opting into letterbox — this only applies to the DI-provided default camera; if you construct your own Camera2D directly, pass the design resolution to its constructor yourself.
Effect Ordering
Effects execute in ascending Order value. Both built-in effects default to Order = 0, so they run in registration order. Set Order on the effect instance to control sequencing when you have multiple effects.
Custom Effects
Implement ISDL3PostProcessEffect to write your own GPU shader effect:
public class MyCustomEffect : ISDL3PostProcessEffect, IDisposable
{
public int Order { get; set; } = 100;
public string Name => "MyEffect";
public bool Enabled { get; set; } = true;
private int _width;
private int _height;
public MyCustomEffect(int width, int height)
{
_width = width;
_height = height;
// Create your GPU pipeline, shaders, samplers here
}
public void SetDimensions(int width, int height)
{
_width = width;
_height = height;
// Recreate any dimension-dependent resources
}
public void Apply(IRenderer renderer, nint sourceTexture, nint targetTexture, nint commandBuffer)
{
// Render a full-screen quad with your shader,
// reading from sourceTexture and writing to targetTexture.
// See GrayscaleEffect or BlurEffect for reference.
}
public void Dispose()
{
// Release GPU resources
}
}
Register it via DI:
builder.Services.AddSingleton<IPostProcessEffect>(sp =>
{
return new MyCustomEffect(1280, 720);
});
The pipeline resolves all IPostProcessEffect registrations on first use.
Factory Registration
For effects that need the GPU device to be initialized first, use the factory pattern on SDL3PostProcessPipeline:
Factories are called lazily the first time the pipeline executes.
Runtime Control
Effects can be toggled and adjusted at runtime:
// Disable/enable the whole pipeline
postProcessingOptions.Enabled = false;
// Toggle individual effects
grayscaleEffect.Enabled = false;
// Adjust parameters
grayscaleEffect.Intensity = 0.5f;
blurEffect.BlurRadius = 4.0f;
Configuration Options
PostProcessingOptions controls pipeline-level settings:
| Property | Default | Description |
|---|---|---|
Enabled |
false |
Master toggle — when disabled, the scene renders directly to the swapchain |
RenderTargetFormat |
null |
Texture format override; null uses the swapchain format |
builder.Services.AddPostProcessing(options =>
{
options.Enabled = true;
options.RenderTargetFormat = SDL3.SDL.GPUTextureFormat.R8G8B8A8Unorm; // optional
});
Window Resize
The pipeline handles resize automatically. When the renderer recreates its render targets, it calls SetEffectDimensions on all effects and rebuilds the ping-pong buffers. Effects that own dimension-dependent GPU resources (like the blur effect's intermediate render target) recreate them in SetDimensions.
Performance
- Each effect is one or more GPU render passes — keep the total pass count low
- Blur is two passes (horizontal + vertical) plus the pipeline's own source/target management
- Effects compile HLSL at runtime via SDL_ShaderCross, which transpiles to the format required by the active GPU backend (SPIR-V for Vulkan, DXIL for D3D12, DXBC for D3D11, MSL for Metal)
- Render targets are created once and reused; they're only recreated on resize
- Disable effects you aren't using — disabled effects are skipped entirely (zero cost)
Resolution Scaling
For expensive effects, render at lower resolution:
var scale = 0.5f;
var w = (int)(1280 * scale);
var h = (int)(720 * scale);
builder.Services.AddBlurEffect(width: w, height: h, blurRadius: 3.0f);
The GPU blit to the swapchain handles upscaling with linear filtering.
Troubleshooting
Black screen after enabling post-processing
- Check
Enabled = true—PostProcessingOptions.Enableddefaults tofalse - Check effect registration order —
AddPostProcessing()must come beforeAddGrayscaleEffect()/AddBlurEffect() - Check logs — the pipeline logs effect initialization and errors at Info/Error levels
Effects not visible
- Check
effect.Enabled— effects default to enabled, but verify nothing is toggling them off - Check effect parameters —
Intensity = 0orBlurRadius = 0produces no visible change
Artifacts or incorrect colors
- Check render target format — mismatched formats between the pipeline and effect shaders can cause issues. Leave
RenderTargetFormatasnullunless you have a reason to override it
Next Steps
- GPU Renderer — Render targets, scissor rects, blend modes
- Sprites — Sprite rendering
- Cameras — Camera follow, zoom, and shake
- Performance — Rendering optimization