418 lines
21 KiB
Markdown
418 lines
21 KiB
Markdown
# Native Video for Godot
|
|
|
|
Hardware-accelerated MP4/MOV video playback (H.264/HEVC) for Godot 4 on
|
|
macOS, Windows, and Linux, using the platform media stack as a **hardware
|
|
decoder only** — never in player mode — and presenting decoded frames to the
|
|
GPU through either zero-copy import or an explicit, counted CPU-copy path.
|
|
Drop-in compatible with Godot's `VideoStreamPlayer`.
|
|
|
|
## Architecture
|
|
|
|
The extension is built around a Godot-independent **core** that drives the
|
|
OS media framework in Decoder mode: `AVAssetReader` on macOS, a synchronous
|
|
`IMFSourceReader` on Windows. Either way, decode itself runs on a bounded
|
|
shared worker pool, not one thread per video; on Windows, COM is confined to
|
|
a single dedicated MTA (multi-threaded apartment) executor thread the
|
|
backend owns for the life of the open media, since Godot's own main thread
|
|
runs in an STA that Media Foundation object creation can't use directly.
|
|
The core owns the master clock, the frame queue, sync, and decode-ahead, so
|
|
nothing implicit in the OS framework owns timing.
|
|
|
|
On macOS, frames reach the GPU with no CPU copy: the hardware-decoded NV12
|
|
surface is imported via `RenderingDevice.texture_create_from_extension` as a
|
|
`Texture2DRD` and converted NV12→RGB in a single Metal compute pass into an
|
|
engine-owned texture returned from `VideoStreamPlayback._get_texture()`.
|
|
Godot never samples the decoder surface directly. On Windows the same
|
|
zero-copy shape is available when Godot runs its D3D12 rendering driver —
|
|
see [Windows present path](#windows-present-path) below for the one case
|
|
where it falls back to a CPU copy instead.
|
|
|
|
Highlights:
|
|
|
|
- **Decoder mode, engine-owned clock** — audio-master sync with a monotonic
|
|
fallback for silent clips; drop-late / hold-early present policy recovers
|
|
from decode hiccups without permanent drift.
|
|
- **Explicit present pipelines** — zero-copy on macOS and Windows D3D12;
|
|
counted, bounded CPU-copy on Windows Vulkan and the initial Linux Vulkan
|
|
backend.
|
|
- **Hardware decode via the OS** — AVFoundation on macOS, Media Foundation
|
|
on Windows, GStreamer with VA-API/NVDEC on Linux; low CPU and battery, no
|
|
bundled codec libraries.
|
|
- **Adaptive scrubbing** — keyframe-only while dragging fast, exact frame on
|
|
settle.
|
|
- **Shared decode-worker pool** — many videos in one scene without a thread
|
|
per video; per-stream frame order preserved.
|
|
|
|
## Requirements
|
|
|
|
- **macOS** (Apple Silicon or Intel; the extension links AVFoundation,
|
|
CoreMedia, CoreVideo, and Metal directly), **Windows** (the extension links
|
|
Media Foundation and D3D11/D3D12 directly), or **x86_64 GNU/Linux** with
|
|
GStreamer 1.22+ and the appropriate hardware decoder plugin.
|
|
- **Godot 4.6+**, **Forward+ or Mobile renderer** (the RenderingDevice
|
|
renderers). The Compatibility/OpenGL renderer is not supported on any
|
|
platform — there is no OpenGL texture presentation path.
|
|
- **Zig 0.16.0** to build from source (see [mise.toml](mise.toml)). Other
|
|
Zig versions are not supported: the extension is built against
|
|
[gdzig](https://github.com/gdzig/gdzig), a pre-1.0 binding generator
|
|
pinned to a specific commit and Zig version.
|
|
|
|
## Releases
|
|
|
|
Each GitHub Release attaches a `native_video-<tag>.zip` containing an
|
|
`addons/native_video/` folder — a self-contained Godot addon: drop it into
|
|
a project's `addons/` directory and Godot picks up `native_video.gdextension`
|
|
automatically. It bundles:
|
|
|
|
| Platform | Arch | Files |
|
|
| --- | --- | --- |
|
|
| macOS | universal (arm64 + x86_64, via `lipo`) | `libnative_video.macos.debug.dylib`, `libnative_video.macos.release.dylib` |
|
|
| Windows | x86_64 | `native_video.windows.debug.x86_64.dll`, `native_video.windows.release.x86_64.dll` |
|
|
| Windows | x86 (32-bit) | `native_video.windows.debug.x86_32.dll`, `native_video.windows.release.x86_32.dll` |
|
|
| Windows | arm64 | `native_video.windows.debug.arm64.dll`, `native_video.windows.release.arm64.dll` |
|
|
| Linux | x86_64 | `libnative_video.linux.debug.x86_64.so`, `libnative_video.linux.release.x86_64.so` |
|
|
|
|
Both a debug and a release binary ship for every platform/arch, matching
|
|
Godot's `template_debug`/`template_release` export split — the `.gdextension`
|
|
routes each automatically based on how the exported project was built.
|
|
Debug binaries are built with `-Doptimize=ReleaseSafe` (safety checks kept
|
|
in, unstripped); release binaries with `-Doptimize=ReleaseFast` (stripped).
|
|
|
|
**iOS** builds and links (produces a valid iOS Mach-O `dylib`), but is not
|
|
shipped in releases: Godot's iOS export conventionally expects a static
|
|
library / `.xcframework` rather than a plain dylib, and only link-time (not
|
|
on-device runtime) has been verified. Follow-up work; not currently
|
|
tracked for a specific release.
|
|
|
|
## Windows present path
|
|
|
|
Windows can run Godot's Vulkan or D3D12 `RenderingDevice` driver, and this
|
|
build links two import paths that hand decoded frames to Godot. The choice
|
|
is made once, automatically, at startup, from the active RD driver name and
|
|
the running Godot version — never a build-time variant, never a
|
|
try-and-fail probe:
|
|
|
|
| Renderer driver | Import path | Zero-copy | Notes |
|
|
| --- | --- | --- | --- |
|
|
| `d3d12` | D3D12 shared-handle import | Yes | Shared NT handles + a shared D3D11/D3D12 fence hand the decoder's D3D11 texture to Godot's D3D12 device; a plane-split compute pass splits NV12/P010 into luma/chroma views. The selection code requires Godot 4.5+ for this path (`texture_create_from_extension` isn't implemented for the D3D12 driver before that); this extension's Godot 4.6+ floor already clears that bar. |
|
|
| `vulkan` (stock) or anything else | CPU-copy fallback | No | A zero-copy Vulkan path (DXGI shared handles into `VK_KHR_external_memory_win32`) is not built here — it was hard-disabled upstream even before this Zig port, because Godot's `texture_create_from_extension` mis-binds NV12 plane aspects on the Vulkan driver. Adds one GPU→CPU readback per frame before the texture reaches Godot. |
|
|
|
|
External absolute drive paths are opened in place. On Windows, `C:\...` and
|
|
`C:/...` inputs are normalized to a UTF-16 `file:///C:/...` URI without
|
|
percent-encoding Unicode or spaces; no copy, link, or ASCII alias is created.
|
|
|
|
If the D3D12 path is selected but fails to come up — no D3D12 device to
|
|
bind, or the shared handles can't be opened across adapters — the extension
|
|
degrades to the CPU-copy path automatically, once, and stays there for the
|
|
rest of that playback session; it never retries the zero-copy path per
|
|
frame. Call `get_cpu_copy_count()` on a playback instance to see how many
|
|
frames took the CPU-copy path this session (always 0 on macOS, since Metal
|
|
import is always zero-copy there).
|
|
|
|
**Recommendation:** run Godot with the D3D12 rendering driver for zero-copy
|
|
present. Stock Vulkan still gets full hardware decode, just with a per-frame
|
|
GPU→CPU readback added before present.
|
|
|
|
## Linux backend (GStreamer)
|
|
|
|
Linux uses GStreamer as a demux/decoder graph with bounded, unsynchronised
|
|
`appsink` outputs. GStreamer never renders video or audio and is not the master
|
|
clock: the existing Zig core continues to own scheduling, decode-ahead, A/V
|
|
sync, seeking and frame selection. Video and audio use independent graphs so a
|
|
mid-stream audio-track change can seek audio without disturbing video.
|
|
|
|
The initial production path targets x86_64 Linux, Godot's Vulkan
|
|
RenderingDevice (Forward+ or Mobile), H.264/HEVC in MP4/MOV/M4V, and NV12/P010
|
|
decoder output. Decoded system-memory planes are copied into two reused staging
|
|
buffers and uploaded to Godot textures. This is an explicit, bounded CPU-copy
|
|
presenter; every presented frame increments `get_cpu_copy_count()`. DMA-BUF
|
|
import is not yet enabled because Godot's public RenderingDevice extension API
|
|
does not expose enough Vulkan image-import synchronization to make modifier and
|
|
fence handling portable.
|
|
|
|
Distributed x86_64 Linux binaries use Zig's architecture baseline (SSE2) and
|
|
do not require AVX, AVX2, or AVX-512. Release CI disassembles both `.so` files
|
|
and rejects non-baseline vector instructions, preventing a build-host CPU from
|
|
silently becoming a deployment requirement.
|
|
|
|
Development packages (Debian 13 package names):
|
|
|
|
```bash
|
|
sudo apt install pkg-config build-essential libgstreamer1.0-dev \
|
|
libgstreamer-plugins-base1.0-dev
|
|
```
|
|
|
|
Runtime packages for the ordinary open-source desktop stack:
|
|
|
|
```bash
|
|
sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
|
gstreamer1.0-plugins-bad gstreamer1.0-libav gstreamer1.0-tools \
|
|
gstreamer1.0-vaapi
|
|
```
|
|
|
|
- Intel and AMD: a working VA-API driver plus the GStreamer `va`/VA-API
|
|
decoder plugin is required. Verify it with `gst-inspect-1.0 vah264dec` or
|
|
`gst-inspect-1.0 vah265dec`.
|
|
- NVIDIA: install the distribution's GStreamer NVDEC/nvcodec plugin matching
|
|
the proprietary driver. Availability and package names vary by distribution.
|
|
- Debian 13 is the verified build baseline. Ubuntu 24.04 and newer are
|
|
supported when they provide GStreamer 1.22+ and the same plugin/API set.
|
|
- Wayland and X11 do not enter the decoder graph; each still needs separate
|
|
Vulkan/driver validation. Compatibility/OpenGL remains unsupported.
|
|
|
|
The selected decoder, negotiated pixel format, presenter, and fallback reason
|
|
are reported in Godot diagnostics. Software decoding is never selected
|
|
silently: if GStreamer chooses `avdec_h264`/`avdec_h265`, opening fails. For
|
|
diagnostics or unsupported machines it can be explicitly enabled with
|
|
`GODOT_NATIVE_VIDEO_ALLOW_SOFTWARE=1`; the fallback is then logged as a warning
|
|
and is not advertised as hardware acceleration.
|
|
|
|
Before deployment, collect a path-free capability report on the target Linux
|
|
machine. The strict form exits with code 2 when no supported hardware decoder
|
|
factory is visible:
|
|
|
|
```bash
|
|
bash tools/linux_diagnostics.sh
|
|
bash tools/linux_diagnostics.sh --require-hardware
|
|
```
|
|
|
|
Run it independently under Wayland and X11 when both sessions are supported by
|
|
the product. A complete hardware qualification records the session type,
|
|
GPU/driver, selected GStreamer factory, Godot renderer, H.264/HEVC result,
|
|
audio result, seek result, lifecycle stress result, and CPU-copy count.
|
|
|
|
The extension dynamically links GStreamer under its LGPL license; GStreamer,
|
|
codec plugins and GPU drivers are not redistributed by this repository. A
|
|
distributor must separately audit the licenses of the exact runtime plugins it
|
|
ships (notably distro-provided FFmpeg/libav plugin builds).
|
|
|
|
## Runtime source switching
|
|
|
|
External MP4/MOV/M4V sources follow the normal `VideoStreamPlayer` lifecycle on
|
|
Godot 4.7.x. `stop()` synchronously cancels and joins any in-flight decode
|
|
slice, pauses the native video/audio pipeline, drains queued audio and video,
|
|
resets the source to position 0, and detaches the displayed frame. Calling it
|
|
more than once is safe. A later `play()` resumes the same source from 0.
|
|
|
|
Setting `stream = null`, assigning a different stream, freeing the player with
|
|
`queue_free()`, and replacing a scene while playback is active are supported.
|
|
Reassigning a stream resource returned from Godot's resource cache is also
|
|
safe, including when its 64-bit ObjectID has the high bit set.
|
|
Destruction first retires presented frame handles, then cancels the decoder,
|
|
waits for its worker, detaches native callbacks/readers, and releases each
|
|
backend handle exactly once. No plugin-side audio worker or audio-bus reference
|
|
survives disposal. The process-wide bounded scheduler threads remain idle for
|
|
reuse; they do not retain a disposed stream or decoder.
|
|
|
|
The lifecycle regression harness accepts two external paths and performs 50
|
|
stop/unbind/rebind/replay cycles, including a second simultaneous decoder,
|
|
active-player deletion and real `SceneTree.change_scene_to_packed()` replacement:
|
|
|
|
```bash
|
|
godot --path project --script res://lifecycle_stress.gd -- /path/a.mp4 /path/b.mp4
|
|
zig build lifecycle-smoke -- /path/a.mp4 /path/b.mp4 --cycles=50
|
|
```
|
|
|
|
On Windows, quote paths containing spaces. Headless runs validate decoder,
|
|
audio, worker and ownership teardown but cannot validate texture presentation;
|
|
use Forward+ or Mobile for the full presentation path. The only intentional
|
|
long-lived native objects are the bounded shared scheduler threads described
|
|
above.
|
|
|
|
## Known limitations
|
|
|
|
- **Headless mode (`--headless`)**: presentation is disabled because there
|
|
is no RenderingDevice. Decode, audio mixing, the master clock, and
|
|
playback state machines keep running normally, and end-of-stream is
|
|
reached. No texture output is available.
|
|
- **Compatibility/OpenGL renderer**: not supported on any platform — no
|
|
CPU present path.
|
|
- **Linux DMA-BUF zero-copy**: not available in the initial Linux backend;
|
|
Vulkan presentation uses the counted CPU-copy upload described above.
|
|
- **Vulkan zero-copy on Windows**: not available. A DXGI-shared-handle path
|
|
into Vulkan was hard-disabled upstream (a Godot `texture_create_from_extension`
|
|
bug mis-binds NV12 plane aspects on the Vulkan driver) and isn't built into
|
|
this extension, so Vulkan on Windows always takes the CPU-copy fallback
|
|
described above.
|
|
- **HEVC on Windows** depends on a decoder MFT being registered on the
|
|
machine (typically the "HEVC Video Extensions" package) — not present on
|
|
every Windows installation.
|
|
- **Single precision only (prebuilt binaries)**: the released libraries are
|
|
built for the standard single-precision Godot. They will not load into a
|
|
double-precision (`precision=double`) Godot build — the two disagree on the
|
|
in-memory layout of core types. Double-precision users build from source
|
|
with `-Dprecision=double` (see [Building from source](#building-from-source)).
|
|
|
|
## Diagnostics
|
|
|
|
Every log line the extension emits — from the main thread or a decode
|
|
worker, on either backend — is mirrored to Godot's own reporting surface as
|
|
well as stderr: errors and warnings show up in the editor's Output panel
|
|
(`push_error` / `push_warning`), not just a console window, and info/debug
|
|
lines print there too. A failed `load()` (bad path, backend open failure,
|
|
unsupported content) is reported the same way with the underlying error name,
|
|
instead of failing silently. Normal diagnostics do not expose the full media
|
|
path.
|
|
|
|
`get_cpu_copy_count()` on a live `VideoStreamPlayback` reports how many
|
|
frames in this session have gone through a CPU-copy presenter — the Windows
|
|
Vulkan fallback or the current Linux Vulkan path.
|
|
|
|
## Scope
|
|
|
|
- **Codecs:** H.264, HEVC (Main and Main10).
|
|
- **Containers:** MP4, MOV — the loader registers `.mp4`, `.mov`, and
|
|
`.m4v`.
|
|
- **Pixel formats:** NV12 (8-bit) and 10-bit biplanar — x420 on macOS,
|
|
P010 on Windows — negotiated to match the source's bit depth. The
|
|
conversion shader normalises 10-bit payloads in 16-bit GPU words on both
|
|
platforms.
|
|
- **Colorimetry:** BT.709, BT.601, and BT.2020 YCbCr matrices; BT.709 /
|
|
BT.601 / BT.2020 / DCI-P3 primaries; BT.709, PQ (ST 2084 / HDR10), and HLG
|
|
transfer functions; video and full range. Unspecified fields default to
|
|
BT.709, video range.
|
|
- **HDR:** PQ/HLG clips tone-map to watchable SDR by default, or output
|
|
scene-linear HDR (RGBA16F, 1.0 = 203-nit Reference White per BT.2408) when
|
|
the stream's `output_mode` is set to HDR.
|
|
- **Audio:** AAC, decoded to interleaved float32 PCM. Mono, stereo, and 5.1
|
|
sources are channel-mixed to the clip's canonical output format.
|
|
- **Multi-track audio:** supported — enumerate tracks with
|
|
`NativeVideoStream.get_audio_tracks()` and select one via
|
|
`VideoStreamPlayer.audio_track`, either before `play()` or mid-playback.
|
|
|
|
Beyond this scope, the extension will attempt to play anything the OS media
|
|
framework can decode, as long as it arrives in one of the registered
|
|
container extensions above. Such content may well work, but only the matrix
|
|
above is tested and contractually supported.
|
|
|
|
**Out of scope:** VP9/AV1. **Known limitation:** mixed-sample-rate clips —
|
|
the first audio track's sample rate wins, and a mid-stream switch to a
|
|
track with a differing rate is refused.
|
|
|
|
## Building from source
|
|
|
|
1. Install Zig 0.16.0 (`mise install` picks it up from
|
|
[mise.toml](mise.toml), or install it directly — the system Zig on most
|
|
machines will be a newer, incompatible version).
|
|
|
|
The pinned `gdzig` bindgen currently expects Godot 4.6's extension API
|
|
JSON. A library generated against 4.6 is compatible with and has been
|
|
smoke-tested in Godot 4.7.1, but passing a 4.7 executable through
|
|
`-Dgodot-path` makes this bindgen revision fail while parsing the newer
|
|
JSON. For a 4.7 runtime, omit `-Dgodot-path` (the build downloads Godot
|
|
4.6 for bindgen) or point it at a 4.6.x executable.
|
|
|
|
To build bindings with 4.6 but run integration checks with an installed
|
|
4.7 executable, provide both paths separately:
|
|
|
|
```bash
|
|
zig build lifecycle-smoke \
|
|
-Dgodot-path=/path/to/Godot-4.6 \
|
|
-Druntime-godot-path=/path/to/Godot-4.7 -- /path/a.mp4 /path/b.mp4
|
|
```
|
|
|
|
2. Build the extension:
|
|
|
|
```bash
|
|
zig build
|
|
```
|
|
|
|
This compiles `libnative_video.dylib` (macOS), `native_video.dll`
|
|
(Windows), or `libnative_video.so` (Linux) and installs it to `project/lib/`, where
|
|
`project/native_video.gdextension` expects it. `build.zig` is the entire
|
|
build entry point. Apple/Windows dependencies are system frameworks;
|
|
Linux additionally resolves the documented GStreamer development packages
|
|
through `pkg-config`.
|
|
|
|
Builds default to a stripped `ReleaseFast` binary (~380 KB on macOS
|
|
arm64; Windows binary size hasn't been separately measured). Pass
|
|
`-Doptimize=Debug` for a debug build, or `-Doptimize=ReleaseSmall` to
|
|
trade some speed for an even smaller library.
|
|
|
|
To target a double-precision Godot build, add `-Dprecision=double`
|
|
(default is `float`). The precision must match the Godot the extension
|
|
loads into; a mismatch is a memory-layout error, not a graceful failure.
|
|
|
|
3. Run the core unit tests (no Godot needed):
|
|
|
|
```bash
|
|
zig build test
|
|
```
|
|
|
|
Linux can additionally run the standalone decode/seek/reopen harness under
|
|
Valgrind. Only definite and indirect leaks fail the step; install
|
|
`valgrind` first and explicitly enable software decoding only on machines
|
|
without a hardware plugin:
|
|
|
|
```bash
|
|
GODOT_NATIVE_VIDEO_ALLOW_SOFTWARE=1 zig build leak-check
|
|
```
|
|
|
|
4. Run the demo project (builds the extension, launches Godot, and opens
|
|
the interactive playback UI against `project/synthetic.mp4` or a clip
|
|
passed after `--`):
|
|
|
|
```bash
|
|
zig build run
|
|
```
|
|
|
|
For a headless pass/fail check instead — loads a clip, plays it, polls
|
|
for a presented video texture, does a pixel-content sanity check, and
|
|
quits with exit 0 (PASS) / 1 (FAIL) — use the dedicated smoke step:
|
|
|
|
```bash
|
|
zig build smoke
|
|
```
|
|
|
|
Two additional integration steps exercise lifecycle teardown and load the
|
|
release-style addon directory from a generated clean Godot project:
|
|
|
|
```bash
|
|
zig build lifecycle-smoke -- /path/a.mp4 /path/b.mp4 --cycles=50
|
|
zig build package-smoke
|
|
```
|
|
|
|
(equivalent to `zig build run -- --smoke`, but headless). Both steps
|
|
download a matching Godot build for bindgen/running if `GODOT_PATH` or
|
|
`-Dgodot-path=/path/to/Godot` isn't set.
|
|
|
|
## Project layout
|
|
|
|
- `src/core/` — engine-independent core (clock, frame queue, scrubber,
|
|
present selector, decode scheduler, backend interface, color math). Pure
|
|
Zig, no Godot, AVFoundation, or Media Foundation imports; this is what
|
|
`zig build test` exercises.
|
|
- `src/avf/` — the AVFoundation backend (macOS): `avf_shim.m`/`avf_shim.h`
|
|
(a C-ABI Objective-C shim) plus the Zig glue that drives it through the
|
|
core `Backend` interface. `decode_smoke.zig` is a standalone CLI harness
|
|
that pumps the backend without Godot, for isolating decode-side bugs.
|
|
- `src/mf/` — the Media Foundation backend (Windows): a synchronous
|
|
`IMFSourceReader` driven through the core `Backend` interface, the
|
|
dedicated MTA `ComExecutor` thread, and the hand-written D3D11/D3D12/DXGI
|
|
bindings under `src/mf/win/`. `decode_smoke.zig` mirrors the AVFoundation
|
|
harness for isolating decode-side bugs without Godot.
|
|
- `src/linux/` — the Linux GStreamer backend, isolated C ABI shim, and
|
|
standalone decode smoke.
|
|
- `src/godot/` — [gdzig](https://github.com/gdzig/gdzig) glue:
|
|
`NativeVideoStream` / `NativeVideoStreamPlayback` registration, the
|
|
present pipeline that runs the NV12→RGB compute pass, and the surface
|
|
importers it selects between — Metal on macOS; on Windows, the pure
|
|
`importer_selector` function plus the D3D12 zero-copy and CPU-copy
|
|
importers it chooses between; on Linux, the counted system-memory Vulkan
|
|
presenter.
|
|
- `project/` — the example/verification Godot project and dev harness.
|
|
`main.gd`/`main.tscn` are a single entry point with two modes selected by
|
|
CLI flag: interactive playback UI (`zig build run`, the default) or
|
|
headless pass/fail verification (`zig build smoke`, `--smoke`).
|
|
- `addon/` — the shipped `.gdextension` template packaged into release
|
|
zips (see [Releases](#releases) below). Not used by `zig build`; the dev
|
|
harness in `project/` has its own `native_video.gdextension` pointing at
|
|
local build output.
|
|
|
|
For the historical record of how this implementation was chosen —
|
|
benchmarks and rationale — see [EVALUATION.md](EVALUATION.md).
|
|
|
|
## License
|
|
|
|
MIT — see [LICENSE](LICENSE).
|