-
Notifications
You must be signed in to change notification settings - Fork 0
The composite filter
src/render/shaders.h, pass 1 (kCleanPS)


A PAL-60 GameCube over composite. Left, the filter off: dot crawl speckles the gold and roughens every edge of the chequered flag. Right, both halves on. The crop is not chosen by eye — it is where the measured difference between the two frames is largest.
A composite signal carries colour and brightness on one wire, and the two leak into each other. That crosstalk shows up as two different artefacts, and they need two different treatments because they live in two different places:
| Artefact | What it is | Where it lives |
|---|---|---|
| Rainbow shimmer | dense brightness detail the decoder mistook for colour | the chroma |
| Dot crawl | the colour subcarrier leaking into brightness | the luma |
Blurring the chroma removes the first and does nothing at all for the second.
Everything on this page happens before any deinterlacer has touched the picture. That is the entire reason pass 1 exists as a separate pass.
A cleanup that runs afterwards has to work out which source line the pixel in
front of it came from, and for a mode that interpolates there is no single
answer. It ends up subtracting a pattern that pixel never carried, which is not
a cleanup but a new artefact. Doing it first removes the question — and it is
also why TemporalDelta() and DotDemodDelta() are written as deltas
computed on the woven pixels, kept apart from the decision of where to apply
them.
This was found the hard way: the filter worked with deinterlacing off and fell apart with it on.
Composite carries colour at roughly a quarter of the bandwidth it carries brightness, so a picture decoded from it has no fine colour detail in it to begin with. Averaging the colour sideways therefore costs nothing real, and it takes the shimmer with it.
Reported symptom: with colour shimmer turned up, anything gold on a blue background — the stopwatch on Mario Kart's mode select — gained a grey fringe.
The first diagnosis was wrong and is worth recording, because the wrong answer is the tempting one. The old code read:
float3 soft = sum / n; // box average in RGB
return soft + (Luma(rgb) - Luma(soft)); // brightness put backThat looks like it mishandles luma, and the obvious correction is "convert to
YCbCr, average only Cb and Cr, recombine with the original Y". Checked against a
real captured frame, the two agree to 0.000000 levels: adding
Luma(rgb) − Luma(soft) to all three channels is algebraically identical to
averaging chroma and keeping the centre's luma. Luma of a chroma vector is zero
by construction, so the scalar add lands exactly where the matrix round-trip
would.
The real cause is simpler and harder: gold and blue are close to complementary, so the mean of their chroma genuinely is grey. No unweighted average across that edge can return anything else. The fix had to change which neighbours are averaged, not what is averaged.
const float centreLuma = Luma(rgb);
const float3 centreChroma = rgb - centreLuma;
for (int dx = -gChromaSoft; dx <= gChromaSoft; ++dx) {
float3 s = FetchRgbAt(int2(x + dx, row));
float3 sc = s - Luma(s);
float3 d = sc - centreChroma;
float w = exp(-dot(d, d) * kChromaKeepEdge);
sum += sc * w;
n += w;
}
return centreLuma + sum / max(n, 1e-4);In a flat area every weight is near one and this is the box average it always was. Across a colour edge the far side falls away and the colour survives.
Measured against the gold-on-blue edge from the capture above:
kChromaKeepEdge |
Saturation lost at the edge | Smoothing inside a flat area |
|---|---|---|
| 0 (the old box average) | 0.42 | 2.05 |
| 4 | 0.19 | 2.05 |
| 8 (chosen) | 0.09 | 2.05 |
| 20 | 0.05 | 2.05 |
The second column not moving is the point: the weighting costs nothing on the half of the job that matters, because inside a flat area there is no colour distance to discount. Unfiltered, that same area measures 2.54.
Eight rather than twenty because the returns past it are small and the risk is not — a gentle chroma gradient, a sky or a fade, is made of exactly the small differences a high weight would start discarding.
The loop is deliberately not unrolled. Every FetchRgbAt expands into a
pixel-format branch and a four-way choice of which frame to read, so seventeen
unrolled copies cost the shader compiler seconds — which the user waits through
at every start. A real loop over the actual radius costs nothing at runtime and
compiles in a fraction of the time.
The Picture tab has a checkbox and a slider, and they hand over to each other.
They are two different bargains, which is why they are two controls:
- Averaging costs nothing and only helps where the picture is standing still.
- Demodulation is the only thing that helps where something is moving, and it costs sharpness.
Behind the single slider they used to share, the first was switched on by the very first step above zero and the whole rest of the travel was the second getting gradually softer — which was exactly what it felt like to use, and left no way to have the free half on its own.
The averaging locks on whenever demodulation is asked for (the checkbox ticks itself and greys out when the slider leaves zero). Without it the demodulator works over the still parts of the picture as well, paying sharpness there for something that is free.
float3 TemporalDelta(int x, int row) {
float3 f0 = FetchRgbFrame(int2(x, row), 0);
…
return (f0 + f1 + f2 + f3) * 0.25 - f0;
}The number came off the card, not out of a textbook. Measured on a PAL-60 console, the mean frame-to-frame difference of a still picture:
| Lag | 1 frame | 2 frames | 3 frames | 4 frames |
|---|---|---|---|---|
| Mean difference | 1.91 | 2.62 | 1.90 | 0.78 |
A clean four-frame cycle. Averaging two frames therefore cancels nothing — it picks two arbitrary points of that cycle. Averaging four covers it exactly, and what is left is the picture.
This half costs no sharpness at all. Analogue noise, being uncorrelated between frames, goes the same way for free.
A console generates its line timing and its subcarrier from the same clock, so the phase walk per frame is a fixed fraction of a turn, and which fraction depends on the colour system rather than on the frame rate:
| Phase per frame | Sequence | Left after 2 frames | after 4 | |
|---|---|---|---|---|
| PAL B/G/I, 50 Hz | 270° | 4 frames | 0.71 | 0.00 |
| PAL-60 | 270° (measured) | 4 frames | 0.71 | 0.00 |
| PAL-M | 90° | 4 frames | 0.71 | 0.00 |
| NTSC-M | 180° | 2 frames | 0.00 | 0.00 |
So a 50 Hz PAL console behaves exactly like the 60 Hz one this was built against — same colour system, same four-frame walk — and NTSC repeats twice as fast, which four frames also covers.
The only difference 50 Hz makes is that four frames span 160 ms instead of 133, so the "nothing is moving here" judgement has to hold for a little longer.
TemporalGate() decides how much of the correction to trust at each pixel.
This is the part that took two attempts. Dot crawl crawls: it is not still, so any detector that simply asks "did this pixel change" calls it movement and switches the filter off exactly where the artefact is. Measured that way, the filter took 48 % off the mild half of the picture and 15 % off the worst one per cent — the wrong way round, and therefore invisible.
What separates them is not amount but scale. The shimmer is a fine pattern a few pixels across; a picture that is really moving moves in broad shapes.
for (int dx = -3; dx <= 3; ++dx) { s0 += LumaFrameAt(…, 0); … } // seven samples
float mhi = max(max(s0, s1), max(s2, s3)) * 0.142857;
float mlo = min(min(s0, s1), min(s2, s3)) * 0.142857;
return 1.0 - saturate((mhi - mlo - gMotionSlack) * gMotionSlope);Seven horizontal samples is a little over two cycles of the colour subcarrier, so the shimmer averages itself away before it is ever asked about, and real movement comes through untouched. Then a slack, and a flank.
Where the gate lets go is the whole trade, so it is a checkbox — Avoid ghosting, next to the averaging that it governs.
The filter can only average, and averaging across movement is smearing. That cannot be computed away, only moved, which is why this is a setting rather than a better default.
| Slack | Fully closed at | What you get | |
|---|---|---|---|
| off — crawl first | 5 levels | 26 levels | slow, low-contrast movement is averaged with three older copies of itself: a trail |
| on — ghosting first | 5 levels | 9 levels | moving edges stay clean; slow areas keep some crawl, where the demodulator picks the work back up |
The slack does not move, deliberately. Five levels out of 255 is not a sensitivity setting — it is the card's noise floor. Setting it to zero was tried and is wrong: it makes the filter switch on and off on a still picture, which gains nothing and looks worse than either operating point.
Only the flank moves. Between 9 and 26 levels lies exactly the band of slow, low-contrast movement where the trail becomes visible.
DotDemodDelta(). This is the slider.
Dot crawl is the subcarrier amplitude modulated by however much colour is at that point in the picture — which is why it only appears at colour edges. Measured on this card, isolating the pattern through its own four-frame cycle and looking at its spectrum, a band of thirty-two bins around the peak holds under half its energy. The modulation smears it far too wide for any narrow filter to catch, and a wide one takes the picture with it.
Synchronous detection does not care about the smearing. Multiplying the line by the carrier and by the same carrier a quarter cycle over moves the pattern down to zero frequency, where a short average recovers exactly how much of it is there; multiplying back up reconstructs it, and it is subtracted.
float w = kTwoPi / gCarrierPeriod;
…
acc += hann * l * cos(w * (x + k)); // in phase
accQ += hann * l * sin(w * (x + k)); // quadrature
…
float pattern = 2.0 * (acc * cos(ph0) + accQ * sin(ph0)) / norm;
return -pattern; // subtracted from all three channelsPicture detail is not tied to the carrier's phase and survives, apart from whatever happened to sit right on the frequency. Subtracting the same amount from all three channels moves brightness and leaves colour alone, which is the half of the picture this belongs to.
Against this card's own frames, at a window of nine samples this removes 81 % of the pattern for 17 % of the horizontal sharpness, where the three-tap notch it replaced managed 67 % for 18 %.
There are two passes over the window. The first exists only to find the local average:
float mean = 0.0, norm = 0.0;
for (…) { mean += hann * Luma(FetchRgbAt(…)); norm += hann; }
mean /= norm;The carrier does not sum to zero over a window of a few samples, so a flat area of picture correlates with it and gets a pattern invented on top of itself. Measured on a constant field of 128, leaving this out produced swings of ninety levels. That was the "picture goes oddly bright" bug.
A raised cosine (Hann) weighting is applied over the window in both passes, so the ends do not ring.
float cycles = 8.2 - gDotNotch * 5.9; // a little over eight, down to just over two
int r = (int)floor(cycles * gCarrierPeriod * 0.5 + 0.5);
r = clamp(r, 2, 12);| Window (samples) | 25 | 15 | 11 | 9 | 7 | 5 |
|---|---|---|---|---|---|---|
| Pattern removed | 42 % | 61 % | 74 % | 81 % | 90 % | 99 % |
| Horizontal sharpness | −7 % | −11 % | −14 % | −17 % | −25 % | −39 % |
In practice a value of 0.1 on the slider is already enough for most material.
The window is an integer count of samples, clamped to 2–12, so a continuous
slider has a great many positions that compute the same window and change
nothing. BuildDotCrawlSteps() walks the slider's range, collects the distinct
windows it produces, and the control snaps to those — nine steps on PAL, plus
off.
A slider that moves without changing anything is claiming something untrue. The
label reads step 4 of 9 rather than a number with no meaning, and the line
underneath says what that step costs.
The window is measured in cycles of the carrier, and that matters because the two are not the same thing across standards: NTSC's carrier occupies 3.77 samples of a 720 pixel line where PAL's occupies 3.04. A window fixed at a pixel count therefore hands NTSC a fifth fewer cycles to detect with.
Simulated against an amplitude modulated pattern — which is what dot crawl actually is — that cost NTSC 61 % removal at the far end of the slider where PAL got 69 %, and it invented a quarter more pattern of its own doing it. Counted in cycles the two land at 67 % and 72 %.
The same scaling covers capture widths other than 720, which is why
gCarrierPeriod arrives already multiplied by srcW / 720
(VideoRenderer::effectiveCarrierPeriod()).
gCarrierPeriod comes from VideoStandardSubcarrierSamples() — see
Source and signal. Nothing is detected from the picture.
With the carrier assumed wrong — PAL's filter against an NTSC signal or the reverse — removal falls from about 70 % to 34 % at the middle of the slider. That is the reason to care about the Source tab beyond getting a picture at all.
float handled = 0.0;
if (gTemporal > 0.0 && gHistCount >= 3) {
handled = TemporalGate(p.x, p.y) * gTemporal;
rgb += TemporalDelta(p.x, p.y) * handled;
}
if (gDotNotch > 0.0) rgb += DotDemodDelta(p.x, p.y) * (1.0 - handled);Both work out how much pattern is present from the captured pixels. Where the four-frame average has already taken it away, the demodulator would go and subtract it a second time — putting an inverted copy back. That is why turning both up used to make the picture worse than either one alone.
So the temporal half reports how much of the job it did, and the demodulator handles only the rest:
- Still picture — the average does it all, at no cost in sharpness.
- Moving picture — the average steps back and the demodulator takes over.
Not handled. SECAM's colour is frequency modulated on two carriers that alternate line by line, so there is no single frequency to demodulate against. The temporal half still applies, and still works.
This is roughly where composite restoration generally stops. The established offline filters — TComb, DeDot, LUTDeCrawl, Checkmate — are all temporal and say so themselves about moving content. Going further needs motion compensation of the QTGMC sort, which is not a real-time proposition.