English | 한국어
Play a stream in your browser · Benchmark your device · Benchmarks · C API · iPhone app
PyroWave is a video codec for game streaming on a local network: a GPU encodes and decodes each frame in a millisecond or two, at the cost of bandwidth. Valve has added it to Steam Remote Play, in the Steam client's beta (GamingOnLinux). Upstream's implementation is written as Vulkan compute shaders, with a separate port to Metal. emberwave is PyroWave rewritten in Rust and WGSL on wgpu: one code base, and the same bitstream, on Metal, Vulkan, DX12 and WebGPU, from a native app to a browser tab and an iPhone.
- Fast. On an Apple M3 Max, frames one after the other, a 4K frame decodes in 0.90 ms to its planes and 1.22 ms to RGB, and encodes in 1.90 ms on the GPU, from CPU memory to CPU memory. Upstream's own Metal port takes 0.80 ms to decode it at the same FP32 precision and 0.56 ms at its default FP16, and 3.2 to 3.3 ms to encode it (
BENCHMARKS.md, 2026-10-26). - Every frame stands alone. No frame refers to another, so a lost packet costs one frame at most, with no wait for a keyframe: emberwave's frames can still be shown in part, unless the packet held the coarsest level or the frame's header. Each frame keeps to a fixed byte budget.
- Compatible with upstream, both ways. It decodes the streams of upstream's encoder as upstream's decoder does, at 64 dB PSNR or more, and upstream's decoder reads emberwave's streams. At the same bytes per frame, emberwave's frames have 6.5 to 13 % less of the contrast-weighted error that both encoders minimise than those of upstream's encoder, with the fixes upstream made to its rate control in October 2026, on every test picture but noise, where the two are within 1 %. In luma PSNR, emberwave's ranges from 0.8 dB below upstream's, on a gradient, to 2.3 dB above it, on a test scene, and is the same on noise.
- Native, in the browser and on the iPhone. A command line tool, a browser player that decodes with WebGPU in the page, opens with a demo stream it encodes itself and can play a stream packet by packet, shuffled and some of them lost, as a network delivers it, a C API with prebuilt libraries for Linux and Windows (x86_64) and macOS (Apple silicon), and an XCFramework for iPhone, iPad and Mac apps. Tested on Apple GPUs, and in CI on software Vulkan and DX12.
These times are for frames one after the other. A stream's frames arrive 60 a second, and between them the GPU idles and slows down, so each decode takes longer (Performance).
Status: 0.x. The bitstream is upstream's, and the Rust and C APIs may still change between minor versions. CI tests every change on Linux with the lavapipe Vulkan driver, on macOS with Metal and on Windows with DX12 on WARP. Releases and their changes are in
CHANGELOG.md.
PyroWave buys its speed with bandwidth, and how much it takes depends on the picture. This 4K frame of a game is a hard one, much of it grass, needles and stone. emberwave's encoder made the frames. Each decoded panel gives the bit rate at 60 frames a second and the luma PSNR over the whole frame, and shows the region that loses the most, enlarged twice. At 322 Mbit/s the needles run together and the joints of the wall blur; at 816 Mbit/s they are mostly back, and at 1.2 Gbit/s the region is close to the source. At 322 Mbit/s the first picture of the benchmarks' synthetic 4K scene keeps 42.7 dB. On this frame, a key frame of Apple's hardware HEVC encoder, outside its real-time mode, has 1.9 to 3.5 dB more luma PSNR within the same budget (Apple M3 Max, macOS 27.0.1, 2026-10-27), and an HEVC stream needs key frames only at its start and after a loss. PyroWave is meant for local networks with bandwidth to spare (BENCHMARKS.md).
The frame is from Crimson Desert, © Pearl Abyss. This comparison is unofficial content: it contains copyrighted material and IP from Pearl Abyss, and it is not made or endorsed by Pearl Abyss. The picture is not covered by this repository's license.
A frame is a wavelet transform of the picture, its coefficients quantised in blocks and sent as bit-planes, most significant first. To fit a frame into its budget, the encoder drops the least significant planes where that costs the picture least, weighted by the eye's sensitivity to each band. Every stage of the figure but the decoder's assembly of the packets is a compute shader in WGSL; that assembly runs on the CPU, as in upstream's decoder. Around them, the CPU copies the planes in, the frame out and the picture's header. ARCHITECTURE.md describes the bitstream, the passes and the decisions behind them.
You need Rust 1.87 or newer and a GPU with Vulkan, Metal or DX12. Without a GPU, a software driver works too: Mesa's lavapipe on Linux (sudo apt install mesa-vulkan-drivers libvulkan1 on Debian and Ubuntu), WARP on Windows, which is built in.
git clone https://github.com/with-developer/emberwave.git
cd emberwave
cargo build --release -p emberwave-cli
target/release/emberwave-cli decode testdata/gradient_200x120_420.pwv1 --out-dir outThe first build takes a few minutes. The last command prints the adapter it uses and the time of each frame, and leaves out/gradient_200x120_420_000000.png to 000002.png: a colour gradient. The .pwv1 files in testdata/ are small streams made by upstream's encoder.
With Rust, the command line decoder also installs from crates.io with cargo install emberwave-cli, and a Rust project takes the library with cargo add emberwave-core (documentation).
Without Rust, take emberwave-cli for Linux (x86_64), macOS (Apple Silicon) or Windows (x86_64) from the releases and decode a .pwv1 file from testdata/ with it. The macOS binary is not signed, so run xattr -d com.apple.quarantine emberwave-cli once after downloading it.
emberwave-cli decodes PyroWave files in the PWV1 format (.pwv1). The examples assume emberwave-cli is on the PATH (cargo install --path crates/emberwave-cli puts it there, or use target/release/emberwave-cli).
# What is in a file
emberwave-cli info testdata/gradient_200x120_420.pwv1
# Decode to one PNG per frame, or to a Y4M video of the YUV planes
emberwave-cli decode testdata/gradient_200x120_420.pwv1 --out-dir out
emberwave-cli decode testdata/gradient_200x120_420.pwv1 --out-dir out --format y4m
# Time the decoder; --output rgb includes the colour conversion, --json prints JSON
emberwave-cli bench testdata/gradient_200x120_420.pwv1 --output rgb
# Compare two Y4M files plane by plane; fails if a plane is below 60 dB PSNR
emberwave-cli compare out/gradient_200x120_420.y4m testdata/gradient_200x120_420.ref.y4m --min-psnr 60
# Encode a Y4M video (8-bit 4:2:0 or 4:4:4), each frame within 6000 bytes;
# --gpu encodes on the GPU
emberwave-cli encode testdata/encoder/scene_200x120_420.source.y4m --out scene.pwv1 --bytes-per-frame 6000 --gpu
# Measure a stream, from any encoder, against the pictures it was encoded
# from: PSNR, and the error that the encoder's rate control minimises
emberwave-cli quality testdata/encoder/scene_200x120_420.source.y4m scene.pwv1
# Time the encoder, on the GPU here
emberwave-cli bench-encode testdata/encoder/scene_200x120_420.source.y4m --bytes-per-frame 6000 --gpuThe decoder, and the encoder with --gpu, use whatever GPU the machine offers: Vulkan, Metal or DX12, or a software driver such as Mesa's lavapipe or Windows' WARP. --backend vulkan|metal|dx12 and --software narrow the choice, as does the EMBERWAVE_BACKEND environment variable, and the adapter in use is printed. The RGB conversion follows what the file signals (matrix, range, chroma siting); --matrix bt601|bt709|bt2020 and --range full|limited override it. ARCHITECTURE.md lists every option and explains how the timings are measured.
The C API decodes packets as a client receives them, into the caller's memory, and encodes pictures into frames and packets as a host sends them: a header, crates/emberwave-ffi/include/emberwave.h, and a shared or static library, which each release also ships prebuilt. crates/emberwave-ffi/README.md explains how to build and link it, and examples/c/decode.c and examples/c/encode.c are complete programs. On Linux, Android and Windows it also runs on the client's own Vulkan device and decodes into its images, on Windows it shares textures and fences with Direct3D 12 and 11 devices, and hosts encode pictures straight from their textures, in RGB as captures hold them too (the C API's GPU interop).
For apps on iPhones, iPads and Macs, each release also has the static library as an XCFramework, Emberwave.xcframework, which Swift imports as Emberwave; Metal clients can have the picture drawn into a texture on the GPU, or its Y, Cb and Cr planes put into three, and read the id<MTLTexture> (Pictures on the GPU). examples/swift decodes a file with it from Swift, examples/ios is a benchmark app for iPhones and iPads, and CI runs the examples and the app with it in an iOS Simulator (iPhone, iPad and Mac apps).
Decode time per frame on an Apple M3 Max (Metal), RGB output kept in GPU memory, measured on 2026-10-26, in milliseconds:
| Stream | mean | p99 |
|---|---|---|
| 1080p | 0.53 | 0.79 |
| 1440p | 0.69 | 0.97 |
| 4K | 1.22 | 1.62 |
Upstream's own Metal decoder is faster on the same machine: 0.80 ms for a 4K frame at FP32 against emberwave's 0.90 ms (planes only). BENCHMARKS.md has the method and all the numbers.
Encoding a 4K frame within 1.2 MB takes 1.90 ms on the GPU, from the planes in CPU memory to the frame in CPU memory, and 3.33 ms with upstream's encoder at FP32.
In Safari on an iPhone 17 the benchmark page decodes a 1080p frame in 2.4 ms and a 4K frame in 8.1 ms, to RGB in GPU memory (BENCHMARKS.md). Natively, through the XCFramework, the benchmark app decodes them into a Metal texture in 1.6 and 6.2 to 6.3 ms, and into Y, Cb and Cr textures in 1.3 and 4.8 ms (BENCHMARKS.md).
The same app times the hardware HEVC decoder that Moonlight uses, at Moonlight's default bit rates. Frame after frame, emberwave decodes into textures 2.2 to 3.9 times as quickly on the M3 Max, 1.0 against 2.6 to 3.4 ms at 4K, and 1.4 to 1.7 times as quickly on the iPhone 17, 4.5 against HEVC's 6.2 to 6.3 ms, in runs a day after those above. When frames come 60 a second, the GPU slows down while it idles between them, and the media engine does not. The two then take about as long on the Mac, and on the phone HEVC is 1.5 to 2.7 times as quick (BENCHMARKS.md).
- Decode bitstreams produced by the original PyroWave encoder, straight from a file, and match the reference decoder's output. PyroWave decoding is floating-point and not bit-exact by design, so the acceptance bar is PSNR ≥ 60 dB per plane against the reference decoder.
- Rely only on features wgpu guarantees on every backend: no Vulkan-only extensions and no subgroup operations. Where Metal offers more, an optional path may use it with the portable one as its fallback (decisions D52 and D53).
- Measure per-frame decode time at 1080p / 1440p / 4K on macOS (Metal) and publish the numbers together with their environment.
Out of scope: network transport, RTP and FEC.
Done: the milestones to the first release, M0 to M5 (#1 to #6); since then the encoder, on the CPU and on the GPU (#14), the browser player and its benchmark page (#15), the C API for clients and hosts (#16, #42), pictures to and from GPU memory, Metal textures included (#27, #33), and the XCFramework for iPhone, iPad and Mac apps (#52). 0.12 took two copies out of a frame's work on Apple's GPUs: the planes are stored straight into textures on Metal, and on Apple silicon the frames are written where the shaders read them (#86). 0.13 decodes streams as they arrive: the browser player takes a stream packet by packet, every frame carries the blocks that a decode in part needs, a client chooses how much of a frame it draws in part, and calls on a client's own Metal queue take turns at its command buffers (#109, #116, #123, #126). 0.14 shares the GPU with clients and hosts on every platform: a context on the caller's own Vulkan device decodes into its images with semaphores, Direct3D 12 and 11 devices on Windows share textures and fences through NT handles, and a host's encoder reads its pictures straight from its textures, NV12's layout included. Each release also has the XCFramework in the archive a Swift package takes (#115, #133, #137). 0.15 encodes pictures in RGB, as hosts' screen captures hold them: the encoder converts them to Y'CbCr as it reads them, from a BGRA or RGBA texture of Metal, Vulkan or Direct3D or from CPU memory, in integers that make the same bytes on every GPU (#142). 0.16 keeps a still picture's detail through lost packets: where the picture has not moved, a frame drawn in part fills the blocks of detail it lacks with the last frame's, when a client asks for it. Its wavelet transforms also take a fifth less GPU time (#129, #147). 0.17 readies the APIs for 1.0: the C API's structs carry their size, so that they can grow without breaking programs, its planes their capacity, and the Rust API's enums and structs can grow without breaking callers (#152). 0.18 lets a client ask how much of a frame has arrived and which GPU a context runs on. CHANGELOG.md has each release.
Next, each in a release when it is ready:
- Measured in real use. Decoding inside a client's display loop, timed to the screen, on a Mac and an iPhone (#81); NVENC's HEVC and AV1 streams decoded beside emberwave's, and the quality of moving game footage against HEVC with predicted frames at the same bit rate, which also tells whether chroma's band weights should change (#82, decision D67); and 1080p at a stream's pace, still 0.15 to 0.3 ms behind upstream's FP32 decoder where 4K has caught up (#150).
- Toward 1.0, clients. Streaming to a browser, in a repository of its own so that emberwave stays a codec: a host's screen encoded on its GPU, sent over the local network and decoded with WebGPU in a tab, its latency measured first. After that, emberwave's encoder offered to the hosts that stream PyroWave. 1.0 needs stable C and Rust APIs, one client in real use, and decoding at a stream's pace level with upstream's.
| Path | Purpose |
|---|---|
crates/emberwave-core |
Bitstream parser, CPU reference decoder, colour conversion, the wgpu/WGSL decoder, and the encoder on the CPU and the GPU |
crates/emberwave-cli |
emberwave-cli command line tool |
crates/emberwave-ffi |
The C API: header, library and C examples |
crates/emberwave-web, web/ |
The browser player and its benchmark: WebAssembly module and pages |
testdata/ |
Test vectors and how to regenerate them |
scripts/ |
Test data, 4K acceptance check and benchmark scripts |
docs/images/ |
The README's banner and charts, drawn by scripts/readme_figures.py from the numbers in BENCHMARKS.md, and its picture of the quality, made by scripts/quality_figure.py |
README.ko.md |
This README in Korean |
ARCHITECTURE.md |
Bitstream format, GPU pipeline and the decision log |
BENCHMARKS.md |
How decode times are measured, and the results |
CONTRIBUTING.md |
How to build, test and change the code |
CHANGELOG.md |
What each release changed |
LICENSE, THIRD_PARTY_NOTICES.md |
The MIT license; PyroWave's license and what the license does not cover |
cargo build --workspace
cargo test --workspaceThe GPU tests skip when there is no adapter, unless EMBERWAVE_REQUIRE_GPU is set. CONTRIBUTING.md explains the test vectors, the conventions and how changes get in.
emberwave follows PyroWave's algorithm structure and its bitstream definition, but contains no code copied from the upstream repository. Test vectors and reference output come from upstream's own encoder and decoder; see testdata/README.md.
Upstream also ships an Apple-only Metal port. emberwave aims at one portable code base that additionally covers Vulkan, DX12 and the browser.
MIT. See LICENSE. THIRD_PARTY_NOTICES.md has PyroWave's copyright notice and license, and what the license does not cover: the frame of Crimson Desert in docs/images/quality.png, © Pearl Abyss, which the source archives of releases leave out.
