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

Usage

Basic Example

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

Key Classes

Window

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-zero emulated_fps so 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 the SDL_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% of emulated_fps. Useful for deciding whether the loop has display-based pacing (call video.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 by needs_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.

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

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. count is total samples (= frames × channels). The class handles resampling and DRC.
  • push_silence(frames) — Push frames worth 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 before init() (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-second SDL_Log diagnostic 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.

Resampler

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 a slew_len of ~30 ms of output samples so each change is interpolated smoothly. Requires SOXR_VR (handled by init()).
  • 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.
  • PIMPL for platform-specific impls: Audio exposes a single C++ class; the implementation behind it differs by platform (src/audio_macos.mm vs. 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.

Performance Considerations

  • Internal DRC: Audio keeps 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-side apply_drc call 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 via Audio::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: 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