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

crt |
scanline |
|---|---|
![]() |
![]() |
Mode select

crt |
scanline |
|---|---|
![]() |
![]() |
Play

crt |
scanline |
|---|---|
![]() |
![]() |
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
glCopyTexSubImage2Dinto an RGBA8 texture of the drawable's size; there is no CPU readback, so the cost per frame is one copy and one quad.noneis 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.





