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

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, 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:

    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:

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

    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:

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

    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:

    zig build smoke
    

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

License

MIT — see LICENSE.