Title

Hardware-Decoded Video Textures in Rust: NVIDIA NVDEC, CUDA, and wgpu-hal

Decode MP4 video with NVIDIA NVDEC, convert it with CUDA into a shared D3D12 texture, and sample it in Rust's wgpu through wgpu-hal, with no CPU readback.

Logan RiosPublished About 55 min read
Article
A cube rendered with wgpu, with Big Buck Bunny playing on its faces: the bunny stands in a meadow under a blue sky on the front face, and the same frame shows on the top and left faces.
The finished demo: NVDEC decodes the video, CUDA converts each frame into a shared D3D12 texture, and wgpu samples it on a spinning cube. — Big Buck Bunny © Blender Foundation, CC BY 3.0

I wanted a video playing on a 3D object in a Rust program. That sounds like a solved problem, and in one sense it is: decode a frame with any library, copy its pixels into memory, upload them to the GPU as a texture, repeat 24 times a second. It works. But every frame makes a round trip the GPU didn't need: the decoder (often the GPU's own video hardware) produces pixels in GPU memory, the program copies them down to system memory, and then uploads them straight back up so the renderer can use them.

This post is about building the other version, where that round trip doesn't exist. NVIDIA's hardware decoder writes each frame into GPU memory, a CUDA kernel converts it into an RGBA texture that lives in Direct3D 12, and wgpu, Rust's graphics API, samples that same texture. The decoded pixels never leave the GPU.

The rotating cube above is where it ends up: a Windows program, about 1,100 lines of Rust, C++ and CUDA, playing an MP4 on all six faces. The cube itself is ordinary wgpu, and you could replace it with anything. The work, and this post, is about getting the frames there. I'll go through it in the order I built it, explaining at each step what I needed, why I did it this way, and what the code is doing.

The full project is on GitHub at Lrios4403/nvdec-wgpu-cube. Every code block below is a verbatim excerpt of it, and each block's file name links to those exact lines.

The short version

  • Media Foundation reads the MP4, NVDEC decodes it. Windows' built-in Source Reader hands over the compressed H.264 or HEVC packets as they are in the file, and NVIDIA's Video Codec SDK decodes them on the GPU's dedicated video engine. No FFmpeg.
  • CUDA converts NV12 to RGBA in place. The decoder outputs a YUV layout called NV12, which a shader can't sample directly. A small CUDA kernel converts it, writing straight into a D3D12 texture that CUDA has imported as external memory.
  • wgpu-hal wraps that texture. Two unsafe calls, texture_from_raw and create_texture_from_hal, turn the existing ID3D12Resource into an ordinary wgpu::Texture that you can bind and sample in WGSL like any other.
  • It's a working example, not a player. Synchronization is a blocking wait each frame, there's no audio, and it's Windows, D3D12 and NVIDIA only.

Part 1: The plan, and why it takes five technologies

Before any code, here's the shape of the thing. The problem splits into stages, and each stage turned out to need a different piece of technology, because no single API does the whole job. That's the main reason this program looks complicated: it isn't that any one part is hard, it's that five parts have to agree with each other.

From MP4 file to cube

Only compressed bytes cross host memory.

From file to cube: Media Foundation reads compressed packets from the MP4 and NVIDIA's parser splits them into pictures on the CPU. NVDEC decodes them into NV12 in GPU memory, a CUDA kernel converts each frame to RGBA in one of three shared D3D12 textures, and wgpu imports those textures through wgpu-hal and samples them on the cube.CPU: compressed bytes onlyGPU memory: decoded framespicturesMP4 fileH.264 or HEVCMedia Foundationpackets + timestampsNVDEC parserruns on the CPUNVDECdecodes to NV12CUDA kernelNV12 → RGBAD3D12 textures3 shared slotswgpu-hal importthe same resourceswgpu + WGSLsamples, draws the cube
From file to cube: Media Foundation reads compressed packets from the MP4 and NVIDIA's parser splits them into pictures on the CPU. NVDEC decodes them into NV12 in GPU memory, a CUDA kernel converts each frame to RGBA in one of three shared D3D12 textures, and wgpu imports those textures through wgpu-hal and samples them on the cube.CPU: compressed bytesGPU: decoded framespicturesMP4 fileH.264 or HEVCMedia Foundationpackets + timestampsNVDEC parserruns on the CPUNVDECdecodes to NV12CUDA kernelNV12 → RGBAD3D12 textures3 shared slotswgpu-hal importthe same resourceswgpu + WGSLsamples, draws the cube
Show as text
  1. Media Foundation reads the MP4 and hands over compressed H.264 or HEVC packets with their timestamps. Nothing is decoded yet.
  2. NVIDIA's parser, running on the CPU, splits the packets into pictures and calls back when each one can be decoded and when it's due for display.
  3. NVDEC, the GPU's video decoding engine, decodes each picture into NV12 in GPU memory.
  4. A CUDA kernel converts the NV12 frame to RGBA, writing straight into one of three D3D12 textures.
  5. wgpu wraps those same three textures through wgpu-hal, and the WGSL shader samples the current one onto the cube.
  6. Only compressed bytes pass through host memory. Decoded frames stay in GPU memory from NVDEC to the screen.

Reading the diagram top to bottom, this is what has to happen to show one frame:

  1. Get the compressed video out of the file. An MP4 is a container, not a video. Something has to find the video track inside it and hand over its packets. I use Windows Media Foundation for this.
  2. Turn packets into pictures. That's the decoder's job. I use NVDEC, the video decoding engine on NVIDIA GPUs, through NVIDIA's Video Codec SDK. It leaves each decoded frame in GPU memory.
  3. Convert the frame into something a shader can use. Decoders output YUV, not RGB. A CUDA kernel does the conversion, because the decoded frame is already visible to CUDA.
  4. Put the result where wgpu can see it. This is the step that makes the whole thing work: the kernel writes into a Direct3D 12 texture that both CUDA and wgpu can use, so there's one allocation and no copy.
  5. Draw it. wgpu samples the texture in a WGSL shader. From here on it's ordinary graphics code.

Notice what this is and isn't. It's a no-readback pipeline: the GPU still does real work between decoder and screen (NVDEC writes NV12, the kernel writes RGBA, the renderer samples it), and the demuxing and parsing still run on the CPU. What it avoids is the trip through system memory, and with it the CPU copies and the PCIe transfers that would otherwise happen every frame.

The rest of Part 1 explains each stage in a bit more depth, so the code in Parts 2 to 4 has somewhere to hang.

MP4 is the box, H.264 is what's in it

An MP4 file is a container: it holds one or more tracks (video, audio, subtitles) plus the timing and the settings needed to play them. The video track is compressed with a codec, here H.264 or HEVC. A compressed frame isn't a picture. It's a list of instructions (predictions from other frames, motion vectors, transformed residuals) that a decoder turns back into pixels.

So playing an MP4 takes two separate steps: demuxing (pulling the compressed video samples out of the container, with their timestamps) and decoding (turning those samples into images). NVDEC only does the second. It has never heard of MP4. That's why the first thing I need is a demuxer, and why Media Foundation is in the picture at all: it's already on every Windows machine, it reads MP4, and it can be told to hand over the packets without decoding them.

What NVDEC does, and what it needs from me

NVDEC is the fixed-function video decoder on NVIDIA GPUs. NVIDIA's NVDEC programming guide puts it plainly: “NVDEC runs completely independently of compute/graphics engine.” That's the appeal. Decoding doesn't take CUDA cores or shader time away from the renderer; it happens on separate silicon.

The Video Codec SDK exposes it through an API called NVCUVID. The thing that confused me at first is that it isn't one call that takes packets and gives back pictures. It's two objects. A parser runs on the CPU and reads the compressed bytes to find where each picture starts and ends. A decoder runs on NVDEC and decodes one picture at a time. The parser drives the decoder through callbacks: you feed the parser, and it calls you back when it has something.

The NVDEC calls this program uses
CallWhat it does
cuvidCreateVideoParserCreates the parser for one codec, with your three callbacks.
cuvidParseVideoDataFeeds the parser one packet of compressed bytes. The callbacks fire from inside this call.
cuvidDecodePictureCalled from the decode callback: kicks off decoding of one picture on NVDEC.
cuvidMapVideoFramePost-processes a decoded picture into an output surface and returns a CUDA pointer to it. Must be unmapped again.

The three callbacks are the key to the whole decode section later, so here's what each is for:

  • The sequence callback fires when the parser learns the stream's format: codec, size, chroma format, bit depth. That's the first moment you know enough to create the decoder, so that's where it gets created.
  • The decode callback fires when a complete picture is ready to decode. You call cuvidDecodePicture and NVDEC goes to work.
  • The display callback fires when a picture is ready to be shown, in display order. That matters because codecs decode B-frames out of order: a frame that depends on a later frame is decoded after it but shown before it. The parser sorts that out and the display callback is where you get the sorted result.

NV12, and why CUDA converts it

Video is stored as luma (brightness) and chroma (color difference), not RGB, because the eye is less sensitive to color detail than to brightness, so codecs store color at lower resolution. NVDEC's output here is NV12: a full-resolution plane of 8-bit Y values, followed by a half-resolution plane of interleaved U and V pairs. Every 2×2 block of pixels shares one U and one V, so a frame takes 1.5 bytes per pixel instead of RGBA's 4.

A fragment shader can't sample NV12 as if it were an RGBA texture, so something has to convert it, and I had two options. I could upload the Y and UV planes as two separate textures and do the math in the fragment shader. Or I could convert once, into RGBA, and keep the shader trivial. I chose the second, for a reason that only becomes clear once you see Step 6: the decoded frame comes back from NVDEC as a CUDA pointer. CUDA already has it. A CUDA kernel, one thread per pixel, is the shortest path from “NV12 I can read” to “RGBA a shader can sample”, and it keeps the WGSL to a single textureSample.

Why wgpu-hal: wgpu has to use memory it didn't allocate

Here's the crux of the whole project. wgpu normally owns every texture it uses: you call device.create_texture(&descriptor), wgpu allocates the memory, and wgpu tracks it from then on. In this program the texture has to already exist before wgpu sees it, because CUDA writes into it, and CUDA can't write into a texture wgpu allocated privately. I need a texture that was created outside wgpu, and then I need wgpu to adopt it.

The high-level wgpu API can't take an existing D3D12 resource. wgpu-hal can. It's the layer underneath wgpu that implements each backend (D3D12, Vulkan, Metal), and wgpu exposes it through a few unsafe functions. Device::as_hal gives me the underlying D3D12 device, so I can create resources on it. wgpu-hal's texture_from_raw wraps a resource I hand it. And Device::create_texture_from_hal registers the wrapped resource with wgpu proper. After that it's an ordinary wgpu::Texture: views, bind groups and textureSample all work as usual.

That's also why Direct3D 12 is involved at all, even though I never write a line of D3D12 rendering. wgpu's Windows backend is D3D12, and CUDA can import D3D12 resources as external memory. A D3D12 texture is the one kind of allocation both sides can use natively. So the plan is: create a D3D12 texture by hand, show it to CUDA, show the same one to wgpu.

Why a C++/CUDA bridge instead of pure Rust

Rust could call NVCUVID, CUDA and Media Foundation directly through FFI bindings, and for a library that's what I'd do. For this project I put all three in one C++ file, native/video_bridge.cu, for two practical reasons. First, I need a CUDA kernel anyway, which means nvcc is already in the build. Second, these are C APIs with lots of structs, callbacks and COM pointers, and they read most naturally in C++. The Rust side then sees only seven small functions.

What the bridge wraps
InterfaceWhy it's there
NVIDIA Video Codec SDK (NVCUVID)The parser and the NVDEC decoder
CUDA (driver and runtime APIs)The context, the imported texture memory and the color kernel
Media Foundation and Direct3D 12Reading MP4 packets, and creating the shared textures

The trade-off I'm accepting is that all the unsafe interop lives in one file, behind a C ABI that hides Media Foundation and CUDA from Rust entirely. The Rust side can't misuse what it can't see.

Part 2: Set up the project

What you need

  • Windows 11 x64 and an NVIDIA GPU with NVDEC, with a current driver. The driver provides nvcuda.dll and nvcuvid.dll, the only NVIDIA files the program loads at run time.
  • Rust stable with the MSVC toolchain, and Visual Studio 2022 Build Tools with the Windows SDK. They provide the C++ compiler nvcc uses underneath, and the D3D12 and Media Foundation headers.
  • The CUDA Toolkit, which provides nvcc and sets CUDA_PATH.
  • The NVIDIA Video Codec SDK, downloaded and extracted. It has the NVCUVID headers and nvcuvid.lib. NVIDIA doesn't let you redistribute it, so every reader has to fetch it themselves.
  • An MP4 with progressive 8-bit 4:2:0 H.264 or HEVC, which is most of them.

What I built and tested with:

Test setup
ItemVersion
GPUNVIDIA GeForce RTX 5080 Laptop GPU, Windows 11
Rust / wgpucargo 1.97.1, wgpu 30.0.1
CUDA Toolkit13.2
Video Codec SDK13.1.15
Test clipBig Buck Bunny, 320×180 H.264 (CC BY 3.0, Blender Foundation)

The project

Seven files. If you only read two, read native/video_bridge.cu (everything that talks to Media Foundation, NVDEC and CUDA) and src/video.rs (the Rust side of the bridge and the wgpu-hal import). The rest is setup and a standard wgpu cube that could be any mesh, a fullscreen quad or a material in an engine. To follow along with the files open, clone the repository:

Command
git clone https://github.com/Lrios4403/nvdec-wgpu-cube.git
cd nvdec-wgpu-cube
nvdec-wgpu-cube
  • Cargo.tomlwgpu 30.0.1 with only the DX12 backend
  • build.rscompiles the CUDA bridge with nvcc and links the SDKs
  • native/
    • video_bridge.cuMedia Foundation, NVDEC, CUDA and the shared textures
    • bitstream.hMP4 length prefixes to Annex B
    • tests/
      • bitstream_test.cppportable tests for bitstream.h
  • src/
    • main.rsthe wgpu device, bind groups and the cube
    • video.rsthe FFI, the wgpu-hal import and frame timing
    • cube.wgslvertex and fragment shaders

Step 1: Dependencies

The manifest is short, but two choices in it are deliberate.

1[package]
2name = "nvdec-wgpu-cube"
3version = "0.1.0"
4edition = "2021"
5build = "build.rs"
6
7[dependencies]
8wgpu = { version = "=30.0.1", default-features = false, features = ["std", "parking_lot", "dx12", "wgsl"] }
9winit = "0.30"
10pollster = "0.4"
11glam = "0.30"
12bytemuck = { version = "1.22", features = ["derive"] }
13windows = { version = "0.62", features = ["Win32_Graphics_Direct3D12"] }
14
15[build-dependencies]
16cc = "1.2"

wgpu is pinned to an exact version, =30.0.1, not 30. The wgpu-hal calls I make in Step 9 are the kind of thing that changes signature between releases (the number of arguments to texture_from_raw is different in older versions, for example). A pinned version means the code in this post compiles against what it was written for, and a deliberate upgrade means revisiting that one file.

Default features are off, and only dx12 and wgsl are turned back on. This isn't about binary size. The whole design depends on wgpu using D3D12, so I don't want a Vulkan backend even available to be picked by accident. std and parking_lot are just what wgpu needs to function at all.

Of the rest: windows gives me the ID3D12Resource type, which I need to hold the native texture pointer in Rust. cc is the build dependency that will drive nvcc. The others (winit, glam, bytemuck, pollster) are the usual wgpu companions for a window, matrices, vertex bytes and blocking on async setup.

Step 2: Compile CUDA from build.rs

Cargo doesn't know how to compile a .cu file, so a build script has to do it. The cc crate has a CUDA mode that runs nvcc and packages the result as a static library Cargo can link. The script also has to tell the linker about every native library the bridge uses.

1use std::{env, path::PathBuf};
2
3fn main() {
4    if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
5        panic!("Windows x64 is required (D3D12 + Media Foundation + NVIDIA NVDEC)");
6    }
7    let cuda = PathBuf::from(env::var_os("CUDA_PATH").expect("Install the NVIDIA CUDA Toolkit (CUDA_PATH)"));
8    let sdk = PathBuf::from(env::var_os("VIDEO_CODEC_SDK_DIR").expect("Set VIDEO_CODEC_SDK_DIR to the extracted NVIDIA Video Codec SDK"));
9    let nvcuvid_dir = [sdk.join("Lib/win/x64"), sdk.join("Lib/x64")]
10        .into_iter()
11        .find(|dir| dir.join("nvcuvid.lib").exists())
12        .expect("Could not locate SDK nvcuvid.lib (Lib/win/x64 or Lib/x64)");
13    let headers = sdk.join("Interface");
14    if !headers.join("nvcuvid.h").exists() || !headers.join("cuviddec.h").exists() {
15        panic!("Video Codec SDK Interface/nvcuvid.h and cuviddec.h missing");
16    }
17    println!("cargo:rerun-if-changed=native/video_bridge.cu");
18    println!("cargo:rerun-if-changed=native/bitstream.h");
19    println!("cargo:rerun-if-env-changed=VIDEO_CODEC_SDK_DIR");
20    println!("cargo:rerun-if-env-changed=CUDA_PATH");
21    // "shared" links cudart.lib instead of cc's default, cudart_static.lib, which is
22    // built against the static C runtime (LIBCMT) and clashes with Rust's /MD one
23    // (linker warning LNK4098). With CUDA 13, cudart.lib is itself linked statically
24    // and loads the driver's nvcuda.dll at run time, so no CUDA DLL ships with the exe.
25    cc::Build::new()
26        .cuda(true)
27        .cudart("shared")
28        .include(&headers)
29        .include(cuda.join("include"))
30        .file("native/video_bridge.cu")
31        .flag("-std=c++17")
32        .compile("video_bridge");
33    println!("cargo:rustc-link-search=native={}", cuda.join("lib/x64").display());
34    println!("cargo:rustc-link-search=native={}", nvcuvid_dir.display());
35    // cudart itself is linked by cc (see .cudart above)
36    for name in ["nvcuvid", "cuda", "d3d12", "dxgi", "mfplat", "mfreadwrite", "mfuuid", "ole32"] {
37        println!("cargo:rustc-link-lib={name}");
38    }
39    // CUDA 13's cuda.lib is a static driver-API loader (it opens nvcuda.dll at
40    // run time) built against the static C runtime. Bind it to the same DLL C
41    // runtime as everything else instead of linking a second CRT.
42    println!("cargo:rustc-link-arg=/NODEFAULTLIB:LIBCMT");
43}

Walking through it:

  • Lines 4–15 fail early. If this isn't Windows, or CUDA_PATH or VIDEO_CODEC_SDK_DIR is unset, or the SDK folder is missing its headers or library, the build stops with a message that says what to fix. Without these checks you get a wall of linker errors instead.
  • Lines 17–20 tell Cargo when to rerun. By default a build script reruns whenever anything in the package changes. Listing the two native files and the two environment variables means editing main.rs doesn't recompile the CUDA.
  • Lines 25–32 run nvcc on the bridge with the SDK's and CUDA's include folders, and compile it into video_bridge.lib.
  • Lines 33–37 link the native libraries: nvcuvid for the decoder API, cuda for the CUDA driver API, d3d12 and dxgi, and the three Media Foundation libraries plus ole32 for COM.

A side effect worth knowing: both cuda.lib and cudart.lib load the driver's nvcuda.dll at run time, so the finished executable needs the NVIDIA driver but no CUDA Toolkit DLLs. You can copy it to another machine with the same driver and it runs.

Part 3: Decode on the GPU

The Bridge object and its helpers

Everything the native side knows lives in one struct. I'm showing it first so that when a later excerpt reads or writes a field, you've already seen where it came from:

78struct Bridge {
79    ComPtr<IMFSourceReader> reader;
80    ComPtr<IMFMediaType> compressed_type;
81    ComPtr<ID3D12Device> d3d;
82    bool com_started = false, mf_started = false;
83    bool has_eos = false, flushed = false;
84    unsigned width = 0, height = 0;
85    unsigned decode_width = 0, decode_height = 0;
86    cudaVideoCodec codec_type = cudaVideoCodec_H264;
87    std::vector<uint8_t> sequence_header;
88    std::vector<uint8_t> bitstream;
89    NalFraming framing = NalFraming::Unknown; // Set by the first sample, kept across loops.
90    std::deque<CUVIDPARSERDISPINFO> decoded;
91    CUcontext cuda_context = nullptr;
92    CUvideoparser parser = nullptr;
93    CUvideodecoder decoder = nullptr;
94    std::array<OutputSlot, kSlots> slots;
95    std::string error;
96    int current_slot = -1;
97    int64_t first_timestamp = -1;
98    int64_t last_pts = 0;
  • Lines 79–81: the Media Foundation reader and the compressed media type (Step 3), and the D3D12 device the textures are created on (Step 7).
  • Line 82: whether COM and Media Foundation were started, so the destructor only shuts down what was actually started.
  • Line 83: the end-of-file state machine (Step 6): has_eos is set when the reader hits the end, flushed once the parser has been told.
  • Lines 84–85: the picture size from the media type, and the coded size from the sequence callback, kept separately so a mid-stream size change can be detected (Step 5).
  • Lines 86–89: the codec, the parameter sets, the scratch buffer each converted packet is built in (reused every packet, so nothing is allocated per frame), and the stream's NAL framing (Step 3).
  • Line 90: decoded, the queue of frames ready to show, in display order. The display callback appends to it (Step 5) and the per-frame loop drains it (Step 6).
  • Lines 91–94: the CUDA context (Step 4), the parser and decoder (Step 5), and the three texture slots (Step 7).
  • Lines 95–98: the first error message, which slot was written last, and the two timestamp fields that turn the file's absolute timestamps into ones relative to the first frame (Step 6).

Then the helpers. Nearly every line in the steps that follows ends in one of these, so they're worth a minute:

100bool fail(const char* message) {
101    if (error.empty()) error = message;
102    std::fprintf(stderr, "[nvdec] %s\n", message);
103    return false;
104}
105bool check(CUresult result, const char* message) {
106    if (result == CUDA_SUCCESS) return true;
107    const char* name = "unknown";
108    cuGetErrorName(result, &name);
109    error = std::string(message) + ": " + (name ? name : "unknown");
110    std::fprintf(stderr, "[nvdec] %s\n", error.c_str());
111    return false;
112}
113bool check_cuda(cudaError_t result, const char* message) {
114    if (result == cudaSuccess) return true;
115    error = std::string(message) + ": " + cudaGetErrorString(result);
116    std::fprintf(stderr, "[nvdec] %s\n", error.c_str());
117    return false;
118}
119bool push() { return check(cuCtxPushCurrent(cuda_context), "cuCtxPushCurrent"); }
120void pop() { CUcontext old = nullptr; cuCtxPopCurrent(&old); }

Three ways to report an error, because the file talks to three kinds of API. fail takes a plain message, and is what the Media Foundation and D3D12 code uses after checking an HRESULT. check takes a CUDA driver-API result (CUresult, from functions starting with cu, which is what NVCUVID returns too) and looks up its name. check_cuda takes a CUDA runtime-API result (cudaError_t, from functions starting with cuda) and looks up its description. All three print to stderr immediately and return false, which is why call sites throughout the file read as if (!check(...)) return false;. Line 101 is a small but useful rule: only the first error is kept in error, because the first one is the cause and the ones after it are usually consequences. That first message is what Rust gets back through nv_error (Step 8).

push and pop (lines 119–120) make the bridge's CUDA context current on the thread and un-make it. Every method that does CUDA work is wrapped in them. Step 4 explains why, once the context exists; for now, read push() as “about to do CUDA work” and pop() as “done”.

Step 3: Read compressed packets

The goal of this step: get a stream of compressed packets, each with a timestamp, out of an MP4 file, and get them in a form NVIDIA's parser accepts. Media Foundation's Source Reader is the API for reading samples from a media file, and the important thing about it is that it can either decode for you or hand over the stream as it is in the file.

First, the boilerplate: COM and Media Foundation have to be started, and the file opened.

139HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
140// winit may already have created an STA on the Windows UI thread.
141if (hr == RPC_E_CHANGED_MODE) hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
142if (FAILED(hr)) return fail("CoInitializeEx failed");
143com_started = true;
144hr = MFStartup(MF_VERSION);
145if (FAILED(hr)) return fail("MFStartup failed");
146mf_started = true;
147const std::wstring wide = utf8_to_utf16(path);
148if (wide.empty()) return fail("Invalid UTF-8 video path");
149hr = MFCreateSourceReaderFromURL(wide.c_str(), nullptr, reader.GetAddressOf());
150if (FAILED(hr)) return fail("MFCreateSourceReaderFromURL failed (MP4 file required)");

Line 147 calls the one helper that isn't on the Bridge. Rust hands the file path over as a UTF-8 C string (Step 8), and Media Foundation wants a UTF-16 wide string:

56static std::wstring utf8_to_utf16(const char* src) {
57    int chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, src, -1, nullptr, 0);
58    if (chars <= 0) return {};
59    std::wstring out((size_t)chars, 0);
60    if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, src, -1, out.data(), chars)) return {};
61    out.pop_back();
62    return out;
63}

One call to measure, one to convert, and the trailing NUL that MultiByteToWideChar counts is dropped. MB_ERR_INVALID_CHARS makes malformed UTF-8 an error (an empty result, which line 148 turns into a fail) rather than a garbled path.

Now the part that matters: configuring the reader so it does not decode.

151// Disable all other tracks; preserve the *native compressed* video type.
152hr = reader->SetStreamSelection(kAllStreams, FALSE);
153if (FAILED(hr)) return fail("Disable other streams failed");
154hr = reader->SetStreamSelection(kVideoStream, TRUE);
155if (FAILED(hr)) return fail("Select video stream failed");
156hr = reader->GetNativeMediaType(kVideoStream, 0, compressed_type.GetAddressOf());
157if (FAILED(hr)) return fail("Cannot get native compressed video type");
158GUID subtype = {};
159if (FAILED(compressed_type->GetGUID(MF_MT_SUBTYPE, &subtype))) return fail("No video subtype");
160if (subtype == MFVideoFormat_H264) codec_type = cudaVideoCodec_H264;
161else if (subtype == MFVideoFormat_HEVC) codec_type = cudaVideoCodec_HEVC;
162else return fail("Only native compressed H.264/HEVC MP4 is supported");
163hr = reader->SetCurrentMediaType(kVideoStream, nullptr, compressed_type.Get());
164if (FAILED(hr)) return fail("Could not keep native compressed video output");

Three things happen here, and the order matters:

  1. Lines 152–155: select only the video stream. The reader starts with every stream selected. I deselect all of them and reselect the first video stream, so ReadSample never hands me an audio packet.
  2. Lines 156–162: find out what codec it is. The stream's native media type is the type it has in the file, before any decoding. Its subtype GUID tells me whether it's H.264 or HEVC, which I'll need to create the right parser. Anything else is rejected, since NVDEC would need different handling for it.
  3. Lines 163–164: set that native type as the output type. This is the line that makes Media Foundation a demuxer instead of a decoder. If I asked for an RGB or NV12 output type instead, the reader would insert its own software or hardware decoder, and I'd be back to copying pixels around.

Two more pieces of information come from the media type: the frame size, and the codec's parameter sets.

165if (FAILED(MFGetAttributeSize(compressed_type.Get(), MF_MT_FRAME_SIZE, &width, &height)) ||
166    !width || !height || (width & 1) || (height & 1)) {
167    return fail("Missing/unsupported video frame size (even dimensions required)");
168}
169UINT32 header_size = 0;
170if (SUCCEEDED(compressed_type->GetBlobSize(MF_MT_MPEG_SEQUENCE_HEADER, &header_size)) && header_size) {
171    sequence_header.resize(header_size);
172    UINT32 got = 0;
173    if (FAILED(compressed_type->GetBlob(MF_MT_MPEG_SEQUENCE_HEADER,
174            sequence_header.data(), header_size, &got))) return fail("Sequence-header read failed");
175    sequence_header.resize(got);
176    // MF sequence headers are Annex B SPS/PPS or VPS/SPS/PPS, NOT avcC/hvcC.
177    if (!annexb_prefix(sequence_header.data(), sequence_header.size()))
178        return fail("Expected Annex B MF_MT_MPEG_SEQUENCE_HEADER");
179}

The frame size is needed to create the textures in Step 7, before any frame is decoded. The sequence header is something I didn't know I needed until the parser refused to produce frames without it. H.264 and HEVC streams start with parameter sets (the SPS and PPS; HEVC adds a VPS) that describe how the rest of the stream is coded. In an MP4, they're stored in the track's header rather than in the first packet. Media Foundation exposes them as MF_MT_MPEG_SEQUENCE_HEADER, and I feed them to the parser before any packet, in Step 5. Line 177 checks that they're in Annex B form, which brings me to the wrinkle.

NAL units and how they're separated. An H.264 or HEVC stream is a sequence of NAL units (a header byte or two, then a slice of a picture or a parameter set). There are two ways to mark where one ends and the next begins. A raw stream uses Annex B framing: each NAL unit starts with a start code, 00 00 01 or 00 00 00 01. Inside an MP4, NAL units are usually length-prefixed instead: four bytes giving the size, then the data. NVIDIA's own samples demux with FFmpeg and run its mp4toannexb filter before parsing, which converts the second form into the first. Media Foundation's MP4 source happened to deliver Annex B for my test clips, but I didn't want to depend on that, so the bridge accepts both and converts if needed. That logic is in native/bitstream.h:

23// True when nal_len_bytes-byte lengths tile the whole sample exactly, every
24// NAL unit is non-empty, and every NAL header has its forbidden_zero_bit (the
25// top bit, in both H.264 and HEVC) clear.
26inline bool length_prefixed(const uint8_t* p, size_t n, unsigned nal_len_bytes) {
27    if (!n || !(nal_len_bytes == 1 || nal_len_bytes == 2 || nal_len_bytes == 4)) return false;
28    while (n) {
29        if (n < nal_len_bytes) return false;
30        size_t count = 0;
31        for (unsigned i = 0; i < nal_len_bytes; ++i) count = (count << 8) | p[i];
32        p += nal_len_bytes;
33        n -= nal_len_bytes;
34        if (count == 0 || count > n || (p[0] & 0x80)) return false;
35        p += count;
36        n -= count;
37    }
38    return true;
39}
40
41// Which framing one sample uses. The length check wins: a 4-byte length of 1
42// or 256-511 starts with 00 00 00 01 / 00 00 01 xx, which looks exactly like a
43// start code, while real Annex B data practically never tiles as lengths.
44inline NalFraming detect_framing(const uint8_t* p, size_t n, unsigned nal_len_bytes = 4) {
45    if (length_prefixed(p, n, nal_len_bytes)) return NalFraming::LengthPrefixed;
46    if (annexb_prefix(p, n)) return NalFraming::AnnexB;
47    return NalFraming::Unknown;
48}

With that in place, reading one packet looks like this:

369bool read_packet() {
370    DWORD flags = 0;
371    LONGLONG timestamp = 0;
372    ComPtr<IMFSample> sample;
373    const HRESULT hr = reader->ReadSample(kVideoStream, 0,
374        nullptr, &flags, &timestamp, sample.GetAddressOf());
375    if (FAILED(hr)) return fail("IMFSourceReader::ReadSample failed");
376    if (flags & (MF_SOURCE_READERF_NATIVEMEDIATYPECHANGED | MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED))
377        return fail("Dynamic compressed media-type changes unsupported");
378    if (flags & MF_SOURCE_READERF_ENDOFSTREAM) { has_eos = true; return true; }
379    if (!sample) return true; // stream tick / no sample
380    ComPtr<IMFMediaBuffer> buf;
381    if (FAILED(sample->ConvertToContiguousBuffer(buf.GetAddressOf()))) return fail("Cannot obtain compressed sample");
382    BYTE* bytes = nullptr;
383    DWORD count = 0;
384    if (FAILED(buf->Lock(&bytes, nullptr, &count))) return fail("Compressed packet Lock failed");

ReadSample is synchronous: it returns the next sample of the video stream, or a flag. The flags on lines 376–379 cover the cases that aren't a packet: the stream changed format mid-file (rejected; handling it would mean recreating the decoder), the end of the stream (remembered as has_eos; Step 6 uses it), or a gap with no sample. A real sample comes with a timestamp in 100-nanosecond units, which I keep because NVDEC's packet timestamps use the same unit, and it comes as a buffer I have to lock to read.

385// MF's MPEG-4 source typically presents Annex B, but handle MP4 length-prefixed packets too.
386// The first sample decides which; every later sample must match it.
387const NalFraming before = framing;
388const bool converted = annexb_sample(bytes, count, bitstream, framing);
389buf->Unlock();
390if (!converted) {
391    return fail(before == NalFraming::AnnexB ? "Invalid compressed NAL units (stream started as Annex B)"
392        : before == NalFraming::LengthPrefixed ? "Invalid compressed NAL units (stream started with four-byte lengths)"
393        : "Invalid compressed NAL units (expected Annex B or four-byte lengths)");
394}
395if (before == NalFraming::Unknown) {
396    std::printf("[mf] NAL framing: %s\n", framing == NalFraming::AnnexB ? "Annex B" : "four-byte lengths");
397    std::fflush(stdout);
398}
399if (bitstream.size() > UINT32_MAX) return fail("Compressed packet too large");
400CUVIDSOURCEDATAPACKET pkt = {};
401pkt.flags = CUVID_PKT_TIMESTAMP;
402pkt.timestamp = timestamp; // MF sample time and CUVID timestamps both use 100ns ticks.
403pkt.payload = bitstream.data();
404pkt.payload_size = (unsigned long)bitstream.size();
405if (!push()) return false;
406const bool ok = check(cuvidParseVideoData(parser, &pkt), "cuvidParseVideoData(packet)");
407pop();
408return ok;

Lines 387–398 run the framing logic from above and, the first time, print which form the file uses. Lines 400–406 build the NVDEC packet and feed it to the parser: the (possibly converted) bytes, the timestamp, and a flag saying the timestamp is valid. The push() and pop() around cuvidParseVideoData are about CUDA contexts, which Step 4 explains. The point of this function is that by the time it returns, the parser has seen one more packet, and may have fired any of its three callbacks along the way.

bitstream.h has no Windows or CUDA dependencies, which is deliberate: it means the framing logic can be unit-tested on any machine, including the two cases that used to be misread. The test is a plain C++ file:

Command
clang++ -std=c++17 -Wall -Wextra -pedantic native/tests/bitstream_test.cpp -o bitstream_test
./bitstream_test

Step 4: Make CUDA use the GPU wgpu uses

This step exists because of laptops. Mine has an Intel iGPU next to the RTX, and CUDA and wgpu each pick a device on their own. If wgpu renders on one GPU and CUDA writes textures on another, the shared texture in Step 7 means nothing: memory on one GPU isn't visible to the other. So before creating anything, the bridge has to find the CUDA device that is the GPU wgpu chose.

The Rust side does half of this. In main.rs I ask wgpu for a high-performance adapter and refuse anything that isn't NVIDIA:

84let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
85desc.backends = wgpu::Backends::DX12;
86
87let instance = wgpu::Instance::new(desc);
88let surface = instance.create_surface(window.clone())?;
89let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
90    power_preference: wgpu::PowerPreference::HighPerformance,
91    compatible_surface: Some(&surface),
92    force_fallback_adapter: false,
93    apply_limit_buckets: false,
94}).await?;
95let adapter_info = adapter.get_info();
96println!("wgpu adapter: {} ({:?})", adapter_info.name, adapter_info.backend);
97if adapter_info.vendor != 0x10DE {
98    return Err("This sample requires wgpu to use an NVIDIA GPU (vendor 0x10DE)".into());
99}

That guarantees wgpu is on an NVIDIA GPU. The bridge then has to find the matching CUDA device. The thing that links them is the LUID, a locally unique ID that Windows assigns to each graphics adapter. D3D12 can report the LUID of the adapter a device was created on, and CUDA can report the LUID of each device it sees. Match them, and you have the same physical GPU on both sides:

186bool init_cuda(ID3D12Device* dev) {
187    if (!dev) return fail("Missing wgpu D3D12 device");
188    d3d = dev;
189    if (!check(cuInit(0), "cuInit")) return false;
190    int count = 0;
191    if (!check(cuDeviceGetCount(&count), "cuDeviceGetCount")) return false;
192    const LUID adapter_luid = dev->GetAdapterLuid();
193    CUdevice match = -1;
194    for (int i = 0; i < count; ++i) {
195        CUdevice candidate = 0;
196        char cuda_luid[8] = {};
197        unsigned node_mask = 0;
198        if (cuDeviceGet(&candidate, i) == CUDA_SUCCESS &&
199            cuDeviceGetLuid(cuda_luid, &node_mask, candidate) == CUDA_SUCCESS &&
200            std::memcmp(cuda_luid, &adapter_luid, sizeof(cuda_luid)) == 0) {
201            match = candidate;
202            break;
203        }
204    }
205    if (match < 0) return fail("CUDA device LUID doesn't match the wgpu D3D12 NVIDIA adapter");
206    if (!check(cuCtxCreate(
207        &cuda_context,
208        nullptr,
209        0,
210        match
211    ), "cuCtxCreate")) return false;
212    // cuCtxCreate makes this context current; pop so every call explicitly pushes it.
213    pop();

Lines 192–205 are the search: ask the D3D12 device for its adapter LUID, then walk CUDA's device list comparing LUIDs byte for byte. Line 206 creates a CUDA context on the match. A context is CUDA's per-device state, and every CUDA call acts on whichever context is “current” on the calling thread.

Lines 212–214 are a small discipline I adopted after getting bitten. cuCtxCreate makes the new context current on the thread, implicitly. I pop it straight back off, and from then on every function that does CUDA work pushes the context first and pops it after, with the push() and pop() helpers from the Bridge section. That way nothing depends on which context happens to be current on the thread, which matters because winit, wgpu and the decoder callbacks all share that thread. You'll see the same pair around every CUDA call from here on: in read_packet, in next, and in the destructor.

Step 5: The parser and the decoder

Now the NVDEC objects. Recall the shape from Part 1: a parser that runs on the CPU and a decoder that runs on the hardware, with the parser driving the decoder through callbacks. The order of creation is forced on me. The parser can be created up front, because all it needs is the codec. The decoder can't, because its settings (picture size, how many surfaces to allocate) depend on the stream, and the parser only learns those from the sequence header. So: create the parser, feed it the sequence header, and create the decoder from inside the sequence callback.

325bool create_parser() {
326    if (parser) { if (!check(cuvidDestroyVideoParser(parser), "cuvidDestroyVideoParser")) return false; parser = nullptr; }
327    if (decoder) { if (!check(cuvidDestroyDecoder(decoder), "cuvidDestroyDecoder")) return false; decoder = nullptr; }
328    decode_width = 0; decode_height = 0;
329    CUVIDPARSERPARAMS parameters = {};
330    parameters.CodecType = codec_type;
331    parameters.ulMaxNumDecodeSurfaces = 20; // Sequence callback can override.
332    parameters.ulMaxDisplayDelay = 2; // Frames the parser may hold before the display callback (0 = none).
333    parameters.pUserData = this;
334    parameters.pfnSequenceCallback = &Bridge::sequence_cb;
335    parameters.pfnDecodePicture = &Bridge::decode_cb;
336    parameters.pfnDisplayPicture = &Bridge::display_cb;
337    if (!check(cuvidCreateVideoParser(&parser, &parameters), "cuvidCreateVideoParser")) return false;
338    if (!sequence_header.empty()) {
339        CUVIDSOURCEDATAPACKET pkt = {};
340        pkt.payload = sequence_header.data();
341        pkt.payload_size = (unsigned long)sequence_header.size();
342        if (!check(cuvidParseVideoData(parser, &pkt), "cuvidParseVideoData(sequence header)")) return false;
343    }
344    return true;
345}

Lines 326–328 tear down any existing parser and decoder first, because this function is also what the bridge calls when the clip loops (Step 6). Lines 329–337 create the parser. The three callbacks and pUserData are how C APIs do methods: the parser calls a plain function and passes back whatever pointer I gave it, which is the Bridge itself. ulMaxDisplayDelay is the guide's “max display callback delay”: how many decoded frames the parser may hold before handing one to the display callback (0 means none). The reordering of B-frames is the parser's job either way. Lines 338–343 feed the sequence header from Step 3, which is what triggers the sequence callback for the first time.

The callbacks themselves are static functions that forward to the bridge:

264static int CUDAAPI sequence_cb(void* user, CUVIDEOFORMAT* fmt) {
265    return static_cast<Bridge*>(user)->on_sequence(fmt);
266}
267static int CUDAAPI decode_cb(void* user, CUVIDPICPARAMS* pic) {
268    auto* b = static_cast<Bridge*>(user);
269    return b->decoder && b->check(cuvidDecodePicture(b->decoder, pic), "cuvidDecodePicture") ? 1 : 0;
270}
271static int CUDAAPI display_cb(void* user, CUVIDPARSERDISPINFO* info) {
272    auto* b = static_cast<Bridge*>(user);
273    if (!info) return 1;
274    if (b->decoded.size() >= 64) { b->fail("Decoder display queue overflow"); return 0; }
275    b->decoded.push_back(*info); // Parser delivers display/reordered order.
276    return 1;
277}

The decode callback (lines 267–270) is one line of work: hand the picture to cuvidDecodePicture. The display callback (lines 271–277) doesn't display anything. It just appends the frame's info to the decoded queue from the Bridge section, in display order, and refuses if the queue has somehow grown to 64 entries, which would mean frames aren't being consumed. The per-frame loop in Step 6 drains that queue. Keeping the callback this small is deliberate: it runs from inside cuvidParseVideoData, and I don't want to map frames or run kernels from there.

The sequence callback is the one with real decisions in it. First, what the bridge refuses to handle:

278int on_sequence(CUVIDEOFORMAT* f) {
279    if (!f || f->codec != codec_type || f->chroma_format != cudaVideoChromaFormat_420 ||
280        f->bit_depth_luma_minus8 != 0 || !f->progressive_sequence) {
281        fail("Only progressive 8-bit 4:2:0 video is supported (NV12)"); return 0;
282    }
283    if (decoder) {
284        if (decode_width != f->coded_width || decode_height != f->coded_height) {
285            fail("Midstream resolution changes are not supported"); return 0;
286        }
287        return std::max(
288            2u,
289            static_cast<unsigned int>(f->min_num_decode_surfaces)
290        );
291    }

Lines 279–282 limit the program to progressive 8-bit 4:2:0 video. That isn't an NVDEC limit; it's mine. The color kernel in Step 7 assumes NV12, and 10-bit video (P010) or 4:2:2 would need a different output format and a different kernel. Rejecting them with a clear message beats producing garbage. Lines 283–291 handle the case where the sequence callback fires again mid-stream: if the size changed, I give up (handling it would mean recreating the decoder and the textures), and otherwise I just return the surface count.

292CUVIDDECODECAPS caps = {};
293caps.eCodecType = f->codec;
294caps.eChromaFormat = f->chroma_format;
295caps.nBitDepthMinus8 = f->bit_depth_luma_minus8;
296if (!check(cuvidGetDecoderCaps(&caps), "cuvidGetDecoderCaps") || !caps.bIsSupported) {
297    fail("Codec/profile not supported by NVIDIA hardware"); return 0;
298}
299decode_width = f->coded_width;
300decode_height = f->coded_height;
301CUVIDDECODECREATEINFO create = {};
302create.CodecType = f->codec;
303create.ChromaFormat = f->chroma_format;
304create.OutputFormat = cudaVideoSurfaceFormat_NV12;
305create.DeinterlaceMode = cudaVideoDeinterlaceMode_Weave;
306create.ulCreationFlags = cudaVideoCreate_PreferCUVID;
307create.ulNumDecodeSurfaces = std::max(
308    2u,
309    static_cast<unsigned int>(f->min_num_decode_surfaces)
310);
311create.ulNumOutputSurfaces = 2;
312create.ulWidth = f->coded_width;
313create.ulHeight = f->coded_height;
314create.ulMaxWidth = f->coded_width;
315create.ulMaxHeight = f->coded_height;
316create.display_area.left = f->display_area.left;
317create.display_area.top = f->display_area.top;
318create.display_area.right = f->display_area.right;
319create.display_area.bottom = f->display_area.bottom;
320create.ulTargetWidth = width;
321create.ulTargetHeight = height;
322if (!check(cuvidCreateDecoder(&decoder, &create), "cuvidCreateDecoder")) return 0;
323return (int)create.ulNumDecodeSurfaces;

Lines 292–298 ask the driver whether this GPU's NVDEC can decode this codec at this chroma format and bit depth. Older GPUs lack HEVC, for example, and it's better to learn that here than from a cryptic failure later. Lines 301–322 fill in the decoder settings. A few of them are worth understanding:

  • OutputFormat = NV12 is the format cuvidMapVideoFrame will produce, which is what the kernel expects.
  • ulNumDecodeSurfaces is how many pictures NVDEC can have in flight. Codecs reference earlier frames, so the decoder has to keep several around. min_num_decode_surfaces is the stream's own minimum, and NVIDIA's guide recommends using it to keep memory use down. Returning that count from the callback (line 323) tells the parser to use it too.
  • ulNumOutputSurfaces = 2 is a separate pool: the surfaces that mapped frames go into. Two is enough here because I map one frame at a time (Step 6).
  • The two sizes. ulWidth and ulHeight are the coded size, which can be larger than the picture because codecs store frames in whole blocks (16×16 macroblocks in H.264) and crop the rest. display_area and ulTargetWidth/ulTargetHeight describe the picture I actually want, and NVDEC applies them in its post-processing when a frame is mapped. That's why the textures in Step 7 can be exactly the video's size.

Step 6: The per-frame loop: feed, decode, map

Everything so far was setup. This step is what runs every frame. The bridge exposes it to Rust as nv_next, and the contract is simple: “give me the next frame in display order, converted and sitting in one of the textures, and tell me which texture and what time it's due.” Internally it has two halves. The first feeds the parser until a frame is available:

410// Return 1 on success, -1 on error. out_pts: presentation time in 100ns units.
411int next(int* out_slot, int64_t* out_pts) {
412    if (!out_slot || !out_pts) { fail("Null frame result"); return -1; }
413    // Read compressed packets only until a display-order frame becomes available.
414    // Consume that frame before submitting additional packets: protects NVDEC output surfaces.
415    for (unsigned steps = 0; decoded.empty(); ++steps) {
416        if (steps > 100000) { fail("No decodable video frames"); return -1; }
417        if (!has_eos) {
418            if (!read_packet()) return -1;
419        } else if (!flushed) {
420            CUVIDSOURCEDATAPACKET eos = {};
421            eos.flags = CUVID_PKT_ENDOFSTREAM;
422            if (!push()) return -1;
423            bool ok = check(cuvidParseVideoData(parser, &eos), "cuvidParseVideoData(EOS)");
424            pop();
425            if (!ok) return -1;
426            flushed = true;
427        } else {
428            if (!rewind_video()) return -1;
429        }
430    }

The loop on lines 415–430 runs until the display queue from Step 5 has something in it. Each iteration does one of three things, depending on where in the file we are:

  1. Normally, read one more packet (line 418). That may or may not produce a frame: with B-frames, the parser often needs several packets before it can release one in display order, which is exactly why this is a loop and not a single read.
  2. At the end of the file, flush. When Media Foundation reported end-of-stream, the parser may still be holding frames it hasn't released. Sending a packet with the ENDOFSTREAM flag (lines 420–426) tells it no more data is coming, so it releases them all.
  3. Once those are drained, rewind (line 428), which is what makes the clip loop. The helper that does it is below.

Line 416 is a safety valve: a file with no decodable video would otherwise spin here forever.

352bool rewind_video() {
353    // Do not destroy the parser until *every* display callback frame was consumed.
354    PROPVARIANT seek; PropVariantInit(&seek);
355    seek.vt = VT_I8;
356    seek.hVal.QuadPart = 0;
357    HRESULT hr = reader->SetCurrentPosition(GUID_NULL, seek);
358    if (FAILED(hr)) return fail("Media Foundation failed to seek to video beginning");
359    has_eos = false;
360    flushed = false;
361    first_timestamp = -1;
362    last_pts = 0;
363    decoded.clear();
364    if (!push()) return false;
365    const bool ok = create_parser();
366    pop();
367    return ok;
368}

Lines 354–358 seek the Source Reader back to time zero. Media Foundation seeks take a PROPVARIANT holding a 64-bit time in 100 ns units, which is the ceremony on lines 354–356. Lines 359–363 reset the per-playthrough state: the end-of-file flags, the timestamp base (so the second loop also starts at zero), and the display queue, which is empty by now anyway. Line 365 recreates the parser and the decoder through create_parser from Step 5, because a parser can't be rewound; it has to see the sequence header again from the start. The comment on line 353 is why the loop above drains the queue first: the frames in it live in the old decoder's surfaces, and recreating the decoder would destroy them. Notice what is not reset: framing. The file's NAL framing doesn't change between loops, so the first sample's decision stands.

The second half takes the frame at the front of the queue and maps it:

431CUVIDPARSERDISPINFO display = decoded.front();
432decoded.pop_front();
433const int slot_index = (current_slot + 1) % kSlots;
434if (!push()) return -1;
435CUVIDPROCPARAMS process = {};
436process.progressive_frame = display.progressive_frame;
437process.top_field_first = display.top_field_first;
438process.second_field = 0;
439process.unpaired_field = display.repeat_first_field < 0;
440CUdeviceptr device_frame = 0;
441unsigned pitch = 0;
442bool ok = check(cuvidMapVideoFrame(decoder, display.picture_index,
443    &device_frame, &pitch, &process), "cuvidMapVideoFrame");

Line 433 picks which of the three textures this frame will go into: the slot after the current one, wrapping around. Lines 435–443 map the frame. cuvidMapVideoFrame is where NVDEC's post-processing runs: it takes the decoded picture (identified by picture_index), converts it to the NV12 output format, applies the cropping from Step 5, and hands back two things: a CUDA device pointer to the result, and its row pitch. The pitch is the number of bytes from one row to the next, which is usually wider than the image because of alignment, so the kernel can't assume rows are packed.

What I'm holding now is the thing the whole project revolves around: a pointer to a decoded frame, in GPU memory, that CUDA can read. The next excerpt converts it.

444if (ok) {
445    const dim3 threads(16, 16);
446    const dim3 blocks((width + 15) / 16, (height + 15) / 16);
447    nv12_to_rgba<<<blocks, threads>>>(slots[slot_index].surface,
448        reinterpret_cast<const uint8_t*>(device_frame), pitch, width, height);
449    ok = check_cuda(cudaGetLastError(), "CUDA color-convert launch") &&
450         check_cuda(cudaDeviceSynchronize(), "CUDA color-convert synchronize");
451    // Unmap only once the CUDA kernel has finished accessing NVDEC memory.
452    const bool unmapped = check(cuvidUnmapVideoFrame(decoder, device_frame), "cuvidUnmapVideoFrame");
453    ok = unmapped && ok;
454}
455pop();
456if (!ok) return -1;
457current_slot = slot_index;
458if (first_timestamp < 0) first_timestamp = display.timestamp;
459last_pts = std::max<int64_t>(0, display.timestamp - first_timestamp);
460*out_slot = slot_index;
461*out_pts = last_pts;
462return 1;

Lines 445–448 launch the kernel: one thread per pixel, in 16×16 blocks, reading the NV12 frame and writing RGBA into the chosen slot's surface (the kernel and the surface are Step 7). Lines 449–450 wait for it to finish. Then line 452 unmaps the frame.

Lines 457–462 record the result: which slot has the frame, and its timestamp made relative to the first frame, so playback starts at zero no matter what the file's first timestamp is.

Step 7: Three textures that CUDA writes and wgpu reads

The kernel in Step 6 wrote into “the chosen slot's surface”. This step is where those surfaces come from, and it's the step the whole design hinges on. I need a texture that two different APIs can use. CUDA has to be able to write it, as a surface object (CUDA's name for a writable texture). wgpu has to be able to sample it. And there must be one allocation, not two with a copy between them.

One texture, two users

Each of the three slots is a single allocation that CUDA writes and wgpu samples.

One texture, two users: the bridge creates an ID3D12Resource on wgpu's D3D12 device. CUDA opens it through a shared NT handle and imports it as external memory with a surface object the kernel writes to. wgpu wraps the same resource pointer with texture_from_raw and create_texture_from_hal, and the shader samples it.CUDA writeswgpu readsCreateCommittedResourceD3D12 devicewgpu's own deviceID3D12ResourceRGBA, shared heapCreateSharedHandlean NT handle for CUDAcudaImportExternalMemoryCUDA external memoryCUDA surface objectthe kernel writes RGBAtexture_from_rawthe same COM pointercreate_texture_from_hala wgpu::TextureWGSL textureSampleone bind group per slot
One texture, two users: the bridge creates an ID3D12Resource on wgpu's D3D12 device. CUDA opens it through a shared NT handle and imports it as external memory with a surface object the kernel writes to. wgpu wraps the same resource pointer with texture_from_raw and create_texture_from_hal, and the shader samples it.CUDA writeswgpu readssame resourceD3D12 devicewgpu's own deviceID3D12ResourceRGBA, shared heapCreateSharedHandlean NT handle for CUDAcudaImportExternalMemoryCUDA external memoryCUDA surface objectthe kernel writes RGBAtexture_from_rawthe same COM pointercreate_texture_from_hala wgpu::TextureWGSL textureSampleone bind group per slot
Show as text
  1. The bridge creates an RGBA ID3D12Resource with CreateCommittedResource on wgpu's own D3D12 device, in a shared heap with simultaneous access allowed.
  2. For CUDA: CreateSharedHandle gives an NT handle, cudaImportExternalMemory imports the resource, and a CUDA surface object over it is what the kernel writes RGBA pixels into.
  3. For wgpu: the same resource pointer (no handle needed, it's the same device) goes to texture_from_raw and create_texture_from_hal, which give an ordinary wgpu::Texture. The shader samples it through a bind group.
  4. There is one allocation per slot. CUDA and wgpu read and write the same memory, so they must take turns.

The plan from Part 1 was: create the texture in D3D12, because that's the one place both sides can reach. The bridge creates it on wgpu's own D3D12 device, which the Rust side hands over in Step 9. Each texture, with the CUDA objects that will be stacked on it, is one OutputSlot, and the bridge has an array of three of them (line 94 of the Bridge struct):

64struct OutputSlot {
65    ComPtr<ID3D12Resource> texture;
66    cudaExternalMemory_t memory = nullptr;
67    cudaMipmappedArray_t mipmap = nullptr;
68    cudaSurfaceObject_t surface = 0;
69    void cleanup() {
70        if (surface) cudaDestroySurfaceObject(surface);
71        if (mipmap) cudaFreeMipmappedArray(mipmap);
72        if (memory) cudaDestroyExternalMemory(memory);
73        surface = 0; mipmap = nullptr; memory = nullptr;
74        texture.Reset();
75    }
76};

The four fields are filled in top to bottom by the code below: the D3D12 resource, then CUDA's view of its memory, then an array over that memory, then the surface object the kernel writes through. cleanup destroys them in the reverse order, because each one refers to the one before it, and resets the fields so that calling it twice is harmless. The destructor (Step 9) calls it for each slot.

Here's the creation, which runs once for each of the three slots:

216for (auto& slot : slots) {
217    D3D12_HEAP_PROPERTIES heap = {};
218    heap.Type = D3D12_HEAP_TYPE_DEFAULT;
219    D3D12_RESOURCE_DESC desc = {};
220    desc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;
221    desc.Width = width;
222    desc.Height = height;
223    desc.DepthOrArraySize = 1;
224    desc.MipLevels = 1;
225    desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
226    desc.SampleDesc.Count = 1;
227    desc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN;
228    desc.Flags = D3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESS;
229    HRESULT hr = dev->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_SHARED, &desc,
230        D3D12_RESOURCE_STATE_COMMON, nullptr, IID_PPV_ARGS(&slot.texture));
231    if (FAILED(hr)) { ok = fail("CreateCommittedResource failed"); break; }
232    HANDLE shared = nullptr;
233    hr = dev->CreateSharedHandle(slot.texture.Get(), nullptr, GENERIC_ALL, nullptr, &shared);
234    if (FAILED(hr)) { ok = fail("CreateSharedHandle failed"); break; }

Three settings on the resource make the sharing possible:

  • D3D12_HEAP_FLAG_SHARED (line 229). Without this, the allocation is private to the D3D12 device and can't be opened by anything else.
  • ALLOW_SIMULTANEOUS_ACCESS (line 228). D3D12 normally requires explicit “resource state” transitions when a texture changes use (written by one thing, then read by another). This flag opts the texture out of that system, so CUDA and wgpu can both touch it without either knowing the other's state. Step 9 relies on this.
  • R8G8B8A8_UNORM (line 225). This has to match on all three sides: it's what the kernel writes (uchar4), what CUDA's array format describes (line 245), and what wgpu is told in Step 9 (Rgba8Unorm).

Lines 232–234 then ask D3D12 for an NT handle to the resource. That's the only way CUDA can open a D3D12 resource: through a handle, not a pointer. (wgpu won't need the handle, because it's on the same device and can use the resource pointer directly.) The import into CUDA follows:

235cudaExternalMemoryHandleDesc memory_desc = {};
236memory_desc.type = cudaExternalMemoryHandleTypeD3D12Resource;
237memory_desc.handle.win32.handle = shared;
238memory_desc.size = dev->GetResourceAllocationInfo(0, 1, &desc).SizeInBytes;
239memory_desc.flags = cudaExternalMemoryDedicated;
240const cudaError_t imported = cudaImportExternalMemory(&slot.memory, &memory_desc);
241CloseHandle(shared);
242if (!check_cuda(imported, "cudaImportExternalMemory")) { ok = false; break; }
243cudaExternalMemoryMipmappedArrayDesc mapping = {};
244mapping.offset = 0;
245mapping.formatDesc = cudaCreateChannelDesc(8, 8, 8, 8, cudaChannelFormatKindUnsigned);
246mapping.extent = make_cudaExtent(width, height, 1);
247mapping.flags = cudaArraySurfaceLoadStore;
248mapping.numLevels = 1;
249if (!check_cuda(cudaExternalMemoryGetMappedMipmappedArray(&slot.mipmap, slot.memory, &mapping),
250                "cudaExternalMemoryGetMappedMipmappedArray")) { ok = false; break; }
251cudaArray_t array = nullptr;
252if (!check_cuda(cudaGetMipmappedArrayLevel(&array, slot.mipmap, 0),
253                "cudaGetMipmappedArrayLevel")) { ok = false; break; }
254cudaResourceDesc resource = {};
255resource.resType = cudaResourceTypeArray;
256resource.res.array.array = array;
257if (!check_cuda(cudaCreateSurfaceObject(&slot.surface, &resource),
258                "cudaCreateSurfaceObject")) { ok = false; break; }

This is three CUDA objects stacked on top of each other, and each exists for a reason:

  1. External memory (lines 235–242). cudaImportExternalMemory takes the handle and the allocation's size (asked from D3D12, since a texture's allocation is bigger than width × height × 4) and gives CUDA a reference to the same GPU memory. The handle can be closed as soon as the import succeeds; CUDA keeps its own reference.
  2. A mipmapped array over it (lines 243–253). External memory is just bytes to CUDA. To treat it as a 2D image with a known format, I map a CUDA array over it, describing the format (four 8-bit unsigned channels), the size, and that it will be written through a surface. One mip level, since it's a video frame.
  3. A surface object (lines 254–258). This is the handle the kernel actually writes through, with surf2Dwrite. It's what gets passed to the kernel launch in Step 6.

And the kernel that writes into it:

32// Simple SDR BT.709 *limited-range* matrix; HDR/P010 and metadata conversion
33// require a different kernel and are rejected in on_sequence for this example.
34__device__ __forceinline__ unsigned char byte_clamp(float v) {
35    return (unsigned char)fminf(255.0f, fmaxf(0.0f, v));
36}
37__global__ void nv12_to_rgba(cudaSurfaceObject_t dst, const uint8_t* nv12,
38                             unsigned pitch, unsigned width, unsigned height) {
39    const unsigned x = blockIdx.x * blockDim.x + threadIdx.x;
40    const unsigned y = blockIdx.y * blockDim.y + threadIdx.y;
41    if (x >= width || y >= height) return;
42    const uint8_t* y_plane = nv12;
43    const uint8_t* uv_plane = nv12 + (size_t)pitch * height;
44    const float Y = (float)y_plane[(size_t)y * pitch + x];
45    const unsigned uv_index = (y / 2) * pitch + (x / 2) * 2;
46    const float U = (float)uv_plane[uv_index] - 128.0f;
47    const float V = (float)uv_plane[uv_index + 1] - 128.0f;
48    const float c = 1.16438356f * (Y - 16.0f);
49    const uchar4 rgba = make_uchar4(
50        byte_clamp(c + 1.79274107f * V),
51        byte_clamp(c - 0.21324861f * U - 0.53290933f * V),
52        byte_clamp(c + 2.11240179f * U), 255);
53    surf2Dwrite(rgba, dst, x * sizeof(uchar4), y);
54}

Each thread handles one pixel (lines 39–41). It reads its Y value from the first plane, then finds the U/V pair its 2×2 block shares in the second plane (line 45: halve both coordinates, and the pairs are interleaved, so multiply x by 2). Then it applies the BT.709 limited-range conversion. “Limited range” means the codec stores black as 16 and white as 235, not 0 and 255, which is why the math starts with 1.164 × (Y − 16): that stretches 16–235 back out to 0–255. The constants on lines 50–52 are the standard's coefficients for how much V contributes to red, how much U and V subtract from green, and how much U contributes to blue. The result is clamped to 0–255 and written as one RGBA pixel.

The kernel always assumes BT.709 limited range. That's right for HD video and for my clip, but a real player would read the stream's color metadata and pick the matrix accordingly. I come back to that in the limitations.

Part 4: Hand the frames to wgpu

Step 8: A small C ABI

I want the Rust side to know as little as possible about Media Foundation, NVDEC and CUDA. So the bridge exports seven plain C functions, and the Bridge object is an opaque pointer to Rust: it can hold it and pass it back, but not look inside.

466extern "C" {
467__declspec(dllexport) Bridge* nv_open(const char* path, void* d3d_device) {
468    std::unique_ptr<Bridge> bridge(new (std::nothrow) Bridge());
469    if (!bridge || !bridge->init(path, reinterpret_cast<ID3D12Device*>(d3d_device))) return nullptr;
470    return bridge.release();
471}
472__declspec(dllexport) int nv_width(Bridge* b) { return b ? (int)b->width : 0; }
473__declspec(dllexport) int nv_height(Bridge* b) { return b ? (int)b->height : 0; }
474__declspec(dllexport) void* nv_texture(Bridge* b, int index) {
475    if (!b || index < 0 || index >= kSlots) return nullptr;
476    ID3D12Resource* texture = b->slots[index].texture.Get();
477    if (texture) texture->AddRef(); // Rust from_raw takes this additional reference.
478    return texture;
479}
480__declspec(dllexport) int nv_next(Bridge* b, int* slot, int64_t* pts) {
481    return b ? b->next(slot, pts) : -1;
482}
483__declspec(dllexport) const char* nv_error(Bridge* b) { return b ? b->error.c_str() : "No video bridge"; }
484__declspec(dllexport) void nv_close(Bridge* b) { delete b; }
485}

Most of these are thin. The two that carry the design are nv_next, which runs Step 6 and reports a slot and a timestamp, and nv_texture, which hands out the D3D12 resource for a slot. Line 477 is the subtle part of nv_texture: COM objects are reference-counted, and the bridge calls AddRef before returning the pointer. That gives the Rust side a reference of its own, so the resource stays alive even after the bridge releases its reference in nv_close. Step 9 takes ownership of that extra reference.

On the Rust side, the declarations mirror the exports:

10#[repr(C)]
11struct VideoHandle { _opaque: [u8; 0] }
12unsafe extern "C" {
13    fn nv_open(filename: *const c_char, device: *mut c_void) -> *mut VideoHandle;
14    fn nv_width(ptr: *mut VideoHandle) -> i32;
15    fn nv_height(ptr: *mut VideoHandle) -> i32;
16    fn nv_texture(ptr: *mut VideoHandle, slot: i32) -> *mut c_void;
17    fn nv_next(ptr: *mut VideoHandle, slot: *mut i32, pts: *mut i64) -> i32;
18    fn nv_error(ptr: *mut VideoHandle) -> *const c_char;
19    fn nv_close(ptr: *mut VideoHandle);
20}

VideoHandle on lines 10–11 is the Rust idiom for an opaque type: a struct with no fields that can only be used through a pointer. Nothing checks the two declaration lists against each other, so a change to one has to be made in both. That's the cost of the simplicity.

Step 9: Import the textures with wgpu-hal

This is the step the post is named for. Video::open in src/video.rs is where the Rust side and the bridge meet, and it does four things: get the raw D3D12 device out of wgpu, open the bridge on it, decode the first frames, and wrap each slot's resource as a wgpu::Texture.

54pub fn open(device: &wgpu::Device, filename: &str) -> Result<Self, String> {
55    let hal = unsafe { device.as_hal::<wgpu::hal::api::Dx12>() }
56        .ok_or("wgpu must use the D3D12 backend")?;
57    let file = CString::new(filename).map_err(|e| e.to_string())?;
58    let native = NonNull::new(unsafe { nv_open(file.as_ptr(), hal.raw_device().as_raw()) })
59        .ok_or("Media Foundation / Video Codec SDK initialization failed (see stderr)")?;
60    // Decode initial + lookahead frame before importing the D3D12 resources into wgpu.
61    // On failure, always release the native decoder and CUDA resources.
62    let result = (|| {
63        let width = unsafe { nv_width(native.as_ptr()) } as u32;
64        let height = unsafe { nv_height(native.as_ptr()) } as u32;
65        if width == 0 || height == 0 { return Err("Invalid video dimensions".to_owned()); }
66        let current = Self::decode(native)?;
67        let pending = Self::decode(native)?;

Lines 55–56 are the first unsafe call, and the first place wgpu-hal appears. as_hal reaches under wgpu's abstraction and returns the backend-specific device, here wgpu::hal::dx12::Device. From that I can get the raw ID3D12Device pointer, which is what the bridge needs in Step 7 to create the textures on the same device wgpu renders with. If wgpu weren't using D3D12, as_hal returns None and the program stops here with a clear message. Line 58 opens the bridge, which runs all of Part 3's setup. Lines 66–67 decode two frames before wgpu wraps anything: the frame to show first, and one frame of lookahead (Step 11 explains why there's a lookahead). Self::decode is the Rust wrapper around nv_next, and it returns a DecodedFrame, so here are both:

22#[derive(Clone, Copy)]
23struct DecodedFrame { slot: usize, pts_100ns: i64 }
24
25pub struct Video {
26    native: NonNull<VideoHandle>,
27    pub textures: Vec<wgpu::Texture>,
28    current: DecodedFrame,
29    pending: DecodedFrame,
30    start: Instant,
31    next_due: Instant,
32    fallback_interval: Duration,
33}
34
35fn duration_100ns(ticks: i64) -> Duration {
36    Duration::from_nanos((ticks.max(0) as u64).saturating_mul(100))
37}

A DecodedFrame is the answer to one nv_next call: which slot the frame is in and when it's due. Video itself holds the bridge pointer, the three wrapped textures (public, because main.rs builds bind groups from them), the two frames, and the clock: when playback started, when the pending frame is due, and the last known interval between frames, for files whose timestamps don't advance. duration_100ns converts the bridge's 100-nanosecond ticks to a Duration, clamping negative values to zero and saturating rather than overflowing on absurd inputs.

40fn decode(handle: NonNull<VideoHandle>) -> Result<DecodedFrame, String> {
41    let mut slot = -1;
42    let mut pts = -1_i64;
43    if unsafe { nv_next(handle.as_ptr(), &mut slot, &mut pts) } != 1 {
44        let ptr = unsafe { nv_error(handle.as_ptr()) };
45        return Err(if ptr.is_null() { "Native NVDEC decoder error".into() }
46            else { unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned() });
47    }
48    if !(0..SLOTS as i32).contains(&slot) {
49        return Err(format!("Native NVDEC returned invalid texture slot {slot}"));
50    }
51    Ok(DecodedFrame { slot: slot as usize, pts_100ns: pts })
52}

decode is the only place nv_next is called, and it turns the C convention (return 1 or −1, fetch the message separately) into a Rust Result. Lines 44–46 fetch the bridge's first error message (the one fail and check stored), copying it out of C memory before it can change. Lines 48–50 refuse a slot number outside 0..3, which would otherwise index past textures in main.rs. It takes the raw handle rather than &self so that open can call it before Video exists, which is exactly what lines 66–67 do.

The closure starting on line 62 is for cleanup, and I'll come back to it. Inside it is the import:

69for slot in 0..SLOTS {
70    let resource_ptr = unsafe { nv_texture(native.as_ptr(), slot as i32) };
71    if resource_ptr.is_null() { return Err(format!("D3D12 resource slot {slot} missing")); }
72    // C++ AddRef's, Rust from_raw takes ownership of the additional COM reference.
73    let resource = unsafe { ID3D12Resource::from_raw(resource_ptr) };
74    let desc = wgpu::TextureDescriptor {
75        label: Some("Video Codec SDK CUDA-shared RGBA texture"),
76        size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
77        mip_level_count: 1,
78        sample_count: 1,
79        dimension: wgpu::TextureDimension::D2,
80        format: wgpu::TextureFormat::Rgba8Unorm,
81        usage: wgpu::TextureUsages::TEXTURE_BINDING,
82        view_formats: &[],
83    };
84    let wrapped = unsafe {
85        wgpu::hal::dx12::Device::texture_from_raw(
86            resource,
87            desc.format,
88            desc.dimension,
89            desc.size,
90            desc.mip_level_count,
91            desc.sample_count,
92        )
93    };
94    let tex = unsafe { device.create_texture_from_hal::<wgpu::hal::api::Dx12>(
95        wrapped, &desc, wgpu::TextureUses::RESOURCE) };
96    textures.push(tex);
97}

For each of the three slots:

  1. Lines 70–73: take ownership of the resource. nv_texture returns the raw pointer with an extra reference added (Step 8). ID3D12Resource::from_raw turns that pointer into a Rust value that will call Release when dropped, so the reference count is balanced.
  2. Lines 74–83: describe the texture to wgpu. This descriptor has to match the resource the bridge created. The same format (Rgba8Unorm is R8G8B8A8_UNORM), the same size, one mip level, one sample. The usage is TEXTURE_BINDING only, because wgpu will only ever sample these; it never renders into them or copies from them.
  3. Lines 84–93: wrap the resource in a wgpu-hal texture. texture_from_raw doesn't allocate anything. It just pairs the resource pointer with the metadata wgpu-hal needs to treat it as a texture.
  4. Lines 94–95: register it with wgpu. create_texture_from_hal takes the hal texture and the descriptor and returns an ordinary wgpu::Texture. The third argument, TextureUses::RESOURCE, tells wgpu what state the texture is in right now; more on that below.

Every unsafe in this excerpt is a promise the compiler can't check, and wgpu's documentation for create_texture_from_hal spells out what I'm promising:

wgpu 30.0.1, Device::create_texture_from_hal, Safety
- `hal_texture` must be created from this device internal handle
- `hal_texture` must be created respecting `desc`
- `hal_texture` must be initialized
- `initial_state`, if it is not `TextureUses::UNINITIALIZED`, must
  match the actual driver-side layout/state of the wrapped resource at
  the moment of wrap.

It's worth going through these one at a time, because each one was a decision somewhere earlier in the code:

The safety contract, point by point
RequirementHow it holds here
Created from this deviceStep 7 creates the resources on the ID3D12Device that as_hal returned on line 55, and Step 4 matched the CUDA device to that adapter's LUID. Same device, same GPU.
Matches the descriptorFormat, size, mip count and sample count on lines 74–83 are copied from how Step 7 created the resource. If either side changed, this is the line to update.
InitializedLines 66–67 decode into the first two slots before the import. The third slot is wrapped before its first write. That's harmless here, because wgpu only ever samples a slot the bridge has reported as containing a frame, but it's the one place this code leans on the contract rather than meeting it outright.
Initial state matchesThe resources were created in COMMON, and wgpu is told RESOURCE. That's consistent for simultaneous-access textures: they're “implicitly promoted from D3D12_RESOURCE_STATE_COMMON to the relevant state on first GPU access” and decay back after each submission (Microsoft's resource state docs). That's why the flag in Step 7 mattered.

The end of open is timing setup and the cleanup I promised to explain:

98let start = Instant::now();
99    let fallback_interval = Duration::from_millis(33);
100    let due = if pending.pts_100ns > current.pts_100ns {
101        duration_100ns(pending.pts_100ns)
102    } else { fallback_interval };
103    Ok(Self { native, textures, current, pending, start,
104        next_due: start + due, fallback_interval })
105})();
106if result.is_err() { unsafe { nv_close(native.as_ptr()) }; }
107result

Lines 98–104 start the playback clock and work out when the lookahead frame is due. Line 106 is why the body was wrapped in a closure: if anything after nv_open fails (a slot's resource is missing, a decode errors), the closure returns early with the error, and this line still calls nv_close. Without it, a failed open would leak the decoder, the CUDA context and the textures.

Ownership at the end of this step, because it's easy to lose track of:

  • The bridge owns the decoder, the CUDA objects and one reference to each D3D12 resource.
  • Each wgpu::Texture owns another reference to its resource, via from_raw.
  • Video owns the bridge pointer and the three textures. Its Drop closes the bridge:
139impl Drop for Video {
140    fn drop(&mut self) {
141        // Caller waits for GPU idle before dropping Video/State. The COM resource
142        // refs owned by wgpu outlive the bridge if other bind groups retain them.
143        unsafe { nv_close(self.native.as_ptr()) };
144    }
145}

The comment on lines 141–142 is the one rule the caller has to follow: the GPU must be idle before Video is dropped, because nv_close tears down the CUDA side while wgpu might still have a frame in flight that samples the texture. main.rs handles that by waiting for the device in its own Drop, which runs before Video's.

And nv_close is just delete, so what it actually runs is the bridge's destructor:

122~Bridge() {
123    // Caller must release every wrapped wgpu texture before nv_close!
124    if (cuda_context && push()) {
125        if (parser) cuvidDestroyVideoParser(parser);
126        if (decoder) cuvidDestroyDecoder(decoder);
127        for (auto& slot : slots) slot.cleanup();
128        pop();
129        cuCtxDestroy(cuda_context);
130    }
131    reader.Reset();
132    compressed_type.Reset();
133    d3d.Reset();
134    if (mf_started) MFShutdown();
135    if (com_started) CoUninitialize();
136}

Teardown in the reverse order of setup. The parser and decoder go first, because they own NVDEC surfaces; then each slot's cleanup from Step 7; then the context itself, which has to outlive everything that was created in it, and which is pushed one last time so the destroy calls have a current context. The COM pointers release themselves when reset. Media Foundation and COM are shut down only if this object started them (the two flags from the Bridge struct). The comment on line 123 is the same rule as above, seen from the C++ side.

Step 10: Sample the video in WGSL

From here on it's ordinary wgpu, which is the point. The three textures came from a video decoder, but nothing in the rendering code knows that. Each slot gets its own bind group, sharing the same uniform buffer and sampler:

166let bind_groups: Vec<wgpu::BindGroup> = video.textures.iter().enumerate().map(|(_slot, tex)| {
167    let texture_view = tex.create_view(&Default::default());
168    device.create_bind_group(&wgpu::BindGroupDescriptor {
169        label: Some("NVDEC frame + MVP bindings"), layout: &layout,
170        entries: &[
171            wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() },
172            wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&texture_view) },
173            wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler) },
174        ],
175    })
176}).collect();
276pass.set_pipeline(&self.pipeline);
277pass.set_bind_group(0, &self.bind_groups[self.video.current_slot()], &[]);
278pass.set_vertex_buffer(0, self.vertices.slice(..));
279pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint16);
280pass.draw_indexed(0..36, 0, 0..1);

And the shader has no idea any of this happened:

1struct Uniforms { mvp: mat4x4<f32> }
2@group(0) @binding(0) var<uniform> uniforms: Uniforms;
3@group(0) @binding(1) var video: texture_2d<f32>;
4@group(0) @binding(2) var video_sampler: sampler;
5
6struct VertexInput {
7    @location(0) pos: vec3<f32>,
8    @location(1) uv: vec2<f32>,
9};
10struct VertexOutput {
11    @builtin(position) clip: vec4<f32>,
12    @location(0) uv: vec2<f32>,
13};
14
15@vertex
16fn vs_main(v: VertexInput) -> VertexOutput {
17    var result: VertexOutput;
18    result.clip = uniforms.mvp * vec4<f32>(v.pos, 1.0);
19    result.uv = v.uv;
20    return result;
21}
22
23@fragment
24fn fs_main(v: VertexOutput) -> @location(0) vec4<f32> {
25    return textureSample(video, video_sampler, v.uv);
26}
110// The CUDA kernel writes display-encoded (gamma) RGB into Rgba8Unorm textures, so
111// sampled values are already what the screen should show. A non-sRGB target passes
112// them through; an sRGB one would gamma-encode them a second time (washed out).
113let format = caps.formats.iter().copied().find(|f| !f.is_srgb()).unwrap_or(caps.formats[0]);
114println!("Swapchain format: {format:?}");
115let clear = background(format);

That has one knock-on effect. wgpu treats clear colors as linear only on sRGB targets, so the window's background color needs its own conversion to look the same on either kind of format:

42/// The window background, rgb(69, 80, 93), as clear values for `format`. Clear colors are
43/// linear on sRGB targets, so the display value is decoded there.
44fn background(format: wgpu::TextureFormat) -> wgpu::Color {
45    let [r, g, b] = [69.0, 80.0, 93.0].map(|v: f64| {
46        let c = v / 255.0;
47        if !format.is_srgb() { c } else if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
48    });
49    wgpu::Color { r, g, b, a: 1.0 }
50}

To check the fix was right rather than just different, I compared the cube's pixels against an independent decode: ffmpeg's frame from the same moment of the clip, converted with the same BT.709 limited-range matrix. The fixed version lands within a few levels of ffmpeg:

Average color of the cube's pixels, and of ffmpeg's frame
Average RGBSaturationBrightness
Before (sRGB swapchain)237, 231, 2270.080.95
After (Bgra8Unorm)220, 206, 1980.170.90
ffmpeg's decode217, 205, 1970.170.88

The cube shows the frame in perspective, so this is an approximate check, but the difference is clear.

Step 11: Frame timing and synchronization

Two clocks are running. The cube redraws about every 16 ms, because that's a reasonable refresh rate for an animation. The video has its own rate, 24 frames per second for my clip, which is about every 42 ms. So on most redraws the video shouldn't change, and on some it should. Video::update runs on every redraw and decides:

112pub fn update(&mut self, device: &wgpu::Device) -> Result<bool, String> {
113    let now = Instant::now();
114    if now < self.next_due { return Ok(false); }
115    // Safe first milestone. Serializes CUDA writes against *all* queued wgpu reads.
116    // A future revision can replace this with CUDA-imported D3D12 fences per slot.
117    device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None })
118        .map_err(|e| e.to_string())?;
119    let previous_pts = self.current.pts_100ns;
120    self.current = self.pending;
121    if self.current.pts_100ns <= previous_pts {
122        // Video loop: the native reader rewound to the beginning.
123        self.start = now;
124    }
125    let new_pending = Self::decode(self.native)?;
126    let delta = new_pending.pts_100ns - self.current.pts_100ns;
127    if delta > 0 {
128        self.fallback_interval = duration_100ns(delta).min(Duration::from_secs(1));
129        self.next_due = self.start + duration_100ns(new_pending.pts_100ns);
130    } else {
131        // Next packet may be from the next video loop, or contain a duplicate timestamp.
132        self.next_due = now + self.fallback_interval;
133    }
134    self.pending = new_pending;
135    Ok(true)
136}

This is where the lookahead frame from Step 9 pays off. At any moment Video holds two frames: the one on screen (current) and the next one (pending), whose timestamp it already knows. Line 114 is the whole scheduling decision: if the pending frame isn't due yet, do nothing. When it is due, lines 120–121 promote it to current, and lines 125–134 decode a new pending frame and work out when that one is due. Knowing the next frame's time in advance is what lets this be a simple comparison instead of a guess.

The timestamps make this possible by passing through every layer unchanged: Media Foundation's sample time became the NVDEC packet timestamp in Step 3, came back out with the decoded frame in Step 6, and the bridge made it relative to the first frame. Lines 121–124 handle the loop: a timestamp that goes backwards means the bridge rewound the clip, so the playback clock restarts. Lines 130–133 handle files with duplicate or missing timestamps by falling back to the last known frame interval.

Then there's the part I've been deferring: synchronization, lines 117–118.

Part 5: Build and run

Step 12. From a clone of the repository (or your own copy built from the steps above). No developer prompt is needed; cc finds the Visual Studio compiler for nvcc on its own. The only thing to set is where the Video Codec SDK is. In PowerShell:

PowerShell
$env:VIDEO_CODEC_SDK_DIR = "C:\path\to\Video_Codec_SDK_13.1.15"
cargo run --release -- "C:\Videos\sample.mp4"

A working start prints one line per stage, which is useful for seeing how far it got when something fails:

Output
wgpu adapter: NVIDIA GeForce RTX 5080 Laptop GPU (Dx12)
[mf] compressed H.264 320x180, sequence headers=35 bytes
[mf] NAL framing: Annex B
Native Video Codec SDK: H.264/HEVC, Media Foundation compressed packets, 3 GPU textures
Swapchain format: Bgra8Unorm

Then a window opens with the cube, playing the clip on every face. My test clip played for about 90 seconds per run without an error. The process peaked at about 330 MB and used about 17 seconds of CPU time over those 90 seconds.

That clip is Constrained Baseline H.264, which has no B-frames, so it never exercised the reordering I described in Step 5. To check that path, I cut a 4-second High-profile clip from it with ffmpeg (58 of its 96 frames are B-frames) and let it loop. It played through about four loops without errors, which also covers the end-of-stream flush and the rewind in Step 6. I didn't check the frame order frame by frame.

The build also prints a few Compiler family detection failed ... "nvcc" "-E" warnings. That's cc probing nvcc to learn what kind of compiler it is, and it doesn't affect the build.

Common errors

Build errors come from build.rs's checks in Step 2. At run time the bridge prints [nvdec] lines to stderr, and the same message ends the program.

Errors and what they mean
MessageWhat to do
Set VIDEO_CODEC_SDK_DIR to the extracted NVIDIA Video Codec SDKSet the variable in the same terminal, pointing at the extracted folder.
Could not locate SDK nvcuvid.lib (Lib/win/x64 or Lib/x64)The path points at the wrong level (or at the zip). It should be the folder that contains Interface and Lib.
Install the NVIDIA CUDA Toolkit (CUDA_PATH)Install the CUDA Toolkit, then open a new terminal so CUDA_PATH is set.
This sample requires wgpu to use an NVIDIA GPU (vendor 0x10DE)wgpu picked another adapter, such as an iGPU (Step 4). Check which GPU Windows assigns to the program.
CUDA device LUID doesn't match the wgpu D3D12 NVIDIA adapterCUDA can't see the GPU wgpu chose (Step 4). Check the driver and that the CUDA Toolkit supports the GPU.
Only native compressed H.264/HEVC MP4 is supportedThe video uses another codec (AV1, VP9 and so on). Re-encode it, or extend the subtype check in Step 3.
Only progressive 8-bit 4:2:0 video is supported (NV12)10-bit, 4:2:2 or interlaced video, rejected in Step 5. See the limitations below.
Codec/profile not supported by NVIDIA hardwarecuvidGetDecoderCaps says this GPU's NVDEC can't decode the stream's codec or profile.
Invalid compressed NAL units (...)The packets are neither Annex B nor 4-byte length-prefixed, or the framing changed mid-stream (Step 3).

Part 6: Every file, top to bottom

Parts 2 to 4 followed the data: a packet in, a frame out. This part follows the files instead. If you have the project open next to this post, it's the order to read each file in. The pieces the steps already covered get a pointer back to the step; the pieces they skipped (the helpers, the state, the destructor, the cube geometry, the window loop) are explained here, and each section ends with the whole file. None of this is new design. It's the glue that holds the steps together, and it's where most of the small decisions live.

Cargo.toml and build.rs

Both are short enough that Step 1 and Step 2 already show them in full. The one thing to add is how they relate: Cargo.toml names build.rs as the build script (line 5), and lists cc under [build-dependencies] rather than [dependencies], because it's used by the build script and not by the program. Everything else the program links comes from the rustc-link-lib lines in build.rs, not from Cargo.

native/video_bridge.cu

485 lines, and the file where everything native lives. It's laid out as: includes and constants, the CUDA kernel, two helper types, the Bridge object with its methods in the order they're used, and the C exports at the bottom. Here's the top:

1// Windows/x64 only. No FFmpeg: Media Foundation provides *compressed* packets;
2// NVIDIA Video Codec SDK (nvcuvid) parses/decodes; CUDA writes D3D12 textures.
3#define NOMINMAX
4#define WIN32_LEAN_AND_MEAN
5#include <windows.h>
6#include <mfapi.h>
7#include <mfidl.h>
8#include <mfreadwrite.h>
9#include <wrl/client.h>
10#include <d3d12.h>
11#include <cuda.h>
12#include <cuda_runtime.h>
13#include <nvcuvid.h>
14#include <algorithm>
15#include <array>
16#include <cstdint>
17#include <cstdio>
18#include <cstring>
19#include <deque>
20#include <memory>
21#include <new>
22#include <string>
23#include <vector>
24#include "bitstream.h"
25
26using Microsoft::WRL::ComPtr;
27static constexpr int kSlots = 3;
28// The Source Reader's stream selectors are negative enum values passed as DWORD.
29static constexpr DWORD kVideoStream = static_cast<DWORD>(MF_SOURCE_READER_FIRST_VIDEO_STREAM);
30static constexpr DWORD kAllStreams = static_cast<DWORD>(MF_SOURCE_READER_ALL_STREAMS);

Lines 3–4 are Windows header hygiene: NOMINMAX stops windows.h from defining min and max macros that would break std::max later in the file, and WIN32_LEAN_AND_MEAN leaves out the parts of the Windows API this file doesn't use. The includes then come in groups: Media Foundation (lines 6–8), WRL's ComPtr for reference-counted COM pointers (line 9), D3D12 (line 10), and both CUDA APIs. That last point matters for reading the rest of the file: cuda.h is the driver API (functions starting with cu, returning CUresult), which NVCUVID is built on and which owns the context; cuda_runtime.h is the runtime API (functions starting with cuda, returning cudaError_t), which has the external-memory import and launches the kernel. The bridge uses both, which is why it has two error-checking helpers below.

Lines 27–30 are the constants. kSlots is the number of textures. The two DWORD constants wrap Media Foundation's stream selectors, which are defined as negative enum values but passed to functions that take an unsigned DWORD; casting once here keeps nvcc from warning at every use.

Then, in file order, and where each piece is explained:

native/video_bridge.cu, by line
LinesWhatWhere
32–54nv12_to_rgba, the color kernelStep 7
56–63utf8_to_utf16Step 3
64–76OutputSlot and its cleanupStep 7
78–120The Bridge's fields, error helpers, push and popThe Bridge object
122–136The destructorStep 9
138–184init_media: open the file, keep it compressed, read the headersStep 3
186–262init_cuda: match the GPU, create the context and the three shared texturesStep 4 and Step 7
264–345The parser callbacks, on_sequence and create_parserStep 5
346–351init: the three setup calls in orderBelow
352–368rewind_videoStep 6
369–409read_packet: one Media Foundation sample into the parserStep 3
411–463next: feed until a frame is ready, map it, convert itStep 6
466–485The C exportsStep 8

The one method not shown in the steps is the glue that runs the setup:

346bool init(const char* filename, ID3D12Device* device) {
347    if (!init_media(filename) || !init_cuda(device) || !push()) return false;
348    bool ok = create_parser();
349    pop();
350    return ok;
351}

The order is forced: the media type gives the frame size that the textures need, the CUDA context has to exist before the parser is created in it, and the parser is created last. The whole file:

1// Windows/x64 only. No FFmpeg: Media Foundation provides *compressed* packets;
2// NVIDIA Video Codec SDK (nvcuvid) parses/decodes; CUDA writes D3D12 textures.
3#define NOMINMAX
4#define WIN32_LEAN_AND_MEAN
5#include <windows.h>
6#include <mfapi.h>
7#include <mfidl.h>
8#include <mfreadwrite.h>
9#include <wrl/client.h>
10#include <d3d12.h>
11#include <cuda.h>
12#include <cuda_runtime.h>
13#include <nvcuvid.h>
14#include <algorithm>
15#include <array>
16#include <cstdint>
17#include <cstdio>
18#include <cstring>
19#include <deque>
20#include <memory>
21#include <new>
22#include <string>
23#include <vector>
24#include "bitstream.h"
25
26using Microsoft::WRL::ComPtr;
27static constexpr int kSlots = 3;
28// The Source Reader's stream selectors are negative enum values passed as DWORD.
29static constexpr DWORD kVideoStream = static_cast<DWORD>(MF_SOURCE_READER_FIRST_VIDEO_STREAM);
30static constexpr DWORD kAllStreams = static_cast<DWORD>(MF_SOURCE_READER_ALL_STREAMS);
31
32// Simple SDR BT.709 *limited-range* matrix; HDR/P010 and metadata conversion
33// require a different kernel and are rejected in on_sequence for this example.
34__device__ __forceinline__ unsigned char byte_clamp(float v) {
35    return (unsigned char)fminf(255.0f, fmaxf(0.0f, v));
36}
37__global__ void nv12_to_rgba(cudaSurfaceObject_t dst, const uint8_t* nv12,
38                             unsigned pitch, unsigned width, unsigned height) {
39    const unsigned x = blockIdx.x * blockDim.x + threadIdx.x;
40    const unsigned y = blockIdx.y * blockDim.y + threadIdx.y;
41    if (x >= width || y >= height) return;
42    const uint8_t* y_plane = nv12;
43    const uint8_t* uv_plane = nv12 + (size_t)pitch * height;
44    const float Y = (float)y_plane[(size_t)y * pitch + x];
45    const unsigned uv_index = (y / 2) * pitch + (x / 2) * 2;
46    const float U = (float)uv_plane[uv_index] - 128.0f;
47    const float V = (float)uv_plane[uv_index + 1] - 128.0f;
48    const float c = 1.16438356f * (Y - 16.0f);
49    const uchar4 rgba = make_uchar4(
50        byte_clamp(c + 1.79274107f * V),
51        byte_clamp(c - 0.21324861f * U - 0.53290933f * V),
52        byte_clamp(c + 2.11240179f * U), 255);
53    surf2Dwrite(rgba, dst, x * sizeof(uchar4), y);
54}
55
56static std::wstring utf8_to_utf16(const char* src) {
57    int chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, src, -1, nullptr, 0);
58    if (chars <= 0) return {};
59    std::wstring out((size_t)chars, 0);
60    if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, src, -1, out.data(), chars)) return {};
61    out.pop_back();
62    return out;
63}
64struct OutputSlot {
65    ComPtr<ID3D12Resource> texture;
66    cudaExternalMemory_t memory = nullptr;
67    cudaMipmappedArray_t mipmap = nullptr;
68    cudaSurfaceObject_t surface = 0;
69    void cleanup() {
70        if (surface) cudaDestroySurfaceObject(surface);
71        if (mipmap) cudaFreeMipmappedArray(mipmap);
72        if (memory) cudaDestroyExternalMemory(memory);
73        surface = 0; mipmap = nullptr; memory = nullptr;
74        texture.Reset();
75    }
76};
77
78struct Bridge {
79    ComPtr<IMFSourceReader> reader;
80    ComPtr<IMFMediaType> compressed_type;
81    ComPtr<ID3D12Device> d3d;
82    bool com_started = false, mf_started = false;
83    bool has_eos = false, flushed = false;
84    unsigned width = 0, height = 0;
85    unsigned decode_width = 0, decode_height = 0;
86    cudaVideoCodec codec_type = cudaVideoCodec_H264;
87    std::vector<uint8_t> sequence_header;
88    std::vector<uint8_t> bitstream;
89    NalFraming framing = NalFraming::Unknown; // Set by the first sample, kept across loops.
90    std::deque<CUVIDPARSERDISPINFO> decoded;
91    CUcontext cuda_context = nullptr;
92    CUvideoparser parser = nullptr;
93    CUvideodecoder decoder = nullptr;
94    std::array<OutputSlot, kSlots> slots;
95    std::string error;
96    int current_slot = -1;
97    int64_t first_timestamp = -1;
98    int64_t last_pts = 0;
99
100    bool fail(const char* message) {
101        if (error.empty()) error = message;
102        std::fprintf(stderr, "[nvdec] %s\n", message);
103        return false;
104    }
105    bool check(CUresult result, const char* message) {
106        if (result == CUDA_SUCCESS) return true;
107        const char* name = "unknown";
108        cuGetErrorName(result, &name);
109        error = std::string(message) + ": " + (name ? name : "unknown");
110        std::fprintf(stderr, "[nvdec] %s\n", error.c_str());
111        return false;
112    }
113    bool check_cuda(cudaError_t result, const char* message) {
114        if (result == cudaSuccess) return true;
115        error = std::string(message) + ": " + cudaGetErrorString(result);
116        std::fprintf(stderr, "[nvdec] %s\n", error.c_str());
117        return false;
118    }
119    bool push() { return check(cuCtxPushCurrent(cuda_context), "cuCtxPushCurrent"); }
120    void pop() { CUcontext old = nullptr; cuCtxPopCurrent(&old); }
121
122    ~Bridge() {
123        // Caller must release every wrapped wgpu texture before nv_close!
124        if (cuda_context && push()) {
125            if (parser) cuvidDestroyVideoParser(parser);
126            if (decoder) cuvidDestroyDecoder(decoder);
127            for (auto& slot : slots) slot.cleanup();
128            pop();
129            cuCtxDestroy(cuda_context);
130        }
131        reader.Reset();
132        compressed_type.Reset();
133        d3d.Reset();
134        if (mf_started) MFShutdown();
135        if (com_started) CoUninitialize();
136    }
137
138    bool init_media(const char* path) {
139        HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
140        // winit may already have created an STA on the Windows UI thread.
141        if (hr == RPC_E_CHANGED_MODE) hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
142        if (FAILED(hr)) return fail("CoInitializeEx failed");
143        com_started = true;
144        hr = MFStartup(MF_VERSION);
145        if (FAILED(hr)) return fail("MFStartup failed");
146        mf_started = true;
147        const std::wstring wide = utf8_to_utf16(path);
148        if (wide.empty()) return fail("Invalid UTF-8 video path");
149        hr = MFCreateSourceReaderFromURL(wide.c_str(), nullptr, reader.GetAddressOf());
150        if (FAILED(hr)) return fail("MFCreateSourceReaderFromURL failed (MP4 file required)");
151        // Disable all other tracks; preserve the *native compressed* video type.
152        hr = reader->SetStreamSelection(kAllStreams, FALSE);
153        if (FAILED(hr)) return fail("Disable other streams failed");
154        hr = reader->SetStreamSelection(kVideoStream, TRUE);
155        if (FAILED(hr)) return fail("Select video stream failed");
156        hr = reader->GetNativeMediaType(kVideoStream, 0, compressed_type.GetAddressOf());
157        if (FAILED(hr)) return fail("Cannot get native compressed video type");
158        GUID subtype = {};
159        if (FAILED(compressed_type->GetGUID(MF_MT_SUBTYPE, &subtype))) return fail("No video subtype");
160        if (subtype == MFVideoFormat_H264) codec_type = cudaVideoCodec_H264;
161        else if (subtype == MFVideoFormat_HEVC) codec_type = cudaVideoCodec_HEVC;
162        else return fail("Only native compressed H.264/HEVC MP4 is supported");
163        hr = reader->SetCurrentMediaType(kVideoStream, nullptr, compressed_type.Get());
164        if (FAILED(hr)) return fail("Could not keep native compressed video output");
165        if (FAILED(MFGetAttributeSize(compressed_type.Get(), MF_MT_FRAME_SIZE, &width, &height)) ||
166            !width || !height || (width & 1) || (height & 1)) {
167            return fail("Missing/unsupported video frame size (even dimensions required)");
168        }
169        UINT32 header_size = 0;
170        if (SUCCEEDED(compressed_type->GetBlobSize(MF_MT_MPEG_SEQUENCE_HEADER, &header_size)) && header_size) {
171            sequence_header.resize(header_size);
172            UINT32 got = 0;
173            if (FAILED(compressed_type->GetBlob(MF_MT_MPEG_SEQUENCE_HEADER,
174                    sequence_header.data(), header_size, &got))) return fail("Sequence-header read failed");
175            sequence_header.resize(got);
176            // MF sequence headers are Annex B SPS/PPS or VPS/SPS/PPS, NOT avcC/hvcC.
177            if (!annexb_prefix(sequence_header.data(), sequence_header.size()))
178                return fail("Expected Annex B MF_MT_MPEG_SEQUENCE_HEADER");
179        }
180        std::printf("[mf] compressed %s %ux%u, sequence headers=%zu bytes\n",
181            codec_type == cudaVideoCodec_H264 ? "H.264" : "HEVC", width, height, sequence_header.size());
182        std::fflush(stdout); // C stdio is fully buffered when redirected; Rust's println! isn't affected
183        return true;
184    }
185
186    bool init_cuda(ID3D12Device* dev) {
187        if (!dev) return fail("Missing wgpu D3D12 device");
188        d3d = dev;
189        if (!check(cuInit(0), "cuInit")) return false;
190        int count = 0;
191        if (!check(cuDeviceGetCount(&count), "cuDeviceGetCount")) return false;
192        const LUID adapter_luid = dev->GetAdapterLuid();
193        CUdevice match = -1;
194        for (int i = 0; i < count; ++i) {
195            CUdevice candidate = 0;
196            char cuda_luid[8] = {};
197            unsigned node_mask = 0;
198            if (cuDeviceGet(&candidate, i) == CUDA_SUCCESS &&
199                cuDeviceGetLuid(cuda_luid, &node_mask, candidate) == CUDA_SUCCESS &&
200                std::memcmp(cuda_luid, &adapter_luid, sizeof(cuda_luid)) == 0) {
201                match = candidate;
202                break;
203            }
204        }
205        if (match < 0) return fail("CUDA device LUID doesn't match the wgpu D3D12 NVIDIA adapter");
206        if (!check(cuCtxCreate(
207            &cuda_context,
208            nullptr,
209            0,
210            match
211        ), "cuCtxCreate")) return false;
212        // cuCtxCreate makes this context current; pop so every call explicitly pushes it.
213        pop();
214        if (!push()) return false;
215        bool ok = true;
216        for (auto& slot : slots) {
217            D3D12_HEAP_PROPERTIES heap = {};
218            heap.Type = D3D12_HEAP_TYPE_DEFAULT;
219            D3D12_RESOURCE_DESC desc = {};
220            desc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;
221            desc.Width = width;
222            desc.Height = height;
223            desc.DepthOrArraySize = 1;
224            desc.MipLevels = 1;
225            desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
226            desc.SampleDesc.Count = 1;
227            desc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN;
228            desc.Flags = D3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESS;
229            HRESULT hr = dev->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_SHARED, &desc,
230                D3D12_RESOURCE_STATE_COMMON, nullptr, IID_PPV_ARGS(&slot.texture));
231            if (FAILED(hr)) { ok = fail("CreateCommittedResource failed"); break; }
232            HANDLE shared = nullptr;
233            hr = dev->CreateSharedHandle(slot.texture.Get(), nullptr, GENERIC_ALL, nullptr, &shared);
234            if (FAILED(hr)) { ok = fail("CreateSharedHandle failed"); break; }
235            cudaExternalMemoryHandleDesc memory_desc = {};
236            memory_desc.type = cudaExternalMemoryHandleTypeD3D12Resource;
237            memory_desc.handle.win32.handle = shared;
238            memory_desc.size = dev->GetResourceAllocationInfo(0, 1, &desc).SizeInBytes;
239            memory_desc.flags = cudaExternalMemoryDedicated;
240            const cudaError_t imported = cudaImportExternalMemory(&slot.memory, &memory_desc);
241            CloseHandle(shared);
242            if (!check_cuda(imported, "cudaImportExternalMemory")) { ok = false; break; }
243            cudaExternalMemoryMipmappedArrayDesc mapping = {};
244            mapping.offset = 0;
245            mapping.formatDesc = cudaCreateChannelDesc(8, 8, 8, 8, cudaChannelFormatKindUnsigned);
246            mapping.extent = make_cudaExtent(width, height, 1);
247            mapping.flags = cudaArraySurfaceLoadStore;
248            mapping.numLevels = 1;
249            if (!check_cuda(cudaExternalMemoryGetMappedMipmappedArray(&slot.mipmap, slot.memory, &mapping),
250                            "cudaExternalMemoryGetMappedMipmappedArray")) { ok = false; break; }
251            cudaArray_t array = nullptr;
252            if (!check_cuda(cudaGetMipmappedArrayLevel(&array, slot.mipmap, 0),
253                            "cudaGetMipmappedArrayLevel")) { ok = false; break; }
254            cudaResourceDesc resource = {};
255            resource.resType = cudaResourceTypeArray;
256            resource.res.array.array = array;
257            if (!check_cuda(cudaCreateSurfaceObject(&slot.surface, &resource),
258                            "cudaCreateSurfaceObject")) { ok = false; break; }
259        }
260        pop();
261        return ok;
262    }
263
264    static int CUDAAPI sequence_cb(void* user, CUVIDEOFORMAT* fmt) {
265        return static_cast<Bridge*>(user)->on_sequence(fmt);
266    }
267    static int CUDAAPI decode_cb(void* user, CUVIDPICPARAMS* pic) {
268        auto* b = static_cast<Bridge*>(user);
269        return b->decoder && b->check(cuvidDecodePicture(b->decoder, pic), "cuvidDecodePicture") ? 1 : 0;
270    }
271    static int CUDAAPI display_cb(void* user, CUVIDPARSERDISPINFO* info) {
272        auto* b = static_cast<Bridge*>(user);
273        if (!info) return 1;
274        if (b->decoded.size() >= 64) { b->fail("Decoder display queue overflow"); return 0; }
275        b->decoded.push_back(*info); // Parser delivers display/reordered order.
276        return 1;
277    }
278    int on_sequence(CUVIDEOFORMAT* f) {
279        if (!f || f->codec != codec_type || f->chroma_format != cudaVideoChromaFormat_420 ||
280            f->bit_depth_luma_minus8 != 0 || !f->progressive_sequence) {
281            fail("Only progressive 8-bit 4:2:0 video is supported (NV12)"); return 0;
282        }
283        if (decoder) {
284            if (decode_width != f->coded_width || decode_height != f->coded_height) {
285                fail("Midstream resolution changes are not supported"); return 0;
286            }
287            return std::max(
288                2u,
289                static_cast<unsigned int>(f->min_num_decode_surfaces)
290            );
291        }
292        CUVIDDECODECAPS caps = {};
293        caps.eCodecType = f->codec;
294        caps.eChromaFormat = f->chroma_format;
295        caps.nBitDepthMinus8 = f->bit_depth_luma_minus8;
296        if (!check(cuvidGetDecoderCaps(&caps), "cuvidGetDecoderCaps") || !caps.bIsSupported) {
297            fail("Codec/profile not supported by NVIDIA hardware"); return 0;
298        }
299        decode_width = f->coded_width;
300        decode_height = f->coded_height;
301        CUVIDDECODECREATEINFO create = {};
302        create.CodecType = f->codec;
303        create.ChromaFormat = f->chroma_format;
304        create.OutputFormat = cudaVideoSurfaceFormat_NV12;
305        create.DeinterlaceMode = cudaVideoDeinterlaceMode_Weave;
306        create.ulCreationFlags = cudaVideoCreate_PreferCUVID;
307        create.ulNumDecodeSurfaces = std::max(
308            2u,
309            static_cast<unsigned int>(f->min_num_decode_surfaces)
310        );
311        create.ulNumOutputSurfaces = 2;
312        create.ulWidth = f->coded_width;
313        create.ulHeight = f->coded_height;
314        create.ulMaxWidth = f->coded_width;
315        create.ulMaxHeight = f->coded_height;
316        create.display_area.left = f->display_area.left;
317        create.display_area.top = f->display_area.top;
318        create.display_area.right = f->display_area.right;
319        create.display_area.bottom = f->display_area.bottom;
320        create.ulTargetWidth = width;
321        create.ulTargetHeight = height;
322        if (!check(cuvidCreateDecoder(&decoder, &create), "cuvidCreateDecoder")) return 0;
323        return (int)create.ulNumDecodeSurfaces;
324    }
325    bool create_parser() {
326        if (parser) { if (!check(cuvidDestroyVideoParser(parser), "cuvidDestroyVideoParser")) return false; parser = nullptr; }
327        if (decoder) { if (!check(cuvidDestroyDecoder(decoder), "cuvidDestroyDecoder")) return false; decoder = nullptr; }
328        decode_width = 0; decode_height = 0;
329        CUVIDPARSERPARAMS parameters = {};
330        parameters.CodecType = codec_type;
331        parameters.ulMaxNumDecodeSurfaces = 20; // Sequence callback can override.
332        parameters.ulMaxDisplayDelay = 2; // Frames the parser may hold before the display callback (0 = none).
333        parameters.pUserData = this;
334        parameters.pfnSequenceCallback = &Bridge::sequence_cb;
335        parameters.pfnDecodePicture = &Bridge::decode_cb;
336        parameters.pfnDisplayPicture = &Bridge::display_cb;
337        if (!check(cuvidCreateVideoParser(&parser, &parameters), "cuvidCreateVideoParser")) return false;
338        if (!sequence_header.empty()) {
339            CUVIDSOURCEDATAPACKET pkt = {};
340            pkt.payload = sequence_header.data();
341            pkt.payload_size = (unsigned long)sequence_header.size();
342            if (!check(cuvidParseVideoData(parser, &pkt), "cuvidParseVideoData(sequence header)")) return false;
343        }
344        return true;
345    }
346    bool init(const char* filename, ID3D12Device* device) {
347        if (!init_media(filename) || !init_cuda(device) || !push()) return false;
348        bool ok = create_parser();
349        pop();
350        return ok;
351    }
352    bool rewind_video() {
353        // Do not destroy the parser until *every* display callback frame was consumed.
354        PROPVARIANT seek; PropVariantInit(&seek);
355        seek.vt = VT_I8;
356        seek.hVal.QuadPart = 0;
357        HRESULT hr = reader->SetCurrentPosition(GUID_NULL, seek);
358        if (FAILED(hr)) return fail("Media Foundation failed to seek to video beginning");
359        has_eos = false;
360        flushed = false;
361        first_timestamp = -1;
362        last_pts = 0;
363        decoded.clear();
364        if (!push()) return false;
365        const bool ok = create_parser();
366        pop();
367        return ok;
368    }
369    bool read_packet() {
370        DWORD flags = 0;
371        LONGLONG timestamp = 0;
372        ComPtr<IMFSample> sample;
373        const HRESULT hr = reader->ReadSample(kVideoStream, 0,
374            nullptr, &flags, &timestamp, sample.GetAddressOf());
375        if (FAILED(hr)) return fail("IMFSourceReader::ReadSample failed");
376        if (flags & (MF_SOURCE_READERF_NATIVEMEDIATYPECHANGED | MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED))
377            return fail("Dynamic compressed media-type changes unsupported");
378        if (flags & MF_SOURCE_READERF_ENDOFSTREAM) { has_eos = true; return true; }
379        if (!sample) return true; // stream tick / no sample
380        ComPtr<IMFMediaBuffer> buf;
381        if (FAILED(sample->ConvertToContiguousBuffer(buf.GetAddressOf()))) return fail("Cannot obtain compressed sample");
382        BYTE* bytes = nullptr;
383        DWORD count = 0;
384        if (FAILED(buf->Lock(&bytes, nullptr, &count))) return fail("Compressed packet Lock failed");
385        // MF's MPEG-4 source typically presents Annex B, but handle MP4 length-prefixed packets too.
386        // The first sample decides which; every later sample must match it.
387        const NalFraming before = framing;
388        const bool converted = annexb_sample(bytes, count, bitstream, framing);
389        buf->Unlock();
390        if (!converted) {
391            return fail(before == NalFraming::AnnexB ? "Invalid compressed NAL units (stream started as Annex B)"
392                : before == NalFraming::LengthPrefixed ? "Invalid compressed NAL units (stream started with four-byte lengths)"
393                : "Invalid compressed NAL units (expected Annex B or four-byte lengths)");
394        }
395        if (before == NalFraming::Unknown) {
396            std::printf("[mf] NAL framing: %s\n", framing == NalFraming::AnnexB ? "Annex B" : "four-byte lengths");
397            std::fflush(stdout);
398        }
399        if (bitstream.size() > UINT32_MAX) return fail("Compressed packet too large");
400        CUVIDSOURCEDATAPACKET pkt = {};
401        pkt.flags = CUVID_PKT_TIMESTAMP;
402        pkt.timestamp = timestamp; // MF sample time and CUVID timestamps both use 100ns ticks.
403        pkt.payload = bitstream.data();
404        pkt.payload_size = (unsigned long)bitstream.size();
405        if (!push()) return false;
406        const bool ok = check(cuvidParseVideoData(parser, &pkt), "cuvidParseVideoData(packet)");
407        pop();
408        return ok;
409    }
410    // Return 1 on success, -1 on error. out_pts: presentation time in 100ns units.
411    int next(int* out_slot, int64_t* out_pts) {
412        if (!out_slot || !out_pts) { fail("Null frame result"); return -1; }
413        // Read compressed packets only until a display-order frame becomes available.
414        // Consume that frame before submitting additional packets: protects NVDEC output surfaces.
415        for (unsigned steps = 0; decoded.empty(); ++steps) {
416            if (steps > 100000) { fail("No decodable video frames"); return -1; }
417            if (!has_eos) {
418                if (!read_packet()) return -1;
419            } else if (!flushed) {
420                CUVIDSOURCEDATAPACKET eos = {};
421                eos.flags = CUVID_PKT_ENDOFSTREAM;
422                if (!push()) return -1;
423                bool ok = check(cuvidParseVideoData(parser, &eos), "cuvidParseVideoData(EOS)");
424                pop();
425                if (!ok) return -1;
426                flushed = true;
427            } else {
428                if (!rewind_video()) return -1;
429            }
430        }
431        CUVIDPARSERDISPINFO display = decoded.front();
432        decoded.pop_front();
433        const int slot_index = (current_slot + 1) % kSlots;
434        if (!push()) return -1;
435        CUVIDPROCPARAMS process = {};
436        process.progressive_frame = display.progressive_frame;
437        process.top_field_first = display.top_field_first;
438        process.second_field = 0;
439        process.unpaired_field = display.repeat_first_field < 0;
440        CUdeviceptr device_frame = 0;
441        unsigned pitch = 0;
442        bool ok = check(cuvidMapVideoFrame(decoder, display.picture_index,
443            &device_frame, &pitch, &process), "cuvidMapVideoFrame");
444        if (ok) {
445            const dim3 threads(16, 16);
446            const dim3 blocks((width + 15) / 16, (height + 15) / 16);
447            nv12_to_rgba<<<blocks, threads>>>(slots[slot_index].surface,
448                reinterpret_cast<const uint8_t*>(device_frame), pitch, width, height);
449            ok = check_cuda(cudaGetLastError(), "CUDA color-convert launch") &&
450                 check_cuda(cudaDeviceSynchronize(), "CUDA color-convert synchronize");
451            // Unmap only once the CUDA kernel has finished accessing NVDEC memory.
452            const bool unmapped = check(cuvidUnmapVideoFrame(decoder, device_frame), "cuvidUnmapVideoFrame");
453            ok = unmapped && ok;
454        }
455        pop();
456        if (!ok) return -1;
457        current_slot = slot_index;
458        if (first_timestamp < 0) first_timestamp = display.timestamp;
459        last_pts = std::max<int64_t>(0, display.timestamp - first_timestamp);
460        *out_slot = slot_index;
461        *out_pts = last_pts;
462        return 1;
463    }
464};
465
466extern "C" {
467__declspec(dllexport) Bridge* nv_open(const char* path, void* d3d_device) {
468    std::unique_ptr<Bridge> bridge(new (std::nothrow) Bridge());
469    if (!bridge || !bridge->init(path, reinterpret_cast<ID3D12Device*>(d3d_device))) return nullptr;
470    return bridge.release();
471}
472__declspec(dllexport) int nv_width(Bridge* b) { return b ? (int)b->width : 0; }
473__declspec(dllexport) int nv_height(Bridge* b) { return b ? (int)b->height : 0; }
474__declspec(dllexport) void* nv_texture(Bridge* b, int index) {
475    if (!b || index < 0 || index >= kSlots) return nullptr;
476    ID3D12Resource* texture = b->slots[index].texture.Get();
477    if (texture) texture->AddRef(); // Rust from_raw takes this additional reference.
478    return texture;
479}
480__declspec(dllexport) int nv_next(Bridge* b, int* slot, int64_t* pts) {
481    return b ? b->next(slot, pts) : -1;
482}
483__declspec(dllexport) const char* nv_error(Bridge* b) { return b ? b->error.c_str() : "No video bridge"; }
484__declspec(dllexport) void nv_close(Bridge* b) { delete b; }
485}

native/bitstream.h

A header-only file with no Windows or CUDA in it, on purpose: that's what lets it be tested anywhere. Four functions build on each other. The first two recognize start codes:

1#pragma once
2#include <cstddef>
3#include <cstdint>
4#include <vector>
5
6// How a stream's samples separate NAL units: Annex B start codes, or the MP4
7// length prefixes (avcC/hvcC style). One track uses one framing throughout.
8enum class NalFraming { Unknown, AnnexB, LengthPrefixed };
9
10// Bytes taken by a start code at p (00 00 01, or more zeros then 01), 0 if none.
11inline size_t start_code_length(const uint8_t* p, size_t n) {
12    size_t zeros = 0;
13    while (zeros < n && p[zeros] == 0) ++zeros;
14    return zeros >= 2 && zeros < n && p[zeros] == 1 ? zeros + 1 : 0;
15}
16
17// A start code followed by at least one byte of NAL unit.
18inline bool annexb_prefix(const uint8_t* p, size_t n) {
19    const size_t prefix = start_code_length(p, n);
20    return prefix && prefix < n;
21}

start_code_length counts leading zero bytes and expects a 01 after at least two of them. Accepting two or more zeros rather than exactly two or three matters: the H.264 byte-stream format allows extra zero_bytes before a start code, and some muxers emit them. annexb_prefix adds the requirement that something follows the start code, so a sample that is only a start code is rejected rather than passed to the decoder as an empty NAL unit.

Lines 23–48 are length_prefixed and detect_framing, which Step 3 showed. The last function is the one the bridge calls:

50// Normalizes one sample to Annex B in out. With framing Unknown, it's detected
51// from this sample and stored, so the caller keeps it for the rest of the
52// stream; after that, every sample must parse in that framing. Malformed input
53// is rejected rather than guessed at.
54inline bool annexb_sample(const uint8_t* p, size_t n, std::vector<uint8_t>& out,
55                          NalFraming& framing, unsigned nal_len_bytes = 4) {
56    out.clear();
57    if (!n) return false;
58    const NalFraming kind = framing == NalFraming::Unknown ? detect_framing(p, n, nal_len_bytes) : framing;
59    if (kind == NalFraming::AnnexB) {
60        if (!annexb_prefix(p, n)) return false;
61        out.assign(p, p + n);
62    } else if (kind == NalFraming::LengthPrefixed) {
63        if (!length_prefixed(p, n, nal_len_bytes)) return false;
64        while (n) {
65            size_t count = 0;
66            for (unsigned i = 0; i < nal_len_bytes; ++i) count = (count << 8) | p[i];
67            p += nal_len_bytes;
68            n -= nal_len_bytes;
69            out.insert(out.end(), {0, 0, 0, 1});
70            out.insert(out.end(), p, p + count);
71            p += count;
72            n -= count;
73        }
74    } else {
75        return false;
76    }
77    framing = kind;
78    return true;
79}

framing is an in-out parameter. If the caller passes Unknown, line 58 detects the framing from this sample; otherwise the caller's value is used, and the sample has to match it (lines 60 and 63) or the function fails. Annex B input is copied through unchanged (line 61). Length-prefixed input is rebuilt NAL unit by NAL unit, with each length prefix replaced by a 4-byte start code (lines 64–73). Only on success does line 77 write the detected framing back, so a rejected first sample doesn't lock the stream into a bad guess. The whole file:

1#pragma once
2#include <cstddef>
3#include <cstdint>
4#include <vector>
5
6// How a stream's samples separate NAL units: Annex B start codes, or the MP4
7// length prefixes (avcC/hvcC style). One track uses one framing throughout.
8enum class NalFraming { Unknown, AnnexB, LengthPrefixed };
9
10// Bytes taken by a start code at p (00 00 01, or more zeros then 01), 0 if none.
11inline size_t start_code_length(const uint8_t* p, size_t n) {
12    size_t zeros = 0;
13    while (zeros < n && p[zeros] == 0) ++zeros;
14    return zeros >= 2 && zeros < n && p[zeros] == 1 ? zeros + 1 : 0;
15}
16
17// A start code followed by at least one byte of NAL unit.
18inline bool annexb_prefix(const uint8_t* p, size_t n) {
19    const size_t prefix = start_code_length(p, n);
20    return prefix && prefix < n;
21}
22
23// True when nal_len_bytes-byte lengths tile the whole sample exactly, every
24// NAL unit is non-empty, and every NAL header has its forbidden_zero_bit (the
25// top bit, in both H.264 and HEVC) clear.
26inline bool length_prefixed(const uint8_t* p, size_t n, unsigned nal_len_bytes) {
27    if (!n || !(nal_len_bytes == 1 || nal_len_bytes == 2 || nal_len_bytes == 4)) return false;
28    while (n) {
29        if (n < nal_len_bytes) return false;
30        size_t count = 0;
31        for (unsigned i = 0; i < nal_len_bytes; ++i) count = (count << 8) | p[i];
32        p += nal_len_bytes;
33        n -= nal_len_bytes;
34        if (count == 0 || count > n || (p[0] & 0x80)) return false;
35        p += count;
36        n -= count;
37    }
38    return true;
39}
40
41// Which framing one sample uses. The length check wins: a 4-byte length of 1
42// or 256-511 starts with 00 00 00 01 / 00 00 01 xx, which looks exactly like a
43// start code, while real Annex B data practically never tiles as lengths.
44inline NalFraming detect_framing(const uint8_t* p, size_t n, unsigned nal_len_bytes = 4) {
45    if (length_prefixed(p, n, nal_len_bytes)) return NalFraming::LengthPrefixed;
46    if (annexb_prefix(p, n)) return NalFraming::AnnexB;
47    return NalFraming::Unknown;
48}
49
50// Normalizes one sample to Annex B in out. With framing Unknown, it's detected
51// from this sample and stored, so the caller keeps it for the rest of the
52// stream; after that, every sample must parse in that framing. Malformed input
53// is rejected rather than guessed at.
54inline bool annexb_sample(const uint8_t* p, size_t n, std::vector<uint8_t>& out,
55                          NalFraming& framing, unsigned nal_len_bytes = 4) {
56    out.clear();
57    if (!n) return false;
58    const NalFraming kind = framing == NalFraming::Unknown ? detect_framing(p, n, nal_len_bytes) : framing;
59    if (kind == NalFraming::AnnexB) {
60        if (!annexb_prefix(p, n)) return false;
61        out.assign(p, p + n);
62    } else if (kind == NalFraming::LengthPrefixed) {
63        if (!length_prefixed(p, n, nal_len_bytes)) return false;
64        while (n) {
65            size_t count = 0;
66            for (unsigned i = 0; i < nal_len_bytes; ++i) count = (count << 8) | p[i];
67            p += nal_len_bytes;
68            n -= nal_len_bytes;
69            out.insert(out.end(), {0, 0, 0, 1});
70            out.insert(out.end(), p, p + count);
71            p += count;
72            n -= count;
73        }
74    } else {
75        return false;
76    }
77    framing = kind;
78    return true;
79}

native/tests/bitstream_test.cpp

A plain C++ program with asserts. Its helpers build test input without hand-typing hundreds of bytes:

1#include "../bitstream.h"
2#include <cassert>
3#include <cstdio>
4
5using Bytes = std::vector<uint8_t>;
6
7// Converts one sample as the first of a new stream.
8static bool convert(const Bytes& in, Bytes& out, unsigned nal_len_bytes = 4) {
9    NalFraming framing = NalFraming::Unknown;
10    return annexb_sample(in.data(), in.size(), out, framing, nal_len_bytes);
11}
12
13// A 4-byte length prefix followed by a NAL unit of `size` bytes (header 0x41).
14static Bytes length_nal(size_t size) {
15    Bytes nal = {uint8_t(size >> 24), uint8_t(size >> 16), uint8_t(size >> 8), uint8_t(size), 0x41};
16    for (size_t i = 1; i < size; ++i) nal.push_back(uint8_t(0x10 + i % 0x60));
17    return nal;
18}
19
20static Bytes annexb_of(const Bytes& lengths_sample) {
21    Bytes out;
22    size_t i = 0;
23    while (i < lengths_sample.size()) {
24        const size_t count = (size_t(lengths_sample[i]) << 24) | (size_t(lengths_sample[i + 1]) << 16) |
25            (size_t(lengths_sample[i + 2]) << 8) | lengths_sample[i + 3];
26        out.insert(out.end(), {0, 0, 0, 1});
27        out.insert(out.end(), lengths_sample.begin() + i + 4, lengths_sample.begin() + i + 4 + count);
28        i += 4 + count;
29    }
30    return out;
31}

convert runs one sample as if it were the first of a stream. length_nal builds a length-prefixed NAL unit of any size; the header byte 0x41 is a valid H.264 NAL header (forbidden bit clear, type 1, a non-IDR slice), which matters because length_prefixed checks that bit. annexb_of is an independent, deliberately simple conversion to compare the real one against.

33int main() {
34    Bytes out;
35
36    // Annex B passes through unchanged, with 4- and 3-byte start codes.
37    const Bytes annex = {0,0,0,1,0x67,0x64,0,0,0,1,0x68};
38    assert(convert(annex, out) && out == annex);
39    const Bytes annex3 = {0,0,1,0x67,0x64,0,0,1,0x68};
40    assert(convert(annex3, out) && out == annex3);
41
42    // MP4 length prefixes of 4, 2 and 1 bytes become start codes.
43    const Bytes expected = {0,0,0,1,0x67,0x64,0,0,0,1,0x68};
44    assert(convert({0,0,0,2,0x67,0x64,0,0,0,1,0x68}, out) && out == expected);
45    assert(convert({0,2,0x67,0x64,0,1,0x68}, out, 2) && out == expected);
46    assert(convert({2,0x67,0x64,1,0x68}, out, 1) && out == expected);
47
48    // The old misread: 4-byte lengths of 256-511 start 00 00 01 xx and a length
49    // of 1 starts 00 00 00 01, both of which look like start codes.
50    Bytes len300 = length_nal(300);
51    const Bytes tail = length_nal(5);
52    len300.insert(len300.end(), tail.begin(), tail.end());
53    assert(len300[0] == 0 && len300[1] == 0 && len300[2] == 1 && annexb_prefix(len300.data(), len300.size()));
54    assert(convert(len300, out) && out == annexb_of(len300));
55    Bytes len1 = {0,0,0,1,0x09};
56    const Bytes slice = length_nal(40);
57    len1.insert(len1.end(), slice.begin(), slice.end());
58    assert(convert(len1, out) && out == annexb_of(len1));
59
60    // The first sample fixes the framing for the stream.
61    NalFraming framing = NalFraming::Unknown;
62    assert(annexb_sample(annex.data(), annex.size(), out, framing) && framing == NalFraming::AnnexB);
63    assert(annexb_sample(len300.data(), len300.size(), out, framing) && out == len300); // kept as Annex B
64    framing = NalFraming::Unknown;
65    assert(annexb_sample(len1.data(), len1.size(), out, framing) && framing == NalFraming::LengthPrefixed);
66    assert(!annexb_sample(annex.data(), annex.size(), out, framing)); // Annex B can't follow lengths
67    assert(framing == NalFraming::LengthPrefixed);
68
69    // Malformed input is rejected.
70    assert(!convert({0,0,0,20,0x68}, out));           // length past the end
71    assert(!convert({}, out));                         // empty
72    assert(!convert({0,0,0,0}, out));                  // zero length, no start code
73    assert(!convert({0,0,1}, out));                    // start code with no NAL unit
74    assert(!convert({0,0,0,2,0xE7,0x64,0x22}, out));   // tiles, but forbidden_zero_bit set, and no start code
75    assert(!convert({0,0,0,2,0x67,0x64}, out, 3));     // unsupported prefix size
76
77    std::puts("bitstream normalization tests passed");
78}

The cases follow the comments: Annex B passes through with 4- and 3-byte start codes; length prefixes of 4, 2 and 1 bytes convert; then the regression cases for the misread from Step 3. Line 53 is the important assertion there: it checks that the 300-byte sample really does start with what looks like a start code, so the test is exercising the ambiguity and not just a normal conversion. Lines 60–67 test the framing lock in both directions, and the last block covers malformed input. The whole file:

1#include "../bitstream.h"
2#include <cassert>
3#include <cstdio>
4
5using Bytes = std::vector<uint8_t>;
6
7// Converts one sample as the first of a new stream.
8static bool convert(const Bytes& in, Bytes& out, unsigned nal_len_bytes = 4) {
9    NalFraming framing = NalFraming::Unknown;
10    return annexb_sample(in.data(), in.size(), out, framing, nal_len_bytes);
11}
12
13// A 4-byte length prefix followed by a NAL unit of `size` bytes (header 0x41).
14static Bytes length_nal(size_t size) {
15    Bytes nal = {uint8_t(size >> 24), uint8_t(size >> 16), uint8_t(size >> 8), uint8_t(size), 0x41};
16    for (size_t i = 1; i < size; ++i) nal.push_back(uint8_t(0x10 + i % 0x60));
17    return nal;
18}
19
20static Bytes annexb_of(const Bytes& lengths_sample) {
21    Bytes out;
22    size_t i = 0;
23    while (i < lengths_sample.size()) {
24        const size_t count = (size_t(lengths_sample[i]) << 24) | (size_t(lengths_sample[i + 1]) << 16) |
25            (size_t(lengths_sample[i + 2]) << 8) | lengths_sample[i + 3];
26        out.insert(out.end(), {0, 0, 0, 1});
27        out.insert(out.end(), lengths_sample.begin() + i + 4, lengths_sample.begin() + i + 4 + count);
28        i += 4 + count;
29    }
30    return out;
31}
32
33int main() {
34    Bytes out;
35
36    // Annex B passes through unchanged, with 4- and 3-byte start codes.
37    const Bytes annex = {0,0,0,1,0x67,0x64,0,0,0,1,0x68};
38    assert(convert(annex, out) && out == annex);
39    const Bytes annex3 = {0,0,1,0x67,0x64,0,0,1,0x68};
40    assert(convert(annex3, out) && out == annex3);
41
42    // MP4 length prefixes of 4, 2 and 1 bytes become start codes.
43    const Bytes expected = {0,0,0,1,0x67,0x64,0,0,0,1,0x68};
44    assert(convert({0,0,0,2,0x67,0x64,0,0,0,1,0x68}, out) && out == expected);
45    assert(convert({0,2,0x67,0x64,0,1,0x68}, out, 2) && out == expected);
46    assert(convert({2,0x67,0x64,1,0x68}, out, 1) && out == expected);
47
48    // The old misread: 4-byte lengths of 256-511 start 00 00 01 xx and a length
49    // of 1 starts 00 00 00 01, both of which look like start codes.
50    Bytes len300 = length_nal(300);
51    const Bytes tail = length_nal(5);
52    len300.insert(len300.end(), tail.begin(), tail.end());
53    assert(len300[0] == 0 && len300[1] == 0 && len300[2] == 1 && annexb_prefix(len300.data(), len300.size()));
54    assert(convert(len300, out) && out == annexb_of(len300));
55    Bytes len1 = {0,0,0,1,0x09};
56    const Bytes slice = length_nal(40);
57    len1.insert(len1.end(), slice.begin(), slice.end());
58    assert(convert(len1, out) && out == annexb_of(len1));
59
60    // The first sample fixes the framing for the stream.
61    NalFraming framing = NalFraming::Unknown;
62    assert(annexb_sample(annex.data(), annex.size(), out, framing) && framing == NalFraming::AnnexB);
63    assert(annexb_sample(len300.data(), len300.size(), out, framing) && out == len300); // kept as Annex B
64    framing = NalFraming::Unknown;
65    assert(annexb_sample(len1.data(), len1.size(), out, framing) && framing == NalFraming::LengthPrefixed);
66    assert(!annexb_sample(annex.data(), annex.size(), out, framing)); // Annex B can't follow lengths
67    assert(framing == NalFraming::LengthPrefixed);
68
69    // Malformed input is rejected.
70    assert(!convert({0,0,0,20,0x68}, out));           // length past the end
71    assert(!convert({}, out));                         // empty
72    assert(!convert({0,0,0,0}, out));                  // zero length, no start code
73    assert(!convert({0,0,1}, out));                    // start code with no NAL unit
74    assert(!convert({0,0,0,2,0xE7,0x64,0x22}, out));   // tiles, but forbidden_zero_bit set, and no start code
75    assert(!convert({0,0,0,2,0x67,0x64}, out, 3));     // unsupported prefix size
76
77    std::puts("bitstream normalization tests passed");
78}

src/video.rs

145 lines: the Rust side of the bridge. It knows about D3D12 resources and wgpu, and nothing about Media Foundation or CUDA.

1use std::{
2    ffi::{c_char, c_void, CStr, CString},
3    ptr::NonNull,
4    time::{Duration, Instant},
5};
6use windows::{core::Interface, Win32::Graphics::Direct3D12::ID3D12Resource};
7
8const SLOTS: usize = 3;

The ffi imports are for the C boundary: CString to pass the path in, CStr to read error messages out, and the raw pointer types. NonNull is a pointer that can't be null, which is how Video holds the bridge once nv_open has succeeded. From the windows crate it needs just ID3D12Resource and the Interface trait that provides from_raw. SLOTS on line 8 has to agree with kSlots in the bridge; nothing enforces that either.

The rest of the file, in order:

src/video.rs, by line
LinesWhatWhere
10–20The extern "C" declarationsStep 8
22–37DecodedFrame, Video and duration_100nsStep 9
40–52decode: nv_next as a ResultStep 9
54–108open: the bridge, the first two frames and the wgpu-hal importStep 9
110current_slotStep 10
112–136update: frame timing and the GPU waitStep 11
139–145DropStep 9

The whole file:

1use std::{
2    ffi::{c_char, c_void, CStr, CString},
3    ptr::NonNull,
4    time::{Duration, Instant},
5};
6use windows::{core::Interface, Win32::Graphics::Direct3D12::ID3D12Resource};
7
8const SLOTS: usize = 3;
9
10#[repr(C)]
11struct VideoHandle { _opaque: [u8; 0] }
12unsafe extern "C" {
13    fn nv_open(filename: *const c_char, device: *mut c_void) -> *mut VideoHandle;
14    fn nv_width(ptr: *mut VideoHandle) -> i32;
15    fn nv_height(ptr: *mut VideoHandle) -> i32;
16    fn nv_texture(ptr: *mut VideoHandle, slot: i32) -> *mut c_void;
17    fn nv_next(ptr: *mut VideoHandle, slot: *mut i32, pts: *mut i64) -> i32;
18    fn nv_error(ptr: *mut VideoHandle) -> *const c_char;
19    fn nv_close(ptr: *mut VideoHandle);
20}
21
22#[derive(Clone, Copy)]
23struct DecodedFrame { slot: usize, pts_100ns: i64 }
24
25pub struct Video {
26    native: NonNull<VideoHandle>,
27    pub textures: Vec<wgpu::Texture>,
28    current: DecodedFrame,
29    pending: DecodedFrame,
30    start: Instant,
31    next_due: Instant,
32    fallback_interval: Duration,
33}
34
35fn duration_100ns(ticks: i64) -> Duration {
36    Duration::from_nanos((ticks.max(0) as u64).saturating_mul(100))
37}
38
39impl Video {
40    fn decode(handle: NonNull<VideoHandle>) -> Result<DecodedFrame, String> {
41        let mut slot = -1;
42        let mut pts = -1_i64;
43        if unsafe { nv_next(handle.as_ptr(), &mut slot, &mut pts) } != 1 {
44            let ptr = unsafe { nv_error(handle.as_ptr()) };
45            return Err(if ptr.is_null() { "Native NVDEC decoder error".into() }
46                else { unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned() });
47        }
48        if !(0..SLOTS as i32).contains(&slot) {
49            return Err(format!("Native NVDEC returned invalid texture slot {slot}"));
50        }
51        Ok(DecodedFrame { slot: slot as usize, pts_100ns: pts })
52    }
53
54    pub fn open(device: &wgpu::Device, filename: &str) -> Result<Self, String> {
55        let hal = unsafe { device.as_hal::<wgpu::hal::api::Dx12>() }
56            .ok_or("wgpu must use the D3D12 backend")?;
57        let file = CString::new(filename).map_err(|e| e.to_string())?;
58        let native = NonNull::new(unsafe { nv_open(file.as_ptr(), hal.raw_device().as_raw()) })
59            .ok_or("Media Foundation / Video Codec SDK initialization failed (see stderr)")?;
60        // Decode initial + lookahead frame before importing the D3D12 resources into wgpu.
61        // On failure, always release the native decoder and CUDA resources.
62        let result = (|| {
63            let width = unsafe { nv_width(native.as_ptr()) } as u32;
64            let height = unsafe { nv_height(native.as_ptr()) } as u32;
65            if width == 0 || height == 0 { return Err("Invalid video dimensions".to_owned()); }
66            let current = Self::decode(native)?;
67            let pending = Self::decode(native)?;
68            let mut textures = Vec::with_capacity(SLOTS);
69            for slot in 0..SLOTS {
70                let resource_ptr = unsafe { nv_texture(native.as_ptr(), slot as i32) };
71                if resource_ptr.is_null() { return Err(format!("D3D12 resource slot {slot} missing")); }
72                // C++ AddRef's, Rust from_raw takes ownership of the additional COM reference.
73                let resource = unsafe { ID3D12Resource::from_raw(resource_ptr) };
74                let desc = wgpu::TextureDescriptor {
75                    label: Some("Video Codec SDK CUDA-shared RGBA texture"),
76                    size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
77                    mip_level_count: 1,
78                    sample_count: 1,
79                    dimension: wgpu::TextureDimension::D2,
80                    format: wgpu::TextureFormat::Rgba8Unorm,
81                    usage: wgpu::TextureUsages::TEXTURE_BINDING,
82                    view_formats: &[],
83                };
84                let wrapped = unsafe {
85                    wgpu::hal::dx12::Device::texture_from_raw(
86                        resource,
87                        desc.format,
88                        desc.dimension,
89                        desc.size,
90                        desc.mip_level_count,
91                        desc.sample_count,
92                    )
93                };
94                let tex = unsafe { device.create_texture_from_hal::<wgpu::hal::api::Dx12>(
95                    wrapped, &desc, wgpu::TextureUses::RESOURCE) };
96                textures.push(tex);
97            }
98            let start = Instant::now();
99            let fallback_interval = Duration::from_millis(33);
100            let due = if pending.pts_100ns > current.pts_100ns {
101                duration_100ns(pending.pts_100ns)
102            } else { fallback_interval };
103            Ok(Self { native, textures, current, pending, start,
104                next_due: start + due, fallback_interval })
105        })();
106        if result.is_err() { unsafe { nv_close(native.as_ptr()) }; }
107        result
108    }
109
110    pub fn current_slot(&self) -> usize { self.current.slot }
111
112    pub fn update(&mut self, device: &wgpu::Device) -> Result<bool, String> {
113        let now = Instant::now();
114        if now < self.next_due { return Ok(false); }
115        // Safe first milestone. Serializes CUDA writes against *all* queued wgpu reads.
116        // A future revision can replace this with CUDA-imported D3D12 fences per slot.
117        device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None })
118            .map_err(|e| e.to_string())?;
119        let previous_pts = self.current.pts_100ns;
120        self.current = self.pending;
121        if self.current.pts_100ns <= previous_pts {
122            // Video loop: the native reader rewound to the beginning.
123            self.start = now;
124        }
125        let new_pending = Self::decode(self.native)?;
126        let delta = new_pending.pts_100ns - self.current.pts_100ns;
127        if delta > 0 {
128            self.fallback_interval = duration_100ns(delta).min(Duration::from_secs(1));
129            self.next_due = self.start + duration_100ns(new_pending.pts_100ns);
130        } else {
131            // Next packet may be from the next video loop, or contain a duplicate timestamp.
132            self.next_due = now + self.fallback_interval;
133        }
134        self.pending = new_pending;
135        Ok(true)
136    }
137}
138
139impl Drop for Video {
140    fn drop(&mut self) {
141        // Caller waits for GPU idle before dropping Video/State. The COM resource
142        // refs owned by wgpu outlive the bridge if other bind groups retain them.
143        unsafe { nv_close(self.native.as_ptr()) };
144    }
145}

src/main.rs

341 lines, and the most ordinary file in the project: a winit window, a wgpu device and a textured cube. The video appears in exactly three places: Video::open in setup, the bind groups, and video.update plus current_slot in draw. Everything else would be the same in any wgpu program.

1#![cfg(target_os = "windows")]
2mod video;
3
4use std::{sync::Arc, time::{Duration, Instant}};
5use bytemuck::{Pod, Zeroable};
6use glam::{Mat4, Vec3};
7use wgpu::util::DeviceExt;
8use winit::{
9    application::ApplicationHandler,
10    event::WindowEvent,
11    event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
12    window::{Window, WindowAttributes, WindowId},
13};

Line 1 makes the whole program Windows-only at the compiler level, matching the check in build.rs. Line 2 pulls in video.rs as a module. The rest are the four crates: bytemuck for turning structs into bytes, glam for matrices, wgpu's DeviceExt for create_buffer_init, and winit's 0.30 application API.

15#[repr(C)]
16#[derive(Clone, Copy, Pod, Zeroable)]
17struct Vertex { pos: [f32; 3], uv: [f32; 2] }
18#[repr(C)]
19#[derive(Clone, Copy, Pod, Zeroable)]
20struct Uniform { mvp: [[f32; 4]; 4] }
21
22fn cube() -> (Vec<Vertex>, Vec<u16>) {
23    let faces = [
24        [[-1., -1.,  1.], [ 1., -1.,  1.], [ 1.,  1.,  1.], [-1.,  1.,  1.]],
25        [[ 1., -1., -1.], [-1., -1., -1.], [-1.,  1., -1.], [ 1.,  1., -1.]],
26        [[-1.,  1.,  1.], [ 1.,  1.,  1.], [ 1.,  1., -1.], [-1.,  1., -1.]],
27        [[-1., -1., -1.], [ 1., -1., -1.], [ 1., -1.,  1.], [-1., -1.,  1.]],
28        [[ 1., -1.,  1.], [ 1., -1., -1.], [ 1.,  1., -1.], [ 1.,  1.,  1.]],
29        [[-1., -1., -1.], [-1., -1.,  1.], [-1.,  1.,  1.], [-1.,  1., -1.]],
30    ];
31    let uv = [[0., 1.], [1., 1.], [1., 0.], [0., 0.]];
32    let mut vertices = Vec::with_capacity(24);
33    let mut indices = Vec::with_capacity(36);
34    for face in faces {
35        let start = vertices.len() as u16;
36        for i in 0..4 { vertices.push(Vertex { pos: face[i], uv: uv[i] }); }
37        indices.extend_from_slice(&[start, start + 1, start + 2, start, start + 2, start + 3]);
38    }
39    (vertices, indices)
40}

Vertex and Uniform are the two structs that cross into the GPU. #[repr(C)] fixes their memory layout so it matches what the shader expects, and Pod and Zeroable are bytemuck's promise that the struct is plain bytes with no padding or pointers, which is what lets cast_slice and bytes_of hand them to wgpu without copying.

cube builds the geometry. Each of the six faces (lines 24–29) is four corners, listed counter-clockwise as seen from outside. Every face gets the same texture coordinates (line 31): v = 1 at the bottom edge and 0 at the top, because row 0 of the texture is the top of the picture, so the video comes out upright on each face. Lines 34–38 turn each face into four vertices and two triangles (six indices), for 24 vertices and 36 indices in total. The faces share no vertices, because each corner needs a different texture coordinate on each face it belongs to.

Lines 42–50 are background from Step 10. Then the depth buffer:

52fn depth_texture(device: &wgpu::Device, size: winit::dpi::PhysicalSize<u32>) -> wgpu::TextureView {
53    device.create_texture(&wgpu::TextureDescriptor {
54        label: Some("Cube depth"),
55        size: wgpu::Extent3d { width: size.width.max(1), height: size.height.max(1), depth_or_array_layers: 1 },
56        mip_level_count: 1,
57        sample_count: 1,
58        dimension: wgpu::TextureDimension::D2,
59        format: wgpu::TextureFormat::Depth24Plus,
60        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
61        view_formats: &[],
62    }).create_view(&Default::default())
63}

The pipeline doesn't cull back faces (line 201 below), so without a depth buffer the far faces of the cube could draw over the near ones depending on order. A Depth24Plus texture the size of the window fixes that, and because it's tied to the window size, resize recreates it. The max(1) guards against a zero-sized texture while the window is minimized.

65struct State {
66    window: Arc<Window>,
67    surface: wgpu::Surface<'static>,
68    device: wgpu::Device,
69    queue: wgpu::Queue,
70    config: wgpu::SurfaceConfiguration,
71    pipeline: wgpu::RenderPipeline,
72    depth: wgpu::TextureView,
73    vertices: wgpu::Buffer,
74    indices: wgpu::Buffer,
75    uniform: wgpu::Buffer,
76    bind_groups: Vec<wgpu::BindGroup>,
77    clear: wgpu::Color,
78    start: Instant,
79    video: video::Video,
80}

State is everything that exists once the window is up. Field order matters at the end: Rust drops fields in declaration order after Drop::drop runs, so video, declared last, is dropped last, after the GPU wait in State's Drop below.

State::new starts on line 83. Lines 84–105 pick the adapter and create the device (Step 4), line 107 opens the video (Step 9; it needs only the device, which is why it can come before the swapchain), and lines 109–115 choose the swapchain format (Step 10). The configuration itself:

116let size = window.inner_size();
117let config = wgpu::SurfaceConfiguration {
118    usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
119    format,
120    width: size.width.max(1),
121    height: size.height.max(1),
122    present_mode: wgpu::PresentMode::Fifo,
123    alpha_mode: caps.alpha_modes[0],
124    view_formats: vec![],
125    desired_maximum_frame_latency: 2,
126    color_space: wgpu::SurfaceColorSpace::Auto,
127};
128surface.configure(&device, &config);
129let depth = depth_texture(&device, size);

Fifo is vsync: frames are presented in order at the display's refresh rate, which is the right choice for video. A maximum frame latency of 2 lets the CPU run one frame ahead of the GPU without letting it run away. alpha_modes[0] is whatever the surface supports first, since the window is opaque.

130let (v, i) = cube();
131let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
132    label: Some("Cube VB"), contents: bytemuck::cast_slice(&v), usage: wgpu::BufferUsages::VERTEX,
133});
134let indices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
135    label: Some("Cube IB"), contents: bytemuck::cast_slice(&i), usage: wgpu::BufferUsages::INDEX,
136});
137let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
138    label: Some("MVP uniform"),
139    contents: bytemuck::bytes_of(&Uniform { mvp: Mat4::IDENTITY.to_cols_array_2d() }),
140    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
141});

Three GPU buffers, created with their initial contents. The vertex and index buffers never change. The uniform buffer holds the model-view-projection matrix and is written every frame, so it needs COPY_DST; it starts as the identity matrix and draw overwrites it before the first use.

142let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
143    label: Some("Video + MVP"),
144    entries: &[
145        wgpu::BindGroupLayoutEntry {
146            binding: 0, visibility: wgpu::ShaderStages::VERTEX,
147            ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
148            count: None,
149        },
150        wgpu::BindGroupLayoutEntry {
151            binding: 1, visibility: wgpu::ShaderStages::FRAGMENT,
152            ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false },
153            count: None,
154        },
155        wgpu::BindGroupLayoutEntry {
156            binding: 2, visibility: wgpu::ShaderStages::FRAGMENT,
157            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None,
158        },
159    ],
160});
161let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
162    mag_filter: wgpu::FilterMode::Linear,
163    min_filter: wgpu::FilterMode::Linear,
164    ..Default::default()
165});

The bind group layout is the contract between this file and the shader: binding 0 is a uniform buffer the vertex stage reads, binding 1 is a filterable 2D float texture the fragment stage samples, and binding 2 is the sampler. The numbers match the @binding attributes in cube.wgsl; the filterable and Filtering flags pair with the linear sampler on lines 161–165, which smooths the 320×180 video when a face is drawn larger than that.

Lines 166–176 build one bind group per slot (Step 10). Lines 177–180 compile the shader, which is embedded in the executable with include_str!. Then the pipeline:

177let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
178    label: Some("Cube WGSL"),
179    source: wgpu::ShaderSource::Wgsl(include_str!("cube.wgsl").into()),
180});
181let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
182    label: Some("Video cube pipeline layout"), bind_group_layouts: &[Some(&layout)], immediate_size: 0,
183});
184let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
185    label: Some("Video cube pipeline"),
186    layout: Some(&pipeline_layout),
187    vertex: wgpu::VertexState {
188        module: &shader, entry_point: Some("vs_main"),
189        buffers: &[Some(wgpu::VertexBufferLayout {
190            array_stride: std::mem::size_of::<Vertex>() as u64,
191            step_mode: wgpu::VertexStepMode::Vertex,
192            attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
193        })],
194        compilation_options: Default::default(),
195    },
196    fragment: Some(wgpu::FragmentState {
197        module: &shader, entry_point: Some("fs_main"),
198        targets: &[Some(wgpu::ColorTargetState { format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
199        compilation_options: Default::default(),
200    }),
201    primitive: wgpu::PrimitiveState { cull_mode: None, ..Default::default() },
202    depth_stencil: Some(wgpu::DepthStencilState {
203        format: wgpu::TextureFormat::Depth24Plus,
204        depth_write_enabled: Some(true),
205        depth_compare: Some(wgpu::CompareFunction::Less),
206        stencil: Default::default(),
207        bias: Default::default(),
208    }),
209    multisample: Default::default(),
210    multiview_mask: None,
211    cache: None,
212});

Most of this is wgpu boilerplate, and three lines carry the decisions. Line 192 declares the vertex layout: a Float32x3 at location 0 and a Float32x2 at location 1, which is exactly the Vertex struct and exactly the @locations in the shader. Line 198 renders to the swapchain format chosen in Step 10, with no blending. Line 201 turns face culling off, so the cube draws the same whichever way its faces wind, and lines 202–208 enable the depth test (Less: a fragment wins if it's nearer than what's already there).

217fn resize(&mut self, s: winit::dpi::PhysicalSize<u32>) {
218    if s.width == 0 || s.height == 0 { return; }
219    self.config.width = s.width;
220    self.config.height = s.height;
221    self.surface.configure(&self.device, &self.config);
222    self.depth = depth_texture(&self.device, s);
223}

On resize, reconfigure the surface to the new size and rebuild the depth buffer to match. A zero size (minimized) is ignored rather than configured, which wgpu would reject.

225fn draw(&mut self) -> Result<(), Box<dyn std::error::Error>> {
226    // Presentation timestamps determine *when* to advance the video texture.
227    // The cube itself renders at the display refresh cadence.
228    self.video.update(&self.device)?;
229    let elapsed = self.start.elapsed().as_secs_f32();
230    let model = Mat4::from_rotation_y(elapsed * 0.8) * Mat4::from_rotation_x(elapsed * 0.43);
231    let view = Mat4::look_at_rh(Vec3::new(3.2, 2.1, 4.6), Vec3::ZERO, Vec3::Y);
232    let projection = Mat4::perspective_rh(45f32.to_radians(), self.config.width as f32 / self.config.height as f32, 0.1, 100.0);
233    let uniform = Uniform { mvp: (projection * view * model).to_cols_array_2d() };
234    self.queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));

draw runs once per redraw. Line 228 is the video's one hook: update decides whether the frame changes (Step 11), and a decode error ends the program through the ?. Lines 229–233 build the matrix: the model spins about two axes at different rates so no face stays hidden, the camera sits at (3.2, 2.1, 4.6) looking at the origin, and the projection is a 45° perspective at the window's aspect ratio. Line 234 uploads it.

235let frame = match self.surface.get_current_texture() {
236    wgpu::CurrentSurfaceTexture::Success(frame)
237    | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
238
239    wgpu::CurrentSurfaceTexture::Timeout
240    | wgpu::CurrentSurfaceTexture::Occluded => {
241        return Ok(());
242    }
243
244    wgpu::CurrentSurfaceTexture::Outdated => {
245        self.surface.configure(&self.device, &self.config);
246        return Ok(());
247    }
248
249    wgpu::CurrentSurfaceTexture::Lost => {
250        panic!("Surface lost; surface recreation required");
251    }
252
253    wgpu::CurrentSurfaceTexture::Validation => {
254        panic!("Surface validation error");
255    }
256};

Getting the next swapchain image can fail in ways that aren't errors. Timeout and Occluded (the window is hidden) just skip this frame. Outdated (the window changed under us) reconfigures and skips. The two cases that panic are the ones a sample program can't recover from; a real application would recreate the surface.

257let output = frame.texture.create_view(&Default::default());
258    let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Cube render") });
259    {
260        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
261            label: Some("Cube main pass"),
262            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
263                view: &output, resolve_target: None,
264                ops: wgpu::Operations { load: wgpu::LoadOp::Clear(self.clear), store: wgpu::StoreOp::Store },
265                depth_slice: None,
266            })],
267            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
268                view: &self.depth,
269                depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }),
270                stencil_ops: None,
271            }),
272            occlusion_query_set: None,
273            timestamp_writes: None,
274            multiview_mask: None,
275        });
276        pass.set_pipeline(&self.pipeline);
277        pass.set_bind_group(0, &self.bind_groups[self.video.current_slot()], &[]);
278        pass.set_vertex_buffer(0, self.vertices.slice(..));
279        pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint16);
280        pass.draw_indexed(0..36, 0, 0..1);
281    }
282    self.queue.submit(Some(encoder.finish()));
283    self.window.pre_present_notify();
284    self.queue.present(frame);
285    Ok(())
286}

One render pass: clear the color target to the background from Step 10 and the depth buffer to the far plane, bind the pipeline, bind the current slot's bind group (line 277, the other video hook), set the buffers, and draw the 36 indices. Line 282 submits the commands and line 284 presents the frame; pre_present_notify between them is a winit courtesy that lets the compositor time the frame.

289impl Drop for State {
290    fn drop(&mut self) {
291        let _ = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
292    }
293}

The GPU-idle wait that Step 9 promised. It runs before any field is dropped, so by the time video goes away and closes the bridge, no draw call is still sampling the textures.

295struct App {
296    path: String,
297    state: Option<State>,
298    next_frame: Instant,
299}
300impl ApplicationHandler for App {
301    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
302        if self.state.is_some() { return; }
303        let window = Arc::new(event_loop.create_window(WindowAttributes::default()
304            .with_title("wgpu cube — native NVDEC / CUDA / D3D12")
305            .with_inner_size(winit::dpi::LogicalSize::new(1024, 768))).unwrap());
306        let state = pollster::block_on(State::new(window, &self.path)).expect("Video cube initialization failed");
307        self.next_frame = Instant::now();
308        self.state = Some(state);
309    }
310    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
311        let Some(state) = self.state.as_mut() else { return; };
312        match event {
313            WindowEvent::CloseRequested => event_loop.exit(),
314            WindowEvent::Resized(s) => state.resize(s),
315            WindowEvent::RedrawRequested if state.config.width > 0 && state.config.height > 0 => {
316                if let Err(e) = state.draw() {
317                    eprintln!("Render/decode error: {e}");
318                    event_loop.exit();
319                }
320            }
321            _ => {}
322        }
323    }
324    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
325        if let Some(state) = &self.state {
326            if Instant::now() >= self.next_frame {
327                state.window.request_redraw();
328                self.next_frame = Instant::now() + Duration::from_millis(16);
329            }
330            event_loop.set_control_flow(ControlFlow::WaitUntil(self.next_frame));
331        }
332    }
333}

winit 0.30 drives the program through this handler. resumed (lines 301–309) creates the window and the State; State::new is async because wgpu's adapter and device requests are, and pollster::block_on simply waits for it, since there's nothing else to do yet. The guard on line 302 keeps a second resumed from building everything twice. window_event (lines 310–323) handles the three events that matter: close, resize, and redraw, where a failed draw prints the error and exits. about_to_wait (lines 324–332) is the frame pacing: when 16 ms have passed, request a redraw and schedule the next one, and tell winit to sleep until then rather than spin.

335fn main() -> Result<(), Box<dyn std::error::Error>> {
336    let path = std::env::args().nth(1).ok_or("Usage: cargo run --release -- <video.mp4>")?;
337    let event_loop = EventLoop::new()?;
338    let mut app = App { path, state: None, next_frame: Instant::now() };
339    event_loop.run_app(&mut app)?;
340    Ok(())
341}

main takes the video path from the command line and hands the handler to the event loop, which doesn't return until the window closes. The whole file:

1#![cfg(target_os = "windows")]
2mod video;
3
4use std::{sync::Arc, time::{Duration, Instant}};
5use bytemuck::{Pod, Zeroable};
6use glam::{Mat4, Vec3};
7use wgpu::util::DeviceExt;
8use winit::{
9    application::ApplicationHandler,
10    event::WindowEvent,
11    event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
12    window::{Window, WindowAttributes, WindowId},
13};
14
15#[repr(C)]
16#[derive(Clone, Copy, Pod, Zeroable)]
17struct Vertex { pos: [f32; 3], uv: [f32; 2] }
18#[repr(C)]
19#[derive(Clone, Copy, Pod, Zeroable)]
20struct Uniform { mvp: [[f32; 4]; 4] }
21
22fn cube() -> (Vec<Vertex>, Vec<u16>) {
23    let faces = [
24        [[-1., -1.,  1.], [ 1., -1.,  1.], [ 1.,  1.,  1.], [-1.,  1.,  1.]],
25        [[ 1., -1., -1.], [-1., -1., -1.], [-1.,  1., -1.], [ 1.,  1., -1.]],
26        [[-1.,  1.,  1.], [ 1.,  1.,  1.], [ 1.,  1., -1.], [-1.,  1., -1.]],
27        [[-1., -1., -1.], [ 1., -1., -1.], [ 1., -1.,  1.], [-1., -1.,  1.]],
28        [[ 1., -1.,  1.], [ 1., -1., -1.], [ 1.,  1., -1.], [ 1.,  1.,  1.]],
29        [[-1., -1., -1.], [-1., -1.,  1.], [-1.,  1.,  1.], [-1.,  1., -1.]],
30    ];
31    let uv = [[0., 1.], [1., 1.], [1., 0.], [0., 0.]];
32    let mut vertices = Vec::with_capacity(24);
33    let mut indices = Vec::with_capacity(36);
34    for face in faces {
35        let start = vertices.len() as u16;
36        for i in 0..4 { vertices.push(Vertex { pos: face[i], uv: uv[i] }); }
37        indices.extend_from_slice(&[start, start + 1, start + 2, start, start + 2, start + 3]);
38    }
39    (vertices, indices)
40}
41
42/// The window background, rgb(69, 80, 93), as clear values for `format`. Clear colors are
43/// linear on sRGB targets, so the display value is decoded there.
44fn background(format: wgpu::TextureFormat) -> wgpu::Color {
45    let [r, g, b] = [69.0, 80.0, 93.0].map(|v: f64| {
46        let c = v / 255.0;
47        if !format.is_srgb() { c } else if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
48    });
49    wgpu::Color { r, g, b, a: 1.0 }
50}
51
52fn depth_texture(device: &wgpu::Device, size: winit::dpi::PhysicalSize<u32>) -> wgpu::TextureView {
53    device.create_texture(&wgpu::TextureDescriptor {
54        label: Some("Cube depth"),
55        size: wgpu::Extent3d { width: size.width.max(1), height: size.height.max(1), depth_or_array_layers: 1 },
56        mip_level_count: 1,
57        sample_count: 1,
58        dimension: wgpu::TextureDimension::D2,
59        format: wgpu::TextureFormat::Depth24Plus,
60        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
61        view_formats: &[],
62    }).create_view(&Default::default())
63}
64
65struct State {
66    window: Arc<Window>,
67    surface: wgpu::Surface<'static>,
68    device: wgpu::Device,
69    queue: wgpu::Queue,
70    config: wgpu::SurfaceConfiguration,
71    pipeline: wgpu::RenderPipeline,
72    depth: wgpu::TextureView,
73    vertices: wgpu::Buffer,
74    indices: wgpu::Buffer,
75    uniform: wgpu::Buffer,
76    bind_groups: Vec<wgpu::BindGroup>,
77    clear: wgpu::Color,
78    start: Instant,
79    video: video::Video,
80}
81
82impl State {
83    async fn new(window: Arc<Window>, path: &str) -> Result<Self, Box<dyn std::error::Error>> {
84        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
85        desc.backends = wgpu::Backends::DX12;
86
87        let instance = wgpu::Instance::new(desc);
88        let surface = instance.create_surface(window.clone())?;
89        let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
90            power_preference: wgpu::PowerPreference::HighPerformance,
91            compatible_surface: Some(&surface),
92            force_fallback_adapter: false,
93            apply_limit_buckets: false,
94        }).await?;
95        let adapter_info = adapter.get_info();
96        println!("wgpu adapter: {} ({:?})", adapter_info.name, adapter_info.backend);
97        if adapter_info.vendor != 0x10DE {
98            return Err("This sample requires wgpu to use an NVIDIA GPU (vendor 0x10DE)".into());
99        }
100        let (device, queue) = adapter.request_device(&wgpu::DeviceDescriptor {
101            label: Some("Video cube device"),
102            required_features: wgpu::Features::empty(),
103            required_limits: wgpu::Limits::default(),
104            ..Default::default()
105        }).await?;
106
107        let video = video::Video::open(&device, path)?;
108        println!("Native Video Codec SDK: H.264/HEVC, Media Foundation compressed packets, 3 GPU textures");
109        let caps = surface.get_capabilities(&adapter);
110        // The CUDA kernel writes display-encoded (gamma) RGB into Rgba8Unorm textures, so
111        // sampled values are already what the screen should show. A non-sRGB target passes
112        // them through; an sRGB one would gamma-encode them a second time (washed out).
113        let format = caps.formats.iter().copied().find(|f| !f.is_srgb()).unwrap_or(caps.formats[0]);
114        println!("Swapchain format: {format:?}");
115        let clear = background(format);
116        let size = window.inner_size();
117        let config = wgpu::SurfaceConfiguration {
118            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
119            format,
120            width: size.width.max(1),
121            height: size.height.max(1),
122            present_mode: wgpu::PresentMode::Fifo,
123            alpha_mode: caps.alpha_modes[0],
124            view_formats: vec![],
125            desired_maximum_frame_latency: 2,
126            color_space: wgpu::SurfaceColorSpace::Auto,
127        };
128        surface.configure(&device, &config);
129        let depth = depth_texture(&device, size);
130        let (v, i) = cube();
131        let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
132            label: Some("Cube VB"), contents: bytemuck::cast_slice(&v), usage: wgpu::BufferUsages::VERTEX,
133        });
134        let indices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
135            label: Some("Cube IB"), contents: bytemuck::cast_slice(&i), usage: wgpu::BufferUsages::INDEX,
136        });
137        let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
138            label: Some("MVP uniform"),
139            contents: bytemuck::bytes_of(&Uniform { mvp: Mat4::IDENTITY.to_cols_array_2d() }),
140            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
141        });
142        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
143            label: Some("Video + MVP"),
144            entries: &[
145                wgpu::BindGroupLayoutEntry {
146                    binding: 0, visibility: wgpu::ShaderStages::VERTEX,
147                    ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
148                    count: None,
149                },
150                wgpu::BindGroupLayoutEntry {
151                    binding: 1, visibility: wgpu::ShaderStages::FRAGMENT,
152                    ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false },
153                    count: None,
154                },
155                wgpu::BindGroupLayoutEntry {
156                    binding: 2, visibility: wgpu::ShaderStages::FRAGMENT,
157                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None,
158                },
159            ],
160        });
161        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
162            mag_filter: wgpu::FilterMode::Linear,
163            min_filter: wgpu::FilterMode::Linear,
164            ..Default::default()
165        });
166        let bind_groups: Vec<wgpu::BindGroup> = video.textures.iter().enumerate().map(|(_slot, tex)| {
167            let texture_view = tex.create_view(&Default::default());
168            device.create_bind_group(&wgpu::BindGroupDescriptor {
169                label: Some("NVDEC frame + MVP bindings"), layout: &layout,
170                entries: &[
171                    wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() },
172                    wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&texture_view) },
173                    wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler) },
174                ],
175            })
176        }).collect();
177        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
178            label: Some("Cube WGSL"),
179            source: wgpu::ShaderSource::Wgsl(include_str!("cube.wgsl").into()),
180        });
181        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
182            label: Some("Video cube pipeline layout"), bind_group_layouts: &[Some(&layout)], immediate_size: 0,
183        });
184        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
185            label: Some("Video cube pipeline"),
186            layout: Some(&pipeline_layout),
187            vertex: wgpu::VertexState {
188                module: &shader, entry_point: Some("vs_main"),
189                buffers: &[Some(wgpu::VertexBufferLayout {
190                    array_stride: std::mem::size_of::<Vertex>() as u64,
191                    step_mode: wgpu::VertexStepMode::Vertex,
192                    attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
193                })],
194                compilation_options: Default::default(),
195            },
196            fragment: Some(wgpu::FragmentState {
197                module: &shader, entry_point: Some("fs_main"),
198                targets: &[Some(wgpu::ColorTargetState { format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
199                compilation_options: Default::default(),
200            }),
201            primitive: wgpu::PrimitiveState { cull_mode: None, ..Default::default() },
202            depth_stencil: Some(wgpu::DepthStencilState {
203                format: wgpu::TextureFormat::Depth24Plus,
204                depth_write_enabled: Some(true),
205                depth_compare: Some(wgpu::CompareFunction::Less),
206                stencil: Default::default(),
207                bias: Default::default(),
208            }),
209            multisample: Default::default(),
210            multiview_mask: None,
211            cache: None,
212        });
213        Ok(Self { window, surface, device, queue, config, pipeline, depth, vertices, indices,
214            uniform, bind_groups, clear, start: Instant::now(), video })
215    }
216
217    fn resize(&mut self, s: winit::dpi::PhysicalSize<u32>) {
218        if s.width == 0 || s.height == 0 { return; }
219        self.config.width = s.width;
220        self.config.height = s.height;
221        self.surface.configure(&self.device, &self.config);
222        self.depth = depth_texture(&self.device, s);
223    }
224
225    fn draw(&mut self) -> Result<(), Box<dyn std::error::Error>> {
226        // Presentation timestamps determine *when* to advance the video texture.
227        // The cube itself renders at the display refresh cadence.
228        self.video.update(&self.device)?;
229        let elapsed = self.start.elapsed().as_secs_f32();
230        let model = Mat4::from_rotation_y(elapsed * 0.8) * Mat4::from_rotation_x(elapsed * 0.43);
231        let view = Mat4::look_at_rh(Vec3::new(3.2, 2.1, 4.6), Vec3::ZERO, Vec3::Y);
232        let projection = Mat4::perspective_rh(45f32.to_radians(), self.config.width as f32 / self.config.height as f32, 0.1, 100.0);
233        let uniform = Uniform { mvp: (projection * view * model).to_cols_array_2d() };
234        self.queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
235        let frame = match self.surface.get_current_texture() {
236            wgpu::CurrentSurfaceTexture::Success(frame)
237            | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
238
239            wgpu::CurrentSurfaceTexture::Timeout
240            | wgpu::CurrentSurfaceTexture::Occluded => {
241                return Ok(());
242            }
243
244            wgpu::CurrentSurfaceTexture::Outdated => {
245                self.surface.configure(&self.device, &self.config);
246                return Ok(());
247            }
248
249            wgpu::CurrentSurfaceTexture::Lost => {
250                panic!("Surface lost; surface recreation required");
251            }
252
253            wgpu::CurrentSurfaceTexture::Validation => {
254                panic!("Surface validation error");
255            }
256        };
257        let output = frame.texture.create_view(&Default::default());
258        let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Cube render") });
259        {
260            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
261                label: Some("Cube main pass"),
262                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
263                    view: &output, resolve_target: None,
264                    ops: wgpu::Operations { load: wgpu::LoadOp::Clear(self.clear), store: wgpu::StoreOp::Store },
265                    depth_slice: None,
266                })],
267                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
268                    view: &self.depth,
269                    depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }),
270                    stencil_ops: None,
271                }),
272                occlusion_query_set: None,
273                timestamp_writes: None,
274                multiview_mask: None,
275            });
276            pass.set_pipeline(&self.pipeline);
277            pass.set_bind_group(0, &self.bind_groups[self.video.current_slot()], &[]);
278            pass.set_vertex_buffer(0, self.vertices.slice(..));
279            pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint16);
280            pass.draw_indexed(0..36, 0, 0..1);
281        }
282        self.queue.submit(Some(encoder.finish()));
283        self.window.pre_present_notify();
284        self.queue.present(frame);
285        Ok(())
286    }
287}
288
289impl Drop for State {
290    fn drop(&mut self) {
291        let _ = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
292    }
293}
294
295struct App {
296    path: String,
297    state: Option<State>,
298    next_frame: Instant,
299}
300impl ApplicationHandler for App {
301    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
302        if self.state.is_some() { return; }
303        let window = Arc::new(event_loop.create_window(WindowAttributes::default()
304            .with_title("wgpu cube — native NVDEC / CUDA / D3D12")
305            .with_inner_size(winit::dpi::LogicalSize::new(1024, 768))).unwrap());
306        let state = pollster::block_on(State::new(window, &self.path)).expect("Video cube initialization failed");
307        self.next_frame = Instant::now();
308        self.state = Some(state);
309    }
310    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
311        let Some(state) = self.state.as_mut() else { return; };
312        match event {
313            WindowEvent::CloseRequested => event_loop.exit(),
314            WindowEvent::Resized(s) => state.resize(s),
315            WindowEvent::RedrawRequested if state.config.width > 0 && state.config.height > 0 => {
316                if let Err(e) = state.draw() {
317                    eprintln!("Render/decode error: {e}");
318                    event_loop.exit();
319                }
320            }
321            _ => {}
322        }
323    }
324    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
325        if let Some(state) = &self.state {
326            if Instant::now() >= self.next_frame {
327                state.window.request_redraw();
328                self.next_frame = Instant::now() + Duration::from_millis(16);
329            }
330            event_loop.set_control_flow(ControlFlow::WaitUntil(self.next_frame));
331        }
332    }
333}
334
335fn main() -> Result<(), Box<dyn std::error::Error>> {
336    let path = std::env::args().nth(1).ok_or("Usage: cargo run --release -- <video.mp4>")?;
337    let event_loop = EventLoop::new()?;
338    let mut app = App { path, state: None, next_frame: Instant::now() };
339    event_loop.run_app(&mut app)?;
340    Ok(())
341}

src/cube.wgsl

26 lines, shown in full in Step 10. Reading it against main.rs: the three @group(0) @binding(n) declarations are the bind group layout, in the same order; VertexInput's two @locations are the vertex buffer layout; vs_main multiplies each vertex by the matrix from the uniform buffer and passes the texture coordinate through; and fs_main samples the video at that coordinate. There's no lighting, no color correction and no hint that the texture is video. Any WGSL that samples a texture would work here unchanged.

Limitations and next steps

This is a demonstration of the interop, not a video player, and I want to be clear about which is which. Here is what the example does and doesn't do:

What the example does and doesn't do
FeatureStatus
Hardware H.264 and HEVC decodingYes, for progressive 8-bit 4:2:0
Decoded frames stay in GPU memoryYes
CUDA writing into shared D3D12 texturesYes
Native textures imported into wgpuYes, through wgpu-hal
Video on 3D geometryYes
AudioNo
10-bit, HDR, P010No
Color conversion from the stream's metadataNo: BT.709 limited range is assumed
Asynchronous CUDA and wgpuNo: a blocking wait every frame
Other platformsNo: Windows, D3D12 and NVIDIA only

And to repeat the caveat from Part 1: the demuxing and the bitstream parsing run on the CPU. It's the decoding, the color conversion and the rendering that happen on the GPU.

Color accuracy

The double-gamma bug in Step 10 is fixed, but the kernel still assumes BT.709 limited range for every file. SD video is usually BT.601, HDR is BT.2020 with a different transfer function, and some files are full range. My clip carries no color metadata at all, which is common, so a real player has to guess too, typically from the resolution. Doing this properly means reading the stream's color metadata (Media Foundation exposes it on the media type) and choosing the matrix in the kernel accordingly.

Synchronization

The blocking waits in Step 11 are the biggest cost. The proper version imports a D3D12 fence per slot into CUDA as an external semaphore (the same CUDA interop API has cudaImportExternalSemaphore for this). CUDA signals the slot's fence after the kernel, the render queue waits on it before sampling, and CUDA waits on the render queue's fence before writing the slot again. With a decoder thread and a small frame queue, decode and rendering could overlap.

Audio and timing

There's no audio, so the video clock is the only clock. There's no frame dropping either: if a frame is late, it's shown late. Packet reading is synchronous on the UI thread, so a slow disk read would stall rendering too. A player would read on a worker thread and pace video against the audio clock.

HDR and other formats

10-bit video (P010), 4:2:2, interlaced streams and mid-stream resolution changes are all rejected in Step 5. NVDEC can decode more than this, AV1 included on newer GPUs, but each one needs a different output format, a different kernel, or a decoder that can be recreated on the fly. HDR also needs tone mapping, which is a topic of its own.

Wrapping up

Stepping back from the details, the useful result is small and reusable: an NVDEC-decoded frame as an ordinary, sampleable wgpu::Texture, with no trip through system memory. The cube is just where I happened to show it. The same three textures could feed a fullscreen quad, a material in a game engine, a video wall, or a compute pass. And since the frames are already in CUDA when they're mapped, it's also a starting point for running CUDA processing on decoded video before it's drawn.

If I had to name the three ideas that made it work: the LUID match in Step 4, so CUDA and wgpu are on one GPU; the shared, simultaneous-access texture in Step 7, so both can use one allocation; and wgpu-hal in Step 9, so wgpu can adopt memory it didn't allocate. Everything else is plumbing between those three. The parts that needed fixing were the less obvious ones: how NAL units are framed, two C runtimes in one binary, and gamma applied twice.

Terms used in this post

NVDEC
The hardware video decoder on NVIDIA GPUs, separate from the graphics and compute engines.
NVCUVID
The Video Codec SDK's decoding API: the parser (cuvidCreateVideoParser) and the decoder (cuvidCreateDecoder).
Demuxing
Pulling the compressed samples of one track out of a container such as MP4, with their timestamps.
NAL unit
The basic unit of an H.264 or HEVC stream: a header byte (two in HEVC) followed by a slice of a picture or a parameter set such as the SPS or PPS.
Annex B
The byte-stream format that separates NAL units with start codes (00 00 01). MP4 files usually store them with length prefixes instead.
NV12
An 8-bit 4:2:0 YUV layout: a full-size Y plane, then a half-size plane of interleaved U and V.
PTS
Presentation timestamp: when a frame should be shown. Media Foundation and NVDEC both use 100 ns units.
LUID
The locally unique ID Windows assigns each graphics adapter. D3D12 and CUDA both report it, which is how the bridge matches them.
CUDA context
CUDA's per-device state. Every CUDA call acts on the context that is current on the calling thread.
wgpu-hal
The layer under wgpu that implements each backend. Its unsafe functions let you use native resources.
External memory
CUDA's name for memory allocated by another API, such as a D3D12 resource, and imported through a handle.
Surface object
CUDA's handle for writing to a texture-like array from a kernel.
Simultaneous access
A D3D12 texture flag that lets more than one queue or API use a texture without layout transitions.

References and updates

: first version.