Skip to content

ooPo/Phosphene

Repository files navigation

Phosphene

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 support

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.

Features

  • 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
  • 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)

Requirements

  • 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

Installation

macOS

# 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
make

Linux (Debian 13+)

sudo apt update
sudo apt install build-essential pkg-config libsdl3-dev libsoxr-dev

make

Windows

# Via MSYS2/MinGW-w64
pacman -S mingw-w64-x86_64-sdl3 mingw-w64-x86_64-libsoxr

make

Note: On Linux and Windows, Audio, Input, and Resampler all work. Renderer will throw at runtime on non-Metal backends until SPIR-V shader support is added (see platform support).

Building

Quick Start

# 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 clean

Installation

Install the library and headers system-wide (defaults to /usr/local):

make install                        # installs to /usr/local
make install PREFIX=~/.local        # installs to a custom prefix

This copies libraries to $(PREFIX)/lib/ and headers to $(PREFIX)/include/phosphene/.

Configuration

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 info

Using Phosphene in Your Project

Phosphene 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.

Option A: Git Submodule (recommended)

No install step required — CMake builds Phosphene as part of your project.

git submodule add https://github.com/ooPo/Phosphene vendor/phosphene

In 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).

Option B: System Install + find_package

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 prefix

In 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/prefix

Compiler and include setup

The 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>

Project Structure

.
├── 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

Usage

Basic Example

#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;
}

Key Classes

Window

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 optional emulated_fps lets Window pick a present mode in one shot (see set_emulated_fps for the selection logic). The optional pref overrides the refresh-aware default — see set_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 default Auto preference: if host refresh is within ~2% of fps, 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 of init() 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() — The SDL_GPUPresentMode actually 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 via Renderer::submit_framebuffer on those and Renderer::repeat_last_framebuffer on the others. Only meaningful with PreferVsync (or Auto on a display where refresh matches FPS at all); N=1 collapses to the vsync_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, use vsync_paces_emulator() instead.
  • vsync_paces_emulator() — True iff VSYNC is active AND the display refresh matches the FPS hint. Used by Audio::wait_for_frame(Window&) to decide whether to skip the audio drain. Returns false when VSYNC is forced via PreferVsync but refresh doesn't match FPS (audio still paces the loop in that case).
  • gpu() — Get GPU device handle
  • window() — Get window handle
  • shutdown() — Clean up resources

Renderer

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 resolution
  • submit_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 between begin_frame() and end_frame(). Vertical scale is always integer (crisp scanlines); horizontal stretch is v_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 recent submit_framebuffer() call without re-uploading. Use this for frame duplication when the loop runs at an integer multiple of the emulator FPS (see Window::present_refresh_multiple): emulate once every N iterations, call submit_framebuffer() on those iterations and repeat_last_framebuffer() on the others — vsync sees a clean N:1 cadence. Must be called between begin_frame() and end_frame(); throws if no prior submit_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 between begin_frame() and end_frame().
  • begin_frame() — Start a new render pass
  • submit_draw(cmd) — Queue a draw call
  • end_frame() — Flush and present
  • create_texture(width, height, format=RGBA8) — Allocate GPU texture
  • destroy_texture(tex) — Release texture
  • upload_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.

Audio

Frame-synchronized audio playback with resampling support.

Methods:

  • init(sample_rate, channels, emulated_fps) — Initialize device
  • push(samples, count) — Enqueue audio samples
  • wait_for_frame() — Block until audio buffer ready for next frame (unconditional; use the Window& 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) — Push frames worth of zeros into the queue. Primitive used by prime() and the watermark guard in wait_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 after init() before the first real push().
  • target_queue_depth() — The shared queue target used by prime(), wait_for_frame(), and DRC. Currently 4 × samples_per_frame (~67 ms at 60 FPS). Deep enough that the watermark guard in wait_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 toward target_queue_depth())
  • apply_drc(resampler) — Compute DRC rate and push it into the resampler (one-call equivalent of resampler.set_out_rate(compute_drc_rate(resampler)))
  • shutdown() — Clean up device

Resampler

High-quality sample rate conversion (libsoxr wrapper).

Methods:

  • init(in_rate, out_rate, channels) — Initialize resampler
  • process(in, in_count, out, out_capacity) — Resample audio
  • set_out_rate(new_rate) — Update output rate (for DRC). Internally passes a slew_len of ~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

Input

Gamepad and keyboard input handling.

Methods:

  • init() — Initialize input system
  • handle_event(event) — Process SDL events
  • read() — Get current button state
  • shutdown() — Clean up

Input Mappings:

  • Gamepad: D-pad, A/B buttons, Select, Start
  • Keyboard: Arrow keys, Z (A), X (B), Right Shift (Select), Enter (Start)

Architecture Notes

  • Non-owning pointers: Renderer holds a non-owning reference to Window
  • Forward declarations: Headers minimize coupling (e.g., Resampler forward-declared in Audio)
  • Exception safety: Initialize/shutdown pattern with cleanup on error
  • GPU resource management: SDL3 GPU API handles memory; you call destroy_texture() for manual releases

Performance Considerations

  • 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: Renderer uses a nearest-neighbour sampler so 2D framebuffer presentation preserves authentic retro appearance
  • Depth testing: Renderer includes Z-buffering for correct 3D rendering

Examples

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/hud

Or via CMake with -DPHOSPHENE_BUILD_EXAMPLES=ON.

Contributing

When adding new features:

  1. Update relevant headers with Doxygen-style documentation
  2. Maintain consistent code style (see existing files)
  3. Test on supported platforms
  4. Update README if API changes

License

BSD 2-Clause. See LICENSE.txt.

Troubleshooting

Compilation errors for SDL3?

  • Verify SDL3 is installed: pkg-config --cflags sdl3
  • Try make info to 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/example on Linux

References

About

A C++ library for building emulator frontends — GPU-accelerated 2D/3D rendering, frame-synchronized audio, and gamepad input via SDL3.

Resources

License

Stars

Watchers

Forks

Contributors