{"slug": "show-hn-a-rust-wayland-vulkan-3d-crt-screencast-application-retroarch-cores", "title": "Show HN: a Rust, Wayland, Vulkan 3D CRT, screencast application, retroarch cores", "summary": "A developer released CRTULUM, a Rust-based Wayland and Vulkan application that renders a 3D CRT monitor on screen, captures other program windows or images, and runs RetroArch cores for emulation with real-time CRT effects. The app supports offline video rendering with scriptable camera moves and tube controls, achieving roughly 500 fps at 640×480 on an RX 9070 GPU.", "body_md": "A CRT you can hold in your hands, minus the 70 pounds of leaded glass and the risk of the flyback transformer killing you in your garage.\n\nIt's a Wayland app that grabs another program's output — RetroArch, a terminal, a browser, a video player, whatever — the same way OBS does window capture, and paints it onto a 3D Trinitron you can spin around with the mouse. Not a fullscreen filter. An actual tube, sitting in your compositor, that you can orbit and zoom until the glare slides across the glass the right way.\n\nCurrent Rust toolchain (system rustup, stable). Then:\n\n```\ncargo run -- --capture\n```\n\nThat pops the screencast picker. Point it at something. It lands on the tube.\n\nNo window? Take a picture instead — handy when your compositor won't do\nwlr-screencopy and `grim`\n\ngives up:\n\n```\ncargo run -- --shot out.png 1000x800\n```\n\nThe other direction: put a game on the tube and pick up a controller.\n\n```\ncargo run --release -- --play game.sfc\ncargo run --release -- --play game.cue --core swanstation --option swanstation_GPU_Renderer=Vulkan\n```\n\nA libretro core runs in-process, one emulated frame per tick of the clock — not per monitor refresh, so a 59.727 Hz Game Boy and a 60.099 Hz SNES each run at their own speed whatever your display is doing. A gamepad is picked up automatically if one is plugged in; otherwise the keyboard stands in:\n\n| arrows | d-pad |\n| Z / X | B / A |\n| A / S | Y / X |\n| Q / W | L / R |\n| Enter · RShift | Start · Select |\n| F2 | pause the game (the television keeps running) |\n\nEvery CRT control still works while you play — orbit the tube with the mouse, swap\npresets with the number keys, cut the power with **P**. The game's buttons take\npriority, so they can't also change the television.\n\nSound comes from the core, resampled to whatever your audio device wanted.\n`CRTULUM_PLAY_STATS=1`\n\nprints the emulated rate and how much audio is buffered, which\nis what to look at if it ever feels off.\n\nThe other half of the app: hand it a source and it renders the whole thing through the tube and out the far side as a video file. Same shader, same phosphor planes, same presets — just offline, and pointed at ffmpeg instead of a window.\n\n```\n# any video file\ncargo run --release -- --render clip.mp4 out.mp4\n\n# a URL (anything yt-dlp handles)\ncargo run --release -- --render 'https://youtu.be/…' out.mkv --preset rca\n\n# a directory of stills\ncargo run --release -- --render frames/ out.mp4 --fps 30\n\n# a ROM, with the run itself scripted frame-by-frame (see below)\ncargo run --release -- --render --rom smb.nes out.mp4 --script run.crts\n```\n\nAudio comes along from the source. The tube is driven at 60 fields/sec no matter\nwhat frame rate you export at, so a 30 fps export still scans every frame twice and\n480i still twitters. Signal resolution defaults to 480 lines (`--lines 240`\n\nfor the\nreal thing — a CRT never saw a 1080p signal, and feeding it one dissolves the\nscanline structure).\n\n`--help`\n\nfor the rest: `--size`\n\n, `--fps`\n\n, `--ssaa`\n\n(3 by default, `1`\n\nfor a fast\npreview), `--start`\n\n/`--duration`\n\n, `--codec x264|x265|vp9|ffv1`\n\n, `--crf`\n\n, `--no-audio`\n\n.\nRoughly 500 fps at 640×480/ssaa 2 on an RX 9070 — most clips render faster than they\nplay.\n\nA source alone gives you a static camera. A **script** gives you choreography — a\nflat timeline of camera moves, tube swaps, power cycles and degausses:\n\n```\nsize     1280x960\nlines    240\npreset   trinitron\ncamera   yaw=0.55 pitch=0.22 dist=3.4\n\nat 0:00  power on                              # raster blooms open, auto-degauss\nat 0:03  camera to yaw=-0.35 dist=3.0 over 6   # slow drift across the face\nat 0:12  preset pvm                            # swap tubes mid-shot\nat 0:14  exposure to 1.25 over 2\nat 0:30  spin 1 over 10 linear                 # one full orbit\nat 0:52  power off                             # collapse to a line, then a dot\ncargo run --release -- --render clip.mp4 out.mp4 --script examples/demo.crts\n```\n\nTimes are seconds or clock (`0:03`\n\n, `1:02:30.5`\n\n). Moves take `over <seconds>`\n\nand\nease by default (`linear`\n\nif you'd rather). Actions: `preset`\n\n, `camera`\n\n, `spin`\n\n,\n`exposure`\n\n, `power on|off`\n\n, `degauss`\n\n, `interlace`\n\n, `subpixel`\n\n, `bfi`\n\n, `wait`\n\n. Set\n`source`\n\nin the script and it's self-contained — `--render out.mp4 --script run.crts`\n\n.\nCommand-line flags override the script's setup lines, so one script works across\ndifferent sources and sizes. Typos are errors with a line number, not silent no-ops.\n\nDownloads and emulator recordings are staged in `.crtulum/`\n\nnext to the output, and\nreused on the next run — so iterating on a script doesn't re-download or re-record.\n\nPoint the script at a ROM instead of a video and the same timeline drives the *run*\nas well as the camera. crtulum loads a libretro core in-process and calls it one\nframe at a time with the exact buttons that frame is scripted to hold — so it's\nframe-exact, headless, deterministic, and faster than real time:\n\n```\nrom      smb.nes\ncore     nestopia          # optional; guessed from the extension\nframes   3600              # how long to run (or `duration 60`)\n\npreset   trinitron\ncamera   yaw=0.3 pitch=0.2 dist=3.2\n\nat 0:00     power on\nframe 150   press start              # momentary — 4 frames unless you say otherwise\nframe 180   hold right               # …stays down…\nframe 240   press a for 20 frames    # a precisely-placed jump, mid-hold\nframe 300   release right\nframe 330   tap b                    # exactly one frame\ncargo run --release -- --render run.mp4 --script examples/tas.crts\n```\n\n`at <time>`\n\nis wall clock; `frame <n>`\n\nis exact — write the run in frames, write the\ncamera in seconds. Verbs: `press`\n\n, `hold`\n\n, `release`\n\n, `tap`\n\n, with `for <n> frames`\n\nor `for <seconds>`\n\n. Buttons are the libretro names (`a b x y l r l2 r2 l3 r3 start select up down left right`\n\n), several per line: `press a right`\n\n. Audio comes from the\ncore and is muxed in at the end. `CRTULUM_DEBUG_INPUT=1`\n\nprints the run as it plays,\none line per change, which is how you find out why a jump missed.\n\n`examples/inputtest.nes`\n\n(built by `examples/make_test_rom.py`\n\n, ~70 bytes of 6502)\npaints the screen a colour per button held, so a rendered run is a direct readout of\nits own input timeline — that's how the frame-exactness above is tested rather than\nasserted.\n\nPre-authored runs still work the other way: `--movie run.bsv`\n\nhands the whole thing\nto RetroArch (`-P … --eof-exit -r`\n\n), which owns the emulation and input and records\na clip we then pipe through the tube. Use that for existing `.bsv`\n\n/replay files;\nuse `rom`\n\n+ a script when you want to write the run here. The RetroArch pass runs in\nreal time in a window; the in-process path doesn't.\n\nCores are found in RetroArch's core directory (`--core`\n\ntakes a name or a path). One\ncaveat: a libretro core is a shared library running in our process, so a core that\nmisbehaves takes the process with it — the mesen build on this machine segfaults when\nhosted outside RetroArch, so the NES default order is nestopia, fceumm, quicknes,\nthen mesen.\n\nThree rendering paths, and the core chooses: a software framebuffer, **OpenGL** via a\nheadless EGL context, or **Vulkan** via an instance and device crtulum stands up for\nit — including the context-negotiation handshake, so the core builds the device with\nthe features its renderer needs. No window, no display server, still deterministic.\n\nVerified here |\nNES · SNES · Game Boy · Mega Drive · N64 · PlayStation on both Vulkan and OpenGL — each one boots a real game in `cargo test` (see below), plus frame-exact input against the homebrew ROMs in `examples/` |\nSame class, should just work |\nGame Boy Advance, Master System, Game Gear, PC Engine, 32X, Atari 2600, Lynx, Neo Geo Pocket, WonderSwan, ColecoVision |\nKnown not to work |\n`mupen64plus_next` runs its emulator on its own thread and makes GL calls from there, where our context isn't current. Use `parallel_n64` instead |\n\nThe real-game tests need a library, which obviously isn't in the repo — point\n`CRTULUM_ROMS`\n\nat yours (it looks for `nes/`\n\n, `snes/`\n\n, `gb/`\n\n, `megadrive/`\n\n, `n64/`\n\n,\n`psx/`\n\nsubdirectories) and `cargo test`\n\nboots one game per system, or skips if it\nisn't there. They assert nothing about any particular game: only that the core loads,\nthe picture gains structure and moves, the rate and geometry are sane, and the core\ntears down cleanly. A separate test runs the same ROM twice through a full unload and\nreload and requires identical frames — the property scripted runs depend on.\n\nA GPU core needs nothing special from you:\n\n```\ncrtulum --render out.mp4 --rom game.cue --option swanstation_GPU_Renderer=Vulkan\n```\n\nTwo things that made the difference, in case you hit them elsewhere: the frontend has\nto provide the **log and performance interfaces** (cores call straight through those\npointers and crash if they're absent), and a Vulkan core negotiating its device looks\nfor a queue family that can *present* — with no surface that search fails and the core\nrecords an out-of-range index it later trips over. `VK_EXT_headless_surface`\n\ngives it a\nreal surface with no window, and the whole class of problem goes away.\n\nCore options are passed with `--option key=value`\n\n(repeatable), or `option key=value`\n\nin a script — that's how you reach a core's renderer setting.\n`CRTULUM_TRACE_ENV=1`\n\nlists every option key a core asks for, and `CRTULUM_CORE_LOG=1`\n\nshows the core's own log, which is usually where the real answer is.\n\nAmbiguous extensions are left to you: `.bin`\n\nand `.iso`\n\nbelong to half a dozen\nmachines, so those need `--core`\n\n.\n\nTen tubes, each one measured off real hardware — actual stripe pitch, actual TVL,\nactual white point. `--preset <name>`\n\n(default `trinitron`\n\n), or keys **1–9,0** live,\n**Tab** to cycle.\n\n| Key | Name | What it is |\n|---|---|---|\n| 1 | `trinitron` |\nthe one everybody remembers — aperture grille, cylindrical |\n| 2 | `panasonic` |\nconsumer shadow mask, spherical face |\n| 3 | `slotmask` |\nslot mask, the awkward middle child |\n| 4 | `rca` |\nwarm, fuzzy console set your grandparents owned |\n| 5 | `pvm` |\nthe broadcast monitor you couldn't afford |\n| 6 | `arcade` |\ncoarse 15 kHz mask, scanlines you can count |\n| 7 | `vga` |\nfine-pitch PC monitor, flatter, colder |\n| 8 | `diamondtron` |\ndead-flat aperture grille, blindingly bright |\n| 9 | `green` |\nP1 green phosphor, long afterglow, terminal vibes |\n| 0 | `amber` |\nP3 amber, same energy, warmer |\n\nThe Trinitron even has its damper wires — those two faint horizontal shadows across the screen that drove people nuts and that nobody could explain.\n\n| Input | Does |\n|---|---|\n| left-drag | orbit the tube |\n| scroll | zoom |\n| 1–9,0 / Tab | pick / cycle preset |\n| P | power (warm-up, or collapse to a dot) |\n| G | degauss |\n| I | 480i / 240p |\n| M | subpixel mask (Megatron) / gaussian |\n| B | black-frame insertion (needs 100 Hz+) |\n`[` / `]` |\nexposure trim (for HDR panels) |\n| Esc | quit |\n\nShort version: it's not a texture with a scanline overlay. The light is simulated.\n\n**Color is real.** Each tube runs its measured phosphor gamut (SMPTE-C, P22, sRGB)\nand native white point through a CRT→sRGB matrix computed on the CPU. 9300K reads\nblue the way a cheap TV did; D65 stays neutral. The greens desaturate exactly as\nmuch as SMPTE-C says they should.\n\n**The beam scans.** Two render passes: one integrates the picture into an HDR\nphosphor plane with real per-channel decay (red lingers, blue snaps off in under a\nmillisecond — that's why fast motion trails warm), the other reconstructs the\nelectron beam from the source scanlines. Bright spots bloom and merge; saturated\ncolors stay thin with the gaps open. Leave a bright object moving and it drags a\nfading tail, because the tube is genuinely remembering the last few fields.\n\n**The glass is glass.** Snell refraction bends the view ray through the faceplate\nto the phosphor behind it, traced separately per color channel, so you get real\nchromatic fringing toward the corners. It's a mirror, too — dark screen catches a\ndaylight window and the room, and they slide across as you orbit. That last part\ncame straight off studying photos of real sets; a CRT head-on isn't black, it's a\n4% mirror of whatever's lit in front of it. Bright content gets two separate glows:\na tight warm halation off the phosphor and a wider, softer diffusion haze scattering\nthrough the thick glass — which is where CRT light gets its density.\n\n**The consumer sets cheat, on purpose.** Composite and S-video tubes run scan\nvelocity modulation — the old Sony trick of goosing the beam speed at edges to fake\nsharpness, complete with the bright overshoot halo videophiles complained about for\ntwenty years. The broadcast PVM, fed clean RGB, doesn't bother, so it stays honest\nand razor-flat. Hit **M** for subpixel mask mapping, which lands each simulated\nphosphor on a real panel subpixel for maximum density at native resolution, or **B**\nfor black-frame insertion, which strobes the tube dark between frames so motion snaps\nlike an actual CRT instead of smearing like an LCD (you'll want a 120 Hz panel).\n\n**The signal path is period-correct.** RGB and component stay clean (PVM, arcade,\nPC monitors). S-video keeps sharp luma but band-limits color. Composite gets the\nfull indignity — dot crawl, cross-color, bleed — tuned to real NTSC Y/I/Q\nbandwidths. So the Panasonic smears its reds the way composite did and the PVM\ndoesn't.\n\n**Plus the small stuff nobody asked for.** Deflection geometry errors (pincushion,\nkeystone, corner defocus that only the cheap tubes show), convergence drift toward\nthe edges, purity blotches a degauss actually clears, overscan eating the picture\nedges, a rolling hum bar from beating against 59.94 Hz, analog grain, halation, and\na power switch that collapses the raster to a bright line, then a dot, then nothing\n— and runs it backward with a degauss burst on the way up.\n\nThe cabinet's a real one too: a deep, near-cubic charcoal consumer set modeled on a Sony KV-20TS20, chin grille and knobs and all, lit by a small HDR room so the plastic and glass catch highlights instead of looking like a screensaver from 1999.\n\nIf you've got the panel for it, it'll drive true HDR — BT.2020 linear, compositor\ndoes the transfer, beam cores and speculars pushed past 1.0 so they actually glow.\nThis is the fussiest part on Linux and it took a vendored wgpu-hal patch to get the\ncolorspace mapping right. Use `[`\n\n/ `]`\n\nto trim exposure to taste.\n\n`src/main.rs`\n\n— window, wgpu, tube + cabinet mesh, orbit camera, the two-pass render loop, all ten presets.`src/capture.rs`\n\n— the screencast portal handshake and PipeWire loop that feeds live frames onto the tube.`src/video.rs`\n\n— the`--render`\n\nexport: the script DSL and its timeline, source acquisition (yt-dlp, RetroArch), the ffmpeg pipes, and the GPU SSAA resolve.`src/shader.wgsl`\n\n— the optics. Beam reconstruction, phosphor decay, refraction, masks, glass, PBR cabinet, the room it reflects. Tube curvature lives in`screen_z()`\n\nback in`main.rs`\n\n.`src/libretro.rs`\n\n— the in-process libretro host: loads a core, runs it a frame at a time with a scripted button mask, hands back RGBA frames and PCM.`src/glctx.rs`\n\n— the headless EGL/OpenGL context that hardware-rendering cores draw into, plus the readback.`src/play.rs`\n\n— live play: clock-paced emulation, gamepad and keyboard input, and the audio output.`src/vkctx.rs`\n\n— the Vulkan equivalent: instance, device, the negotiation handshake, the`retro_hw_render_interface_vulkan`\n\ncallbacks, and the image copy back to RGBA.`examples/demo.crts`\n\n— a commented script showing every action.`examples/tas.crts`\n\n+`examples/make_test_rom.py`\n\n/`make_genesis_test_rom.py`\n\n— a scripted run, and the homebrew NES and Mega Drive ROMs it's verified against.", "url": "https://wpnews.pro/news/show-hn-a-rust-wayland-vulkan-3d-crt-screencast-application-retroarch-cores", "canonical_source": "https://github.com/andrewfader/crtulum", "published_at": "2026-07-27 03:26:44+00:00", "updated_at": "2026-07-27 03:52:12.623165+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["CRTULUM", "RetroArch", "Rust", "Wayland", "Vulkan", "RX 9070"], "alternates": {"html": "https://wpnews.pro/news/show-hn-a-rust-wayland-vulkan-3d-crt-screencast-application-retroarch-cores", "markdown": "https://wpnews.pro/news/show-hn-a-rust-wayland-vulkan-3d-crt-screencast-application-retroarch-cores.md", "text": "https://wpnews.pro/news/show-hn-a-rust-wayland-vulkan-3d-crt-screencast-application-retroarch-cores.txt", "jsonld": "https://wpnews.pro/news/show-hn-a-rust-wayland-vulkan-3d-crt-screencast-application-retroarch-cores.jsonld"}}