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 # Unified Audio class (impl chosen per platform)
│ ├── resampler.h # Standalone resampling utility
│ ├── input.h
│ ├── math3d.h
│ └── span.h
├── src/ # Implementation
│ ├── window.cpp
│ ├── renderer_metal.mm # Metal renderer (macOS)
│ ├── renderer_stub.cpp # Stub renderer (other platforms)
│ ├── audio_macos.mm # Audio impl: pull-based CoreAudio (macOS)
│ ├── audio_sdl.cpp # Audio impl: SDL3 push-based (other platforms)
│ ├── audio_platform.h # OS-level hw-latency shim declaration
│ ├── audio_coreaudio.mm # OS-level hw-latency probe (macOS)
│ ├── audio_stub.cpp # OS-level hw-latency stub (other platforms)
│ ├── spsc_ring.h # Lock-free SPSC ring (used by audio_macos.mm)
│ ├── resampler.cpp
│ ├── input.cpp
│ └── math3d.cpp
├── examples/
│ ├── basic/ # Colour-cycling 2D framebuffer + audio
│ ├── 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
The Audio class owns the resampler and DRC internally — the embedder
just pushes raw input-rate samples. The same source compiles to a
pull-based CoreAudio implementation on macOS and a push-based SDL3
implementation everywhere else.
#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);
// Window + Renderer first — Renderer sets up CAMetalDisplayLink
// using the FPS hint we gave Window.
Window ctx;
ctx.init("My Emulator", 512, 480, 60.0);
Renderer video;
video.init(ctx, 256, 240); // NES framebuffer size
// Audio should reflect the actual delivered loop rate, not what we
// asked for — CAMetalDisplayLink rounds to standard rates.
const double actual_fps = video.display_link_active()
? video.display_link_rate()
: 60.0;
Audio audio;
audio.init(/*in_rate=*/1789773.0, /*out_rate=*/44100, /*channels=*/1,
actual_fps);
audio.prime(); // small startup cushion of silence
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.
// Block on the renderer's pacing source (CAMetalDisplayLink
// on macOS; no-op otherwise).
video.wait_for_present();
// Generate and present framebuffer
uint32_t framebuffer[256 * 240];
// ... fill framebuffer with emulated output
video.present_framebuffer(framebuffer, 256, 240);
// Generate APU-rate audio and push raw — resampling + DRC
// happen inside Audio. No `Resampler` object, no `apply_drc`,
// no `wait_for_frame`.
float apu_samples[29830]; // ~1 video frame of NES APU audio
// ... fill apu_samples with emulated audio
audio.push(apu_samples, 29830);
}
input.shutdown();
audio.shutdown();
video.shutdown();
ctx.shutdown();
SDL_Quit();
return 0;
}Pure-windowing wrapper around SDL3 — owns the SDL_Window, does
not own the GPU device (that's the Renderer's job). Stores the
emulator FPS hint used by the display-rate queries below.
Methods:
init(title, width, height, emulated_fps=0)— Create the window. Pass non-zeroemulated_fpsso the renderer (initialised against this Window) can set up its pacing source at the right rate.set_emulated_fps(fps)— Update the FPS hint after init.emulated_fps()— Get the current hint.window()— Get theSDL_Window*handle.refresh_rate()— Host display refresh in Hz (0 if SDL can't determine it).vsync_paces_emulator()— True iff display refresh is within ~2% ofemulated_fps. Useful for deciding whether the loop has display-based pacing (callvideo.wait_for_present) or needs another source —Audio::wait_for_drain()is available as an audio-paced fallback.present_refresh_multiple()— Returns N if host refresh ≈ N × FPS for some positive integer N (within 2%), else 0. Used byneeds_frame_dup.needs_frame_dup()— True when an application-level frame-duplication loop is needed (present_refresh_multiple() >= 2). The basic example gates this with!video.display_link_active()so frame-dup is skipped when the renderer is subdividing at the OS level.shutdown()— Destroy the window. Must be called after any Renderer using this Window has been shut down.
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.
One class, two platform-specific impls behind a PIMPL. From the embedder's perspective the API is identical regardless of which one is compiled in — push raw input-rate samples, the class handles resampling, DRC, mute, fade, and latency reporting internally.
- macOS impl (
src/audio_macos.mm): pull-based. Owns a raw CoreAudio AudioUnit.push()writes raw samples into a lock-free SPSC ring; the render callback drains it through soxr, ticks DRC, and writes output directly to the device. Resampling and DRC are inside the real-time callback. Steady-state latency ~20 ms. - Other-platforms impl (
src/audio_sdl.cpp): push-based via SDL3.push()runs the resampler synchronously on the caller's thread, feeds resampled output to an SDL audio stream, and ticks DRC based on the stream's queue depth. Steady-state latency ~35 ms.
Both impls support mono (channels=1) and interleaved stereo
(channels=2).
Methods:
init(in_rate, out_rate, channels=1|2, emulated_fps, device_buffer_frames=128)— Open the device, create the internal resampler, warm it at the worst-case DRC ratio, start consuming. Mono and stereo only.push(samples, count)— Push raw input-rate samples (interleaved LRLR... for stereo). Non-blocking.countis total samples (= frames × channels). The class handles resampling and DRC.push_silence(frames)— Pushframesworth of input-rate zeros, with a fade-out ramp from the last real sample so the boundary is click-free.prime()/prime(target_in_frames)— Fill the internal buffer with a startup cushion. No-arg version uses half a video frame of input; DRC pulls up to the steady-state target over a couple of seconds.wait_for_drain()— Optional. Block until the internal buffer drains to target. Use only when the loop has no other pacing source — e.g. headless or with VSYNC off. Otherwise skip it.set_muted(bool)/is_muted()— Mute substitution with crossfade.set_muted(false)auto-primes a recovery cushion.set_unmute_recovery_frames(int)— Override the unmute cushion depth (default 3 × one-video-frame-of-input).set_volume(float)/volume()— Output gain. Safe beforeinit()(cached and applied when the device opens).set_emulated_fps(double)/emulated_fps()— Update the FPS hint after init; recomputes the internal target depth.latency_ms()/queue_ms()/device_buffer_frames()/hw_latency_frames()/drc_correction()— Diagnostic accessors.latency_ms()sums queue + device buffer + hw latency.set_debug_log(bool)— Per-secondSDL_Logdiagnostic line.shutdown()— Stop and dispose.
There's no Resampler parameter, no apply_drc tick, no
wait_for_frame(bool) mode flag — those concepts are absorbed inside
the class.
Standalone libsoxr wrapper. Not used by Audio anymore (both
impls drive soxr directly); kept in the public API as a utility for
embedders who want resampling for other purposes.
Methods:
init(in_rate, out_rate, channels)— Initialize.process(in, in_count, out, out_capacity)— Resample.set_out_rate(new_rate)— Update output rate (e.g. for app-side DRC). Internally passes aslew_lenof ~30 ms of output samples so each change is interpolated smoothly. RequiresSOXR_VR(handled byinit()).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. - PIMPL for platform-specific impls:
Audioexposes a single C++ class; the implementation behind it differs by platform (src/audio_macos.mmvs.src/audio_sdl.cpp). The header doesn't leak CoreAudio or SDL types. - Exception safety: Initialize/shutdown pattern with cleanup on error.
- GPU resource management: SDL3 GPU API handles memory; you call
destroy_texture()for manual releases.
- Internal DRC:
Audiokeeps its internal buffer near a target depth by nudging the resampler's io_ratio. Tiny in steady state (~0.25% nudges, inaudible); recovers from transients within a few seconds at a ±2% ceiling. No producer-sideapply_drccall needed. - Adaptive target depth: baseline target is 1 × video-frame of audio; on an underrun the target auto-elevates to 1.5 × for 10 s (each new underrun refreshes the deadline) so DRC has more headroom while conditions settle. Decays back to baseline automatically — no caller intervention.
- Pull architecture on macOS: resampling and DRC run inside the CoreAudio render callback (
src/audio_macos.mm), giving exact ground-truth on consumption rate and absorbing producer/consumer drift without a separate per-frame tick. Steady-state input-to-sound latency ~20 ms on Apple Silicon — measure on your own system viaAudio::latency_ms(). - Push architecture elsewhere: same API; resampling and DRC happen synchronously in
push()on the producer thread, feeding an SDL3 audio stream (src/audio_sdl.cpp). Steady-state ~35 ms. - 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