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 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). Other Zig versions are not supported: the extension is built against 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):
sudo apt install pkg-config build-essential libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev
Runtime packages for the ordinary open-source desktop stack:
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 withgst-inspect-1.0 vah264decorgst-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 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:
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_extensionbug 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).
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_modeis 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 viaVideoStreamPlayer.audio_track, either beforeplay()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
-
Install Zig 0.16.0 (
mise installpicks it up from mise.toml, or install it directly — the system Zig on most machines will be a newer, incompatible version).The pinned
gdzigbindgen 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-pathmakes 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:
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 -
Build the extension:
zig buildThis compiles
libnative_video.dylib(macOS),native_video.dll(Windows), orlibnative_video.so(Linux) and installs it toproject/lib/, whereproject/native_video.gdextensionexpects it.build.zigis the entire build entry point. Apple/Windows dependencies are system frameworks; Linux additionally resolves the documented GStreamer development packages throughpkg-config.Builds default to a stripped
ReleaseFastbinary (~380 KB on macOS arm64; Windows binary size hasn't been separately measured). Pass-Doptimize=Debugfor a debug build, or-Doptimize=ReleaseSmallto trade some speed for an even smaller library.To target a double-precision Godot build, add
-Dprecision=double(default isfloat). The precision must match the Godot the extension loads into; a mismatch is a memory-layout error, not a graceful failure. -
Run the core unit tests (no Godot needed):
zig build testLinux can additionally run the standalone decode/seek/reopen harness under Valgrind. Only definite and indirect leaks fail the step; install
valgrindfirst and explicitly enable software decoding only on machines without a hardware plugin:GODOT_NATIVE_VIDEO_ALLOW_SOFTWARE=1 zig build leak-check -
Run the demo project (builds the extension, launches Godot, and opens the interactive playback UI against
project/synthetic.mp4or a clip passed after--):zig build runFor 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:
zig build smokeTwo additional integration steps exercise lifecycle teardown and load the release-style addon directory from a generated clean Godot project:
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 ifGODOT_PATHor-Dgodot-path=/path/to/Godotisn'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 whatzig build testexercises.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 coreBackendinterface.decode_smoke.zigis a standalone CLI harness that pumps the backend without Godot, for isolating decode-side bugs.src/mf/— the Media Foundation backend (Windows): a synchronousIMFSourceReaderdriven through the coreBackendinterface, the dedicated MTAComExecutorthread, and the hand-written D3D11/D3D12/DXGI bindings undersrc/mf/win/.decode_smoke.zigmirrors 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 glue:NativeVideoStream/NativeVideoStreamPlaybackregistration, the present pipeline that runs the NV12→RGB compute pass, and the surface importers it selects between — Metal on macOS; on Windows, the pureimporter_selectorfunction 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.tscnare 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.gdextensiontemplate packaged into release zips (see Releases below). Not used byzig build; the dev harness inproject/has its ownnative_video.gdextensionpointing at local build output.
For the historical record of how this implementation was chosen — benchmarks and rationale — see EVALUATION.md.
License
MIT — see LICENSE.