Skip to content

Scaling and sharpening

NuclearMeltdown edited this page Aug 24, 2026 · 4 revisions

Scaling and sharpening

src/render/shaders.h, pass 3 (kScalePS)

The pipeline is split so that scaling never sees a card format. Pass 1 cleans the signal, pass 2 deinterlaces and applies geometry, and only then does pass 3 resample onto the window. Every scaling filter therefore works on real pixels rather than on half-decoded chroma, and is independent of what the card happened to deliver.

The five filters

ScaleFilter in src/config.h: Nearest, Bilinear, Bicubic, Lanczos3, SharpBilinear.

Filter Taps Notes
Nearest 1 Point fetch. Exact pixels, hard edges, uneven pixel sizes at non-integer factors
Bilinear 2×2 The hardware sampler
Bicubic 4×4 Catmull-Rom, so it interpolates through its samples rather than approximating them
Lanczos3 6×6 Sharpest of the resampling filters; slight ringing at hard edges
Sharp bilinear 2×2 Nearest up to the integer factor, bilinear only across the remainder

Catmull-Rom rather than Mitchell or B-spline: it passes through its control points, so a pixel that lands exactly on a source pixel comes back unchanged. For a signal made of hard pixel edges that is what you want.

Sharp bilinear is the right answer for pixel art at non-integer window sizes. It gives you the crispness of nearest neighbour with the uneven-pixel artefact confined to a single blend across each boundary, instead of some pixels being drawn twice as wide as others.

Both Catmull-Rom and Lanczos3 are written as real loops with weights computed per tap, not as unrolled tap lists — for the same shader-compile reason as the composite filter's chroma loop.

Integer scaling

AspectMode::Integer in src/config.h. The picture is scaled by the largest whole-number factor that still fits the window, and centred. Combined with nearest neighbour this is the only combination that reproduces the source exactly — every source pixel becomes an n×n block, all blocks the same size.

The other aspect modes are Source (use what the card reports), Force16x9, Force4x3 and Stretch (fill the window, ignore aspect).

Contrast adaptive sharpening

float3 sharp = c + (c * 4.0 - n - s - w - e) * (amount * 0.25);
return clamp(sharp, lo, hi);

A cross-shaped unsharp mask, sampled one output pixel away — so the amount of sharpening tracks how large the picture is on screen rather than how large the source is.

The clamp is what makes it adaptive: lo and hi are the minimum and maximum of the centre pixel and its four neighbours, so the result can never leave the range the neighbourhood already spans. Flat areas stay clean and edges cannot ring, which is what separates this from a plain unsharp mask that halos everything.

It samples through a linear sampler rather than by texel fetch, because at this point in the pipeline the coordinates are fractional anyway.

Sharpening is deliberately not applied to what the recorder, the screenshots or the virtual camera receive. It is a property of viewing at a particular size, not a property of the picture.

Where the picture lands

VideoRenderer::videoRect() reports the rectangle the picture occupies in client pixels, which is what the crop overlay, the mouse hit-testing and the toolbar all work from.

SetTopInset() reserves pixels at the top of the window for the toolbar. The picture is fitted below them rather than drawn underneath, so the bar never covers what you are playing.

Clone this wiki locally