< Back to WIP

Posted 2026-10-05

Post-Processing Shaders: An Arcade Monitor on Top of the Glide Frame (WIP)

Markdown source

Range: v0.0.197 to v0.0.200 (Tasks 768–769)

The original PIU board sent its 640×480 picture to an arcade CRT monitor. rePIU draws that picture at 2x in a window on a modern monitor, and the more exact it is, the further it gets from "the screen of the day". This post records adding post-processing shaders that put an arcade-monitor look over the finished frame at presentation only; the framebuffer the guest sees does not change by a byte.

Major Changes

1. What it looks like

pumpit8 (The Rebirth) was driven to the same scenes with an input script and captured under none, crt and scanline in turn, all on the Win32 x86 Release build, an RTX 4090 and the 2x window (1280×960) at 60 FPS. The scanlines are a per-output-pixel pattern that vanishes when an image is scaled down, so the images stay full size; each strip below puts the same region side by side at 1:1 (none, crt, scanline from the left).

Title

none · crt · scanline, 1:1 crop of the title

crt scanline
crt title scanline title

Mode select

none · crt · scanline, 1:1 crop of mode select

crt scanline
crt mode select scanline mode select

Play

none · crt · scanline, 1:1 crop of play

crt scanline
crt play scanline play

The none originals for comparison are in docs/screenshots/shaders/ under the same names (*-none.jpg).

  • crt — a curved screen (curvature 0.01 by default), Gaussian beam scanlines that widen with brightness, an aperture-grille mask and a light vignette, computed in linear light. Six parameters.
  • scanline — plain scanlines darkening the back half of each line. Two parameters.

2. Where it goes — one place, just before the swap

Every presentation of the game passes SDL_GL_SwapWindow in BufferSwapOnHostThread. The pass runs once just before it, after the diagnostic sampling and before the OSD.

sequenceDiagram
    participant G as Guest (grBufferSwap)
    participant B as BufferSwapOnHostThread
    participant P as GlidePostProcess
    participant O as GlideOsd
    participant S as SDL_GL_SwapWindow
    G->>B: swap
    B->>B: diagnostic sampling (back buffer, before the pass)
    alt a shader is selected
        B->>P: Apply(content rect, logical 640x480)
        P->>P: glCopyTexSubImage2D(GL_BACK → scene texture)
        P->>P: full-screen quad, post program, RGB only
        P->>P: glPushAttrib/glPopAttrib + program restore
    end
    B->>O: Render (with the shader menu)
    B->>S: present
  • The copy stays on the GPU. The back buffer is copied with glCopyTexSubImage2D into an RGBA8 texture of the drawable's size; there is no CPU readback, so the cost per frame is one copy and one quad. none is one branch and zero GL calls.
  • Glide state outlives the swap. The pass is wrapped in glPushAttrib(GL_ALL_ATTRIB_BITS) with the current program saved, and the GL probe checks that blend, viewport and program come back as they were.
  • Alpha is left alone. Only RGB is unmasked, so window alpha never leaks to the compositor as a translucent window.
  • Failure means none. A shader that fails to compile or link counts as not selected, with the reason in the log and the OSD; no failure turns the screen black.

3. The scanlines that vanished in the 2x window

The first two shaders used brightness profiles symmetric about a line's middle (a sine for scanline, a Gaussian beam at the line's centre for crt). In the default 2x window one logical line is two output rows, whose centres sit at 0.25 and 0.75 within the line; a symmetric profile gives both the same brightness, so no scanline showed at the default scale. This came out of working the GL probe's expected values by hand.

logical row y      :  |------- one line -------|
output rows (2x)   :  [    row 0    ][    row 1    ]
row centre in line :        0.25           0.75
symmetric f(t)     :  f(0.25) == f(0.75)  → both rows equally bright
  • scanline: the back half of each line is the gap, with a soft edge one output pixel wide; it is off at 1x.
  • crt: the beam centre moves by half an output pixel (0.5 / OutputSize) to land on a pixel centre at integer scales.

After the fix the probe draws a 64×48 logical screen into 128×96 and finds 48 bright and 48 dark rows (scanline_rows=48/48/96).

4. 4:3 whatever the window's shape (Task 769)

With the pass in place, the old problem of a stretched window stretching the picture stood out more. The viewport is now a content rect keeping 4:3 inside the drawable, and the rest is cleared to black bars at every present. The pass copies and draws inside that rect only, so the shader never touches the bars. A double click or Alt+Enter toggles a borderless fullscreen that leaves the display mode alone.

flowchart LR
    D["drawable<br/>(window or fullscreen)"] --> R["ComputeGlideLetterboxRect<br/>integer cross-multiplication"]
    R --> V["glViewport · glScissor<br/>· point size"]
    R --> C["ClearLetterboxBars<br/>(returns at once with no bars)"]
    R --> P["GlidePostProcess::Apply<br/>(inside the rect only)"]
    R --> RB["ReadbackFramebuffer<br/>(the rect only → the guest never sees the bars)"]

5. Three places to choose

Where Lasts Example
The launcher's Options "Screen shader" [Video] post_shader in cfg\repiu.ini post_shader = crt
Environment variable That run (over the launcher's value) REPIU_POST_SHADER=scanline
The in-game Tab OSD That run only combo, Reload, parameter sliders

A .glsl file in the libretro single-pass layout dropped into shaders\ joins the list; edit it with the game running and press Reload in the OSD to recompile at once. The format is in the post-processing shader guide.

6. Execution log

The key lines of pumpit8 run with REPIU_POST_SHADER=crt and an input script. The environment variable won, so the launcher's value was not published (post-shader: false), and the shader applied with six parameters.

[loader] Launcher settings read from cfg\repiu.ini for an argument run (environment wins: swap-interval/volume file/file)
[loader] Launcher settings published swap-interval/volume/post-shader: true/false/false
[repiu-glide] GL renderer: NVIDIA GeForce RTX 4090/PCIe/SSE2
[repiu-post] shader: crt (6 parameters)

There is no blocker; what remains is under "Known" below.

7. Sample test results

Check Win32 x86 Linux x64 (WSL)
--post-shader (assembly, parameters, built-ins, catalog, variable) 5/5 5/5
--glide-letterbox (same ratio, pillarbox, letterbox, odd, degenerate) 5/5 5/5
--launcher (with the shader key round trip and publish precedence) 5/5 5/5
GL --opengl-post-shader (scanlines, crt, none, state restore, letterbox, fullscreen) 6/6 5/6 (letterbox: a problem in the check)
Real game pumpit1 15 s × 3 shaders, pumpit8 80 s × 3 shaders pumpit1 30 s, 4 fullscreen round trips
xychart-beta
    title "GL post-shader probe checks passed (of 6)"
    x-axis ["Win32 Intel Arc", "WSL x11 d3d12", "WSL Wayland d3d12", "WSL x11 llvmpipe", "WSL Wayland llvmpipe"]
    y-axis "checks" 0 --> 6
    bar [6, 5, 5, 5, 5]

The one missing on Linux is the letterbox readback: Mesa (GLX and EGL) resizes the back buffer only at the next swap after a window resize, and the probe read without one. With one swap added, the letterbox check passes in all four and both x11 configurations pass 6/6. The game swaps every frame, so this is no engine defect; in that state, though, the two Wayland configurations had runs where leaving fullscreen did not finish in time ("Known" below).

Known

  • Leaving fullscreen on WSLg Wayland is slow and erratic (13 to 368 swaps); under x11 and on Windows it leaves at once.
  • Multi-pass presets (.glslp), previous frames (PrevTexture) and LUTs are not supported yet, and parameters changed in the OSD are not saved.

Technology Stack Used

The libretro GLSL shader format

A libretro GLSL shader keeps the vertex and fragment stages in one file, split by #if defined(VERTEX) / #elif defined(FRAGMENT). The engine compiles the same source twice, adding #define VERTEX or #define FRAGMENT and #define PARAMETER_UNIFORM; when the first line is #version, they must go right after it or the source does not compile.

#pragma parameter NAME "label" default min max step lines mean nothing to the GLSL compiler, but the frontend reads them into uniform floats and sliders; rePIU makes its OSD sliders the same way.

TextureSize and InputSize report the game's logical resolution (640×480), not the texture's real size: the game draws at a high resolution scaled to the window, but CRT scanlines must follow the original output's 480 lines. Samples come from the high-resolution scene texture with linear filtering. Both built-in shaders are new BSD 3-Clause code written for this project; GPL shaders such as libretro's crt-geom were not used.

The OpenGL compatibility context and preserving state

The Glide HLE sits on a compatibility context that mixes the fixed-function pipeline (glBegin, glOrtho) with shaders. The pass therefore sends its quad in immediate mode instead of through a core-profile VAO (glVertexAttrib4f, attribute 0 emitting the vertex) and binds VertexCoord=0, TexCoord=1, COLOR=2 before linking. State is wrapped in glPushAttrib, with the current program, which the attribute stack does not hold, saved separately.

Copying the back buffer with glCopyTexSubImage2D

glCopyTexSubImage2D copies a rectangle of the current read buffer into a texture on the GPU. Compared with redirecting rendering to an FBO, it leaves the existing Glide path untouched; the price is one copy per frame, and none at all under none.

SDL3 fullscreen and window size events

SDL3's SDL_SetWindowFullscreen on a window with no fullscreen mode set makes it a borderless window that leaves the display mode alone. The result arrives as SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, on which the engine recomputes the content rect and viewport. Under Wayland the switch is asynchronous, waiting for the compositor, and may not be done when SDL_SyncWindow returns.

Sample positions at integer scales

Output pixel centres are at (i + 0.5) / OutputSize. At 2x the two output rows of one logical line fall at 0.25 and 0.75 within it, so any function symmetric about the line's middle gives both the same value. A per-line pattern in an integer-scale shader needs an asymmetric profile or a sample position moved by half an output pixel. The general lesson is in section 5 of kb: the libretro GLSL post-processing shader format.