A C++ library for building emulator frontends on macOS. Provides GPU-accelerated graphics (2D and 3D), audio playback with frame-based synchronization, and gamepad input handling.
| Platform | Status |
|---|---|
| macOS (Metal) | Supported |
| Linux (Vulkan) | Planned — SPIR-V shaders not yet embedded |
| Windows (DirectX/Vulkan) | Planned — SPIR-V shaders not yet embedded |
The Renderer class currently embeds MSL shaders only. Audio and Input use SDL3 abstractions and are portable, but a full cross-platform build requires pre-compiled SPIR-V bytecode to be added for Vulkan. Contributions welcome.
-
GPU-Accelerated Rendering
- Renderer: Unified 2D + 3D renderer —
present_framebuffer()for pixel-based systems (NES, SNES, Genesis, etc.) plus full 3D pipelines for textured/untextured geometry - Renderer: Advanced 3D renderer for transformed geometry and textured draws (PS1, N64 style) — macOS only for now (see platform support above)
- Built on SDL3 GPU API
- Renderer: Unified 2D + 3D renderer —
-
High-Quality Audio
- Frame-based audio synchronization for emulation accuracy
- Real-time audio resampling (libsoxr)
- Dynamic Rate Correction (DRC) to handle clock drift
-
Input Handling
- Gamepad/joystick support via SDL3
- Keyboard fallback (arrow keys + ZXRS)
- C++17 compiler (GCC, Clang, MSVC)
- SDL3 — graphics, audio, input
- libsoxr — audio resampling
- CMake 3.16+ — recommended build system for integration
- pkg-config — used by the Makefile build
# Install dependencies via Homebrew
brew install sdl3 libsoxr pkg-config xcode-select
# (If needed) Install Xcode Command Line Tools
xcode-select --install
# Build library and example
makesudo apt update
sudo apt install build-essential pkg-config libsdl3-dev libsoxr-dev
make# Via MSYS2/MinGW-w64
pacman -S mingw-w64-x86_64-sdl3 mingw-w64-x86_64-libsoxr
makeNote: On Linux and Windows,
Audio,Input, andResamplerall work.Rendererwill throw at runtime on non-Metal backends until SPIR-V shader support is added (see platform support).
# Build everything (static lib, dynamic lib, example)
make
# Build just the static library
make static
# Build the example binary
make example
# Run the example
./build/example
# Clean build artifacts
make cleanInstall the library and headers system-wide (defaults to /usr/local):
make install # installs to /usr/local
make install PREFIX=~/.local # installs to a custom prefixThis copies libraries to $(PREFIX)/lib/ and headers to $(PREFIX)/include/phosphene/.
Override variables on the command line:
# Use a different compiler
make CXX=clang++
# Build with debug symbols
make CXXFLAGS="-std=c++17 -Wall -Wextra -g"
# See all detected settings
make infoPhosphene uses CMake and exposes the Phosphene::phosphene target. There are two integration paths depending on whether you want to vendor the source or install it system-wide.
No install step required — CMake builds Phosphene as part of your project.
git submodule add https://github.com/ooPo/Phosphene vendor/phospheneIn your CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(MyEmulator)
add_subdirectory(vendor/phosphene)
add_executable(my_emu main.cpp)
target_link_libraries(my_emu PRIVATE Phosphene::phosphene)SDL3 and libsoxr must be installed on the build machine (see Installation).
Build and install Phosphene to a prefix, then use find_package from any project.
cmake -S path/to/phosphene -B phosphene_build
cmake --install phosphene_build --prefix /usr/local # or any prefixIn your CMakeLists.txt:
find_package(Phosphene REQUIRED)
add_executable(my_emu main.cpp)
target_link_libraries(my_emu PRIVATE Phosphene::phosphene)If you installed to a custom prefix, point CMake to it:
cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/prefixThe Phosphene::phosphene target propagates everything automatically — include paths, link libraries, and the C++17 requirement. No manual include_directories or target_compile_options calls are needed.
Your source files include headers as:
#include <phosphene/window.h>
#include <phosphene/renderer.h>
#include <phosphene/audio.h>
#include <phosphene/resampler.h>
#include <phosphene/input.h>.
├── include/
│ └── phosphene/ # Public headers
│ ├── window.h
│ ├── renderer.h
│ ├── audio.h
│ ├── resampler.h
│ ├── input.h
│ ├── math3d.h
│ └── span.h
├── src/ # Implementation
│ ├── window.cpp
│ ├── renderer.cpp
│ ├── audio.cpp
│ ├── resampler.cpp
│ ├── input.cpp
│ └── math3d.cpp
├── examples/
│ ├── basic/ # Colour-cycling 2D framebuffer + audio + DRC
│ ├── cube/ # Untextured spinning cube
│ ├── textured_cube/ # Textured spinning cube
│ └── hud/ # 3D scene with alpha-blended HUD overlay
├── build/ # Build output (generated)
├── Makefile
└── README.md # This file
#include <phosphene/window.h>
#include <phosphene/renderer.h>
#include <phosphene/audio.h>
#include <phosphene/input.h>
int main() {
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMEPAD);
// Initialize context
Window ctx;
ctx.init("My Emulator", 512, 480, 60.0); // 4th arg picks a paced present mode
// Initialize components
Renderer video;
video.init(ctx, 256, 240); // NES framebuffer size
Audio audio;
audio.init(44100, 1, 60.0); // 44.1 kHz mono @ 60 FPS
audio.prime(); // silence-pad the queue to avoid startup chop
Input input;
input.init();
// Main loop
bool running = true;
while (running) {
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT)
running = false;
input.handle_event(event);
}
// Get input
InputState state = input.read();
// ... use state.up, state.down, state.a, state.b, etc.
// Frame pacing (self-skips when the display is pacing the loop)
audio.wait_for_frame(ctx);
// Generate and present framebuffer
uint32_t framebuffer[256 * 240];
// ... fill framebuffer with emulated output
video.present_framebuffer(framebuffer, 256, 240);
// Generate and push audio samples
float samples[1024];
// ... fill with emulated audio
audio.push(samples, 1024);
}
input.shutdown();
audio.shutdown();
video.shutdown();
ctx.shutdown();
SDL_Quit();
return 0;
}Manages SDL3 GPU device and window lifecycle. Initialize once at startup.
Methods:
init(title, width, height, emulated_fps=0, pref=Auto)— Create window and GPU device. The optionalemulated_fpslets Window pick a present mode in one shot (seeset_emulated_fpsfor the selection logic). The optionalprefoverrides the refresh-aware default — seeset_present_mode_preference.set_emulated_fps(fps)— Tell Window the emulator's target frame rate so it can pick a present mode that actually paces the loop. With the defaultAutopreference: if host refresh is within ~2% offps, VSYNC is chosen (display paces the emulator, DRC absorbs the small mismatch); otherwise (e.g. 60 FPS emulator on a 144 Hz display) MAILBOX is preferred — falling back to IMMEDIATE — and VSYNC is avoided, because VSYNC at the wrong cadence fights audio-queue pacing. Prefer the 4th arg ofinit()when FPS is known at startup; use this method for runtime changes (e.g. PAL ↔ NTSC).set_present_mode_preference(pref)— Override the present-mode policy:PresentModePreference::Auto— refresh-aware (the default).PresentModePreference::PreferVsync— VSYNC → MAILBOX → IMMEDIATE regardless of refresh. Use when you prefer judder over tearing on a high-refresh display, or on macOS where the WindowServer compositor may apply its own vsync and IMMEDIATE doesn't behave as "no sync."PresentModePreference::PreferImmediate— IMMEDIATE → MAILBOX → VSYNC. Mainly diagnostic.
refresh_rate()— Host display refresh in Hz (0 if SDL can't determine it)present_mode()— TheSDL_GPUPresentModeactually selected after resolving preferences against what SDL3 reports as supported.present_refresh_multiple()— Returns N if host refresh ≈ N × FPS for some positive integer N (within 2%), else 0. Used to detect cases where the frontend can run a frame-duplication loop: emulate every Nth iteration, present fresh content viaRenderer::submit_framebufferon those andRenderer::repeat_last_framebufferon the others. Only meaningful withPreferVsync(orAutoon a display where refresh matches FPS at all); N=1 collapses to thevsync_paces_emulator()case.is_vsync()— True iff the chosen present mode is VSYNC. Factual query about the present mode — for the "should I skip audio pacing" decision, usevsync_paces_emulator()instead.vsync_paces_emulator()— True iff VSYNC is active AND the display refresh matches the FPS hint. Used byAudio::wait_for_frame(Window&)to decide whether to skip the audio drain. Returns false when VSYNC is forced viaPreferVsyncbut refresh doesn't match FPS (audio still paces the loop in that case).gpu()— Get GPU device handlewindow()— Get window handleshutdown()— Clean up resources
Unified 2D + 3D renderer. Use present_framebuffer() for pixel-based
emulators, begin_frame()/submit_framebuffer()/submit_draw()/
submit_overlay()/end_frame() to compose 2D framebuffers with 3D
geometry or HUD overlays in the same frame.
Methods:
init(ctx, render_width, render_height)— Set internal render resolutionsubmit_framebuffer(pixels, w, h, format=RGBA8, pixel_aspect_ratio=1.0)— Upload a framebuffer and queue it as a centered, aspect-preserving quad into the current frame. Must be called betweenbegin_frame()andend_frame(). Vertical scale is always integer (crisp scanlines); horizontal stretch isv_scale × pixel_aspect_ratio(e.g. NES ≈ 8/7, Genesis ≈ 32/35). At PAR = 1.0 the plain nearest pipeline is used; at non-square PARs a sharp-bilinear pipeline is auto-enabled — pixel bodies stay nearest-sampled and only the column boundaries blend over a single screen pixel. Letterbox bars are filled by the render pass's clear colour.present_framebuffer(...)— Convenience wrapper:begin_frame()+submit_framebuffer(...)+end_frame(), for frames that are nothing but the framebuffer.repeat_last_framebuffer()— Re-submit the cached quad from the most recentsubmit_framebuffer()call without re-uploading. Use this for frame duplication when the loop runs at an integer multiple of the emulator FPS (seeWindow::present_refresh_multiple): emulate once every N iterations, callsubmit_framebuffer()on those iterations andrepeat_last_framebuffer()on the others — vsync sees a clean N:1 cadence. Must be called betweenbegin_frame()andend_frame(); throws if no priorsubmit_framebuffer()has been made.submit_overlay(tex, dst_x, dst_y, dst_w, dst_h)— Submit an alpha-blended textured quad in window pixel coordinates. Convenience for HUDs and overlays; must be called betweenbegin_frame()andend_frame().begin_frame()— Start a new render passsubmit_draw(cmd)— Queue a draw callend_frame()— Flush and presentcreate_texture(width, height, format=RGBA8)— Allocate GPU texturedestroy_texture(tex)— Release textureupload_texture(tex, pixels, pitch)— Upload pixel data to a texture (bytes-per-pixel derived from the texture's format)
Pixel formats (PixelFormat): RGBA8 (4 bpp, default), BGRA8 (4 bpp),
RGB565 (2 bpp). Accepted by create_texture, submit_framebuffer, and
present_framebuffer.
Frame-synchronized audio playback with resampling support.
Methods:
init(sample_rate, channels, emulated_fps)— Initialize devicepush(samples, count)— Enqueue audio sampleswait_for_frame()— Block until audio buffer ready for next frame (unconditional; use theWindow&overload for the idiomatic per-frame call)wait_for_frame(window)— Mode-aware per-frame audio housekeeping. In pacing mode (IMMEDIATE / MAILBOX / mismatched VSYNC) blocks the producer until queue drains to target, like the no-arg overload. In queue-management mode (matched-refresh VSYNC, or VSYNC at an integer-multiple refresh under a frame-dup loop) never blocks but silence-pads the queue if depth dips below the low watermark — keeps drift from underrunning the device. Call unconditionally every loop iteration; the right behaviour is selected from the Window state.push_silence(frames)— Pushframesworth of zeros into the queue. Primitive used byprime()and the watermark guard inwait_for_frame(Window&); exposed so callers can roll their own watermark policy if the built-in one doesn't fit.prime()/prime(target_samples)— Fill the queue with silence up to the standard target depth (target_queue_depth()) or an explicit one. Call once afterinit()before the first realpush().target_queue_depth()— The shared queue target used byprime(),wait_for_frame(), and DRC. Currently 4 × samples_per_frame (~67 ms at 60 FPS). Deep enough that the watermark guard inwait_for_frame(Window&)only fires under pathological drift, not steady-state.compute_drc_rate(resampler)— Compute DRC rate adjustment (includes a 1%-of-frame deadband; corrects towardtarget_queue_depth())apply_drc(resampler)— Compute DRC rate and push it into the resampler (one-call equivalent ofresampler.set_out_rate(compute_drc_rate(resampler)))shutdown()— Clean up device
High-quality sample rate conversion (libsoxr wrapper).
Methods:
init(in_rate, out_rate, channels)— Initialize resamplerprocess(in, in_count, out, out_capacity)— Resample audioset_out_rate(new_rate)— Update output rate (for DRC). Internally passes aslew_lenof ~5 ms of output samples to libsoxr so each change is interpolated rather than stepped — avoids audible discontinuities under frequent per-frame nudges.shutdown()— Clean up
Gamepad and keyboard input handling.
Methods:
init()— Initialize input systemhandle_event(event)— Process SDL eventsread()— Get current button stateshutdown()— Clean up
Input Mappings:
- Gamepad: D-pad, A/B buttons, Select, Start
- Keyboard: Arrow keys, Z (A), X (B), Right Shift (Select), Enter (Start)
- Non-owning pointers:
Rendererholds a non-owning reference toWindow - Forward declarations: Headers minimize coupling (e.g.,
Resamplerforward-declared inAudio) - Exception safety: Initialize/shutdown pattern with cleanup on error
- GPU resource management: SDL3 GPU API handles memory; you call
destroy_texture()for manual releases
- Frame pacing:
audio.wait_for_frame(window)blocks until the audio buffer drains to a target level, providing natural frame pacing — and self-skips when the display is already pacing the loop (VSYNC at a matching refresh rate) - DRC:
audio.apply_drc(resampler)adjusts the resampler output rate to keep audio synchronized with video - Nearest-neighbour filtering:
Rendereruses a nearest-neighbour sampler so 2D framebuffer presentation preserves authentic retro appearance - Depth testing:
Rendererincludes Z-buffering for correct 3D rendering
Four examples are included, each targeting a different feature set:
| Example | What it shows |
|---|---|
basic |
2D colour-cycling framebuffer, sine audio, DRC, window scaling |
cube |
Untextured spinning cube with the 3D renderer |
textured_cube |
Textured spinning cube with checkerboard texture upload |
hud |
3D scene with an alpha-blended HUD overlay composited on top |
Build and run with the Makefile:
make example # builds and links examples/basic
./build/example
make # builds all targets including cube, textured_cube, hud
./build/cube
./build/textured_cube
./build/hudOr via CMake with -DPHOSPHENE_BUILD_EXAMPLES=ON.
When adding new features:
- Update relevant headers with Doxygen-style documentation
- Maintain consistent code style (see existing files)
- Test on supported platforms
- Update README if API changes
BSD 2-Clause. See LICENSE.txt.
Compilation errors for SDL3?
- Verify SDL3 is installed:
pkg-config --cflags sdl3 - Try
make infoto see detected paths - On macOS:
brew install sdl3 - On Linux:
sudo apt install libsdl3-dev
Audio not playing?
- Check system volume and audio device
- Verify
soxr_create()succeeds: run the example and look for errors - Ensure output sample rate matches hardware
Example window not appearing?
- On Linux/Wayland, SDL3 may require additional environment variables
- Try
SDL_VIDEODRIVER=x11 ./build/exampleon Linux