GL: opt-in PS1 dithering ([video] dithering = off|on|scaled)

The OpenGL renderer has always drawn shaded and texture-modulated pixels
at 8 bits per channel with no dither pattern (true colour). That stays the
default. 'on' applies the PS1 4x4 offset matrix and 5-bit truncation per
internal-resolution pixel; 'scaled' puts the pattern on the native grid,
so it reads like the console at any scale and the native VRAM is the same
at every internal resolution.

GP0(E1h) bit 9 reaches the backend through a new optional facade hook
(gr_set_dither) recorded for the render thread and Smooth motion redraws.
With the mode off nothing flushes and no uniform changes. Flat, raw and
other 5-bit-exact pixels are never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-08 13:58:37 -07:00
parent 0b82ce5bf6
commit f14af5594d
14 changed files with 356 additions and 9 deletions
+5 -2
View File
@@ -62,6 +62,7 @@ Columns: **N** = native, **D** = DuckStation oracle.
| `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates |
| `fmv_chroma` | ✓ | | `on` (optional: 0/1) | Read or set `[video] fmv_chroma_smoothing` live. Replies `on` and `frames` (24-bit presents smoothed so far) |
| `texture_lod` | ✓ | | `mode` (optional: 0 off, 1 mipmap), `aniso` (optional: 1..16) | Read or set `[video] texture_lod` / `anisotropic_filtering` live. Replies `mode`, `aniso` |
| `dithering` | ✓ | | `mode` (optional: 0 off, 1 on, 2 scaled) | Read or set `[video] dithering` live. Replies `mode` |
| `post_aa` | ✓ | | `mode` (optional: 0 off, 1 fxaa, 2 fxaa_hq) | Read or set post-process anti-aliasing live (`[video] antialiasing_mode`). Replies `mode`, `passes` (filtered presents so far) and `gpu_us`: the mean pass time since the last query when the run has `PSX_POST_AA_TIME=N` (N passes per present between `glFinish` fences, a measurement mode), else 0 |
| `hd_textures` | ✓ | | optional `replacements`, `dump` (0 or 1), `reload` (1) | HD pack root, switches, backend support, replacement count, matched/ready/applied draw counts, queued/pending dumps, composition hits/builds/pixels, GL uploads/evictions, decoded images/evictions/bytes. Optional controls update the configured host pack on the emulation thread; no configured root is an error. Reload retains unchanged upload identities. OpenGL displays replacements; software/Vulkan retain original artwork. Pair with `present_shot` for visual evidence and native VRAM probes for architectural data |
| `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) |
@@ -491,9 +492,9 @@ between Play and TCP availability.
## Complete command index (generated)
**369 commands registered** — 356 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
**371 commands registered** — 358 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
82 of 369 have prose above; **287 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
83 of 371 have prose above; **288 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code.
@@ -579,6 +580,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `display_ring_aux` | ✓ | | |
| `display_ring_get` | ✓ | | |
| `display_ring_stats` | ✓ | | |
| `dithering` | ✓ | | ✓ |
| `dma_cdrom_history` | ✓ | | |
| `dma_hold_ring` | ✓ | | ✓ |
| `dma_hold_stats` | ✓ | | ✓ |
@@ -654,6 +656,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `history` | ✓ | ✓ | ✓ |
| `hle_dump` | ✓ | | ✓ |
| `host_launch_timings` | ✓ | | ✓ |
| `host_osd_shot` | ✓ | | |
| `host_profile` | ✓ | | |
| `idle_skip` | ✓ | | |
| `imask_trace` | ✓ | | |
+25
View File
@@ -725,6 +725,31 @@ first layer's full 8-bit result where the console would have truncated it to
5 bits first (at most one 5-bit step). OpenGL only.
`PSX_ACCURATE_BLENDING=0|1` overrides it for one run and the
`accurate_blending` TCP command switches it live.
### Dithering (`dithering`)
```toml
[video]
dithering = "off" # off (default, true colour) | on | scaled
```
The PS1 GPU adds a 4x4 offset pattern to shaded and texture-modulated pixels
before truncating them to 5 bits per channel, when the game sets GP0(E1h)
bit 9. The OpenGL renderer has always drawn those pixels at 8 bits per channel
with no pattern ("true colour"), and `off` keeps exactly that. `on` applies
the PS1 pattern and 5-bit truncation per internal-resolution pixel (a fine
grain that all but vanishes at high scales); `scaled` puts the pattern on the
native pixel grid, so it looks like the console at any scale and the native
VRAM a game reads back is the same at every internal resolution. Flat fills,
flat polygons and raw textures are already 5-bit exact and are never touched.
The pattern is applied to the source colour before semi-transparent blending;
the console dithers the blended result at its final 8-to-5-bit write. Fixed-
function GL blending cannot run a shader after the blend (GL 3.3 has no
framebuffer fetch, and macOS offers none), so a dithered semi-transparent
pixel can differ from the console by one 5-bit step; opaque pixels match.
The dither output follows the surface basis, so it composes with
`accurate_blending`.
OpenGL only. `PSX_DITHERING=0|1|2` overrides it for one run and the
`dithering` TCP command switches it live.
### Texture filtering (`texture_filtering`)