CRT / Composite Video¶
References: the authoritative detail is in frontend.md (§ Display pipeline / shader ladder); the palette generator is in ppu-2c02.md. This page is a curated handbook entry point.
RustyNES reproduces the NES's analog look with GPU post-passes over the PPU
framebuffer. Every filter here is display-only: it never touches the core, the
index framebuffer, audio, or any golden vector — the visual_regression corpus
stays byte-identical with any filter active (introduced in v2.1.2 "Prism").
In-core generated NTSC palette¶
Rather than ship a hand-authored RGB table, RustyNES generates its base palette
from a model of the 2C02's composite-video output:
rustynes_ppu::generate_base_palette (a Bisqwit / ares YIQ integration), with the
standard 2C02 composite emphasis applied. This is a core function (deterministic,
no GPU), so the same colors appear headless and on screen.
The shader ladder (v2.1.2 "Prism")¶
Presentation filters run as GPU post-passes. Two selection surfaces coexist:
- Legacy single-select (Settings → Video): an NTSC filter dropdown
(
[graphics] ntsc_filter=off/composite/rgb/composite-rt) plus a binary CRT toggle (crt_filter+crt_scanline). Thecomposite-rt(Bisqwit) option is the only place the Bisqwit picture knobs (contrast / saturation / brightness / hue) have a UI. - Composable stack (Settings → Shaders): add / reorder / toggle / remove any
of the six
BuiltinPassvariants, each with#pragma parametersliders, plus a preset bank and constrained.slangp/.cgpimport.
Precedence: when the stack has any enabled pass it owns the post-process path
and the legacy single-select is bypassed; otherwise the legacy filter applies.
The fixed render order is: stack → CRT → Bisqwit → NTSC → direct blit
(Gfx::render_with_overlay).
The three composite rungs¶
Ntsc— a cheap simplified blur (5-tap + scanline dim + coarse fringe); not a real signal encode/decode.Lmp88959— a real single-pass composite encode→decode (the EMMIR/LMP model), an RGBA post-pass that composes anywhere in the stack.CompositeRt— the faithful Bisqwit per-dot composite (bisqwit.wgsl,rustynes-gfx-shaders); it samples theR16Uintpalette- index framebuffer, so it must be the first pass in the stack.
The shared WGSL lives in crates/rustynes-gfx-shaders. See
frontend.md for the full pipeline, the CRT / scanline passes,
and the preset / import machinery.
Base scanline pass — gamma + sharpness (v2.2.8 "Aperture II")¶
The base CRT/scanline pass (CRT_WGSL) reads a 16-float uniform
(rect + crop + params + aux). Two aux slots were added in v2.2.8:
aux.y— gamma round-trip. The scanline + aperture-mask darkening must happen in linear light to be perceptually correct. On the native path (an sRGB texture + sRGB surface) the sampler decodes and the surface re-encodes, so the math is already linear andaux.y = 0(the shipped native output is byte-identical to pre-v2.2.8). On a plain UNORM path (WebGL2, which does neither) the host setsaux.y = 1and the shader sRGB-decodes on read / re-encodes before output — fixing a browser-only gamma error. The decode/encode use the exact IEC 61966-2-1 piecewise sRGB transfer (srgb_to_linear/linear_to_srgbinCRT_WGSL: a linear segment below0.04045/0.0031308, a 2.4-exponent power segment above), i.e. the same curve a hardware sRGB surface applies — not apow(2.2)approximation — so the WebGL2 path matches the native sRGB path where the two curves would otherwise diverge (the shadows).aux.x— scanline sharpness (0..1, default 0.5). The profile blends from the original soft parabola (0) to a narrow Gaussian beam (1) for crisp vertical row boundaries instead of the linear-sampler blur.aux.x = 0reproduces the pre-v2.2.8 profile exactly; it is only visible when scanlines are enabled.
The advanced CRT stacks (CRT-Royale / Guest / Megatron) were already gamma-correct
via their own gamma_in/gamma_out knobs and are unchanged. Both the desktop
(crt.rs) and Android (gfx.rs) hosts feed the same aux.