-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.
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.
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.
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.
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).
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.
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 | 1× | 0.71 levels | 0.71 levels |
| 1440×1152 | 2× | 0.38 | 1.52 |
| 1920×1536 | 2.7× | 0.25 | 1.55 |
| 2880×2304 | 4× | 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.
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.
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 | — |
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.
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.
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.
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.