Skip to content

Scaling and sharpening

NuclearMeltdown edited this page Aug 25, 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.

The native pixel grid

SampleNativeGrid() in the scale pass. Off by default; set the source's real horizontal pixel count and it resolves every output pixel to the console's own pixel instead of to a fraction of one.

The problem it solves

A capture card samples the active line at a fixed rate — 720 samples for BT.601, whatever the source is doing. A SNES draws 256 pixels across that same line, so each console pixel lands on about 2.8 samples. Not a whole number, and already low-pass filtered by the card.

Scale that straight to a window and the pixel boundaries fall wherever the arithmetic puts them: some console pixels come out three output blocks wide, some four, and the edges between them are soft in a way that changes across the picture. That is why upscaled pixel art usually looks slightly wrong even when it is sharp.

What it does

float n    = floor(uv.x * (float)gNativeWidth);   // which console pixel
float span = gSrcSize.x / (float)gNativeWidth;    // how many samples it covers
float x0   = n * span, x1 = x0 + span;

for (int k = 0; k < 16; ++k) {
  int x = first + k;
  if ((float)x >= x1) break;
  float cover = min((float)x + 1.0, x1) - max((float)x, x0);
  sum += texSrc.Load(int3(clamp(x, 0, maxX), row, 0)).rgb * cover;
  weight += cover;
}
return sum / max(weight, 1e-4);

The partial-coverage weighting at each end is the part that matters. At 2.8 samples per pixel the boundaries land inside a sample two times out of three, so averaging whole samples would put the grid back in the wrong place.

Verified on a 2560-wide output with the count set to 256: 245 to 252 distinct column transitions, against a theoretical maximum of 255. The few missing ones are neighbouring console pixels that happen to share a colour. Every block comes out exactly ten output pixels wide.

What it is not

This is not what an OSSC does, and it cannot be. An OSSC samples the analogue waveform at the console's own dot clock, so it recovers the pixels before they are ever mixed together. Here they have already been resampled once and filtered by the card. What this recovers is the grid, not the detail that grid used to carry.

Worth having, and worth not overselling.

Horizontal only

Deliberately. Vertically the card already delivers one sample per real line — 240 or 288 of them — so there is nothing to undo. The zoomed result shows it clearly: hard blocks across, untouched detail down.

It also replaces the filter setting rather than combining with it. Resolving to the console's pixel is a nearest-neighbour decision; running a smoothing filter afterwards would restore exactly the boundary softness it just removed.

Pairs with integer scaling, which is what makes every block the same width on screen as well as in the source.

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 source texel away.

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.

A consequence worth knowing: a perfect step edge is left alone at any setting. The centre is already the local extreme and the clamp will not push it past its own neighbours. There is nothing to sharpen about an edge that is already hard — the filter works on the bandwidth-limited ramps a composite signal actually delivers.

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

The offset used to come from the wrong size

This line read 1.0 / gDstSize until it was measured. uv runs over the source texture, so an offset of one destination pixel steps srcSize / dstSize source texels — less than one whenever the picture is enlarged.

Both halves then collapse together. The neighbours are sampled between texels, so the unsharp sum shrinks; and the clamp is built from those same samples, so its headroom shrinks with it. The filter faded out in proportion to how large you made the window.

Measured against a bandwidth-limited edge on a 720×576 source, mean change per pixel across the edge zone at full strength:

Window Factor 1/gDstSize 1/gSrcSize
720×576 0.71 levels 0.71 levels
1440×1152 0.38 1.52
1920×1536 2.7× 0.25 1.55
2880×2304 0.12 1.77
3840×3072 5.3× 0.06 1.90
480×384 0.67× 1.43 0.92

At 1:1 the two agree, which is why the filter was not obviously broken. Beyond that it faded to nothing — and at 4K, 0.06 levels out of 255 is invisible. That is exactly the regime a 240p or 576i picture is normally watched in.

The last row is the other half of the same error: shrinking the picture stepped more than one texel, reaching across detail and over-sharpening.

Counted in source texels the response holds between 0.7 and 1.9 levels across the whole range, which is what a scale-independent sharpener should do.

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.

Scanlines and the mask

Off by default, and meant to stay that way for anyone who wants the signal as clean as it arrived. They are here because 240p artwork was drawn for a display that had gaps between its lines and a coloured mask over its phosphors, and on a modern panel the absence of both is itself a distortion.

Like sharpening, both are display-only — a recording takes the intermediate, which is upstream of this pass. Baking scanlines into a file at source resolution would put the gaps in the wrong places for whoever plays it back.

Scanlines follow the source, not the screen

float scale = gDstSize.y / max(gSrcSize.y, 1.0);
float room  = saturate((scale - 2.0) * 1.0);
if (room <= 0.0) return 1.0;

float phase = frac(uv.y * gSrcSize.y);
float beam  = 0.5 + 0.5 * cos(3.14159265 * (abs(phase - 0.5) * 2.0));
float dark  = 1.0 - gScanlines * room * (1.0 - beam);
return dark / (1.0 - gScanlines * room * 0.5);

Three decisions:

The grid is the source's. The gaps belong to the signal, not to the monitor showing it.

Below twice the source height it switches off. There is nowhere to put a gap: the line and the gap land inside the same output pixel and the result is moiré, not scanlines. It fades in across 2× to 3× rather than appearing abruptly.

The brightness is put back. Scanlines darken by construction, so without compensation the control is mostly a brightness slider. Dividing by the profile's mean keeps it structural. Measured across scales:

Source → output Gate Mean Range
240p → 1440p (6×) on 1.000 0.46–1.54
288p → 1440p (5×) on 1.000 0.46–1.54
480p → 1440p (3×) on 1.000 0.46–1.53
576p → 1440p (2.5×) on 1.000 0.79–1.21
720p → 1440p (2×) off 1.000
1080p → 1440p (1.3×) off 1.000

The mask

Mask() tints output pixels in triads — aperture grille (vertical stripes, the way a Trinitron worked) or shadow mask (the triads step sideways every other line). Same brightness compensation.

It needs real output resolution to read as a mask rather than as a colour cast: at 1080p over a 240p source each source line is four output pixels tall and a triad is three across, which is about the floor.

Both are capped at 0.5

Not a taste judgement — beyond half strength the compensation cannot hold the colour. Scanlines and the mask both darken by construction and both divide the result by the profile's mean to put the brightness back; the further the trough goes, the more the division has to lift the peak, and past 0.5 that lift starts clipping channels apart from one another. What arrives is not a stronger effect but a colour cast.

The setting is clamped on load as well as in the control, so an edited configuration file cannot reach past it either.

The lift is limited by the brightest channel

Even below 0.5 the compensation must not be allowed to clip, so the gain is capped by whatever headroom the pixel actually has:

float peak = max(max(rgb.r, rgb.g), rgb.b);
if (peak > 1e-4) {
  float headroom = 1.0 / peak;
  gain = min(gain, max(headroom, 1.0));
}
rgb *= gain;

Clipping one channel and not the others is what turns a bright area a different colour. Capping the gain by the peak channel means the pixel gets darker than the ideal compensation would like, which is a change in brightness — and a brightness error is far less visible than a hue error. max(headroom, 1.0) keeps the cap from ever pulling a pixel below where it started.

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