Flutter ↔ avox video-frame interop: from Texture to native window

Implementation notes·about 1744 words

Panvox is a cross-platform player with a Flutter UI and my own C++ engine, avox, underneath. Which raises an unavoidable question: when the engine decodes and runs GPU processing, how does the resulting video frame get onto the screen?

On Android and iOS, Flutter has PlatformView — platform views exist. On desktop there is no answer: Windows still has no official PlatformView support, an old problem that has been open for years. Two paths in front of me: turn the engine's output into a Flutter texture and put it into the compositor, or bypass Flutter's compositor and open a native window myself. I started with the texture and, nine days later, deleted the entire texture chain and fell back to a native window. Here is the full story of both approaches.

The texture route: the path that looked smoothest

The first version did not even have a texture: after the engine rendered, the shim copied the frame row by row as BGRA for Dart to draw. Fine at 1080p, not at 4K, and the arithmetic was clear at the time. So the zero-copy GPU chain went in: the engine composited in Vulkan and put the result into a cross-API D3D11 shared texture; the shim's bridge thread mirrored it into a GPU texture registered with Flutter; on the Dart side, feeding the texture id to a Texture widget was all it took. Measured against CPU readback it saved 17% CPU, and Android soon followed with external-texture passthrough.

The reasons for choosing textures still hold up: external textures are first-class in Flutter, one Texture widget joins the compositor, and transforms, gestures and lifecycle come for free; platform differences can be boxed into “one texture bridge per platform”; and with no official PlatformView on Windows, textures were the only channel. I even hid the engine's own native render window so that startup showed a single window — in hindsight, that move was laying an ambush for the road ahead.

Breakdown: the blocks that would not heal

The problem appeared in the most ordinary way: some sources showed corrupted blocks during playback, with occasional slight stutter.

Debugging took a detour. First I suspected bad sources — disproved. Then I found a fence defect where the same value could be signalled twice, fixed it with atomic monotonic numbering, and cured one second of corruption; the mirror buffers went from one to three to five, which lowered the block rate but did not remove it at steady state; a frame gate in the engine (write the next frame only after the consumer acknowledges) did not cure the blocks and made the stutter worse. All of it was symptomatic treatment.

Bisection found the real cause: concurrent use of a shared texture across devices within the same process. Its boundary is sharp: the native-window test (engine writes with Vulkan, presents directly to the window) never showed it, and importing the same texture across processes was clean — it has nothing to do with D3D/Vulkan fences, nor with cross-API sharing itself. Only one shape is bad: pulling Flutter's ANGLE compositor into the same chain as a third consumer. Now there are two levels on the consumer side — ANGLE sampling the texture while the shim's mirror blit races the engine's write of the next frame — and neither level has reliable synchronisation. The corrupted content comes consistently from adjacent frames, not random noise. Measured: on the affected sources, frames exceeding the disorder metric were 21% of sequential playback with 8% visibly corrupted; after a seek, 32% and 14%.

Variable isolation took two cuts: three hosts, the same file, the only variable being “texture export on or off” — on is bad, off is clean; cross-process import is clean. The conclusion was blunt: the disease is the multi-consumer concurrency structure of this chain; bypass it rather than fix it.

There was also a forward-looking bill: an 8K RGBA8 frame is 132.7 MB, and this chain hops it three times per frame. Even if synchronisation could be fixed, the bandwidth is not sustainable.

Native window: bypassing Flutter's compositor

The new shape, on Windows, references an existing implementation and comes to about two hundred lines of core code: create a top-level video window — black, borderless, never taking activation focus — and place it directly behind the Flutter main window with SetWindowPos so the z-order holds; make the Flutter main window genuinely transparent with SetWindowCompositionAttribute, an undocumented API used the same way by Windows Terminal; Flutter then draws only a transparent hole in the video area, with playback controls composited over the video using real alpha. The video window takes no input at all, so hit-testing is naturally free; if another window jumps the z-order, a 300-millisecond watchdog re-pins it; the minimize animation is something DWM cannot do with two overlapping windows, worked around in three steps (drop transparency, hide the window, then restore without activation after a delay).

macOS and iOS use the official PlatformView with a CAMetalLayer built inside the view, handing the layer pointer to the shim; Android uses AndroidView with a SurfaceView, with JNI attaching an ANativeWindow. Linux temporarily keeps the old CPU-readback shape and is the only exception across platforms.

The effect was immediate: same machine, same file, A/B in the sharpest environment — the texture group reproduces the corruption, the direct-render group is completely clean; on real playback both stutter and blocks disappear, first frame out of open in 246 milliseconds, and 8K HEVC plays.

In actual use this works better than expected: the playback bar and control show/hide all behave normally, Flutter UI layers above and below the video work, and real alpha sits on top of the picture — it feels less like a workaround around the compositor and more like a supported native rendering intermediate layer. The reason is simple: only the video surface itself is constrained; the interface is still an ordinary Flutter widget tree. The boundaries are concentrated on that surface too: it lives outside the compositor, so Flutter animations and transforms do not apply to it; the video rectangle follows with one frame of lag, so the video node is constrained out of scrolling lists; PrintWindow screenshots cannot capture the video showing through; whole-window transparency depends on an undocumented API, a trade accepted after evaluation. The window producing no input actually removes hit-test handling entirely.

One lane, no switch

After validation I made a decision: no fallback switch. The texture passthrough was deleted outright — the “GPU passthrough” setting, its localisation keys, the environment-variable injection at startup, and the shim's texture symbol handling went with it; a windowless create request to the shim is now refused. The product has exactly one render chain, and no future rendering bug requires first answering “which chain was it”.

Those nine days were not wasted — fences, mirror rings and frame gates all became the evidence behind “why this cannot work”, which is the judgement the later decision rested on.

The complexity did not disappear, it moved

The texture approach's complexity lay in the frame-synchronisation protocol; the native window's lies in window lifecycle. Three examples.

One is 0×0. Everything looked fine on the probe page, but inside the real shell the video window was 0×0 with all controls hidden. Reproducing the open chain with the autoplay hook and measuring up the RenderObject ancestor chain found the root cause: the shell's Stack uses loose fit, the base layer is hidden with Offstage during playback and measures 0×0, which collapsed the Positioned.fill playback page to zero width. The fix is StackFit.expand, one line.

One is a black screen on rotation (Android). Portrait had a picture; rotating to landscape sometimes gave sound with no image. Cause: rotating rebuilds the platform view with a new SurfaceView, and on some systems the old surface's surfaceDestroyed callback never arrives; the mount gate compared only player handles, so the new surface was swallowed as a duplicate request, and the engine polled a dead window that measured 0×0 forever. The fix: re-mount when either the handle or the Surface object changes. On the same test path: two of two black before the fix, four of four picture after.

One is a crash on lane switch (macOS). Switching render lanes for HDR during playback, objc_retain touched a CAMetalLayer that had been released with the previous film. Cause: the plugin registered the pointer only at view creation and nobody unregistered it at destruction; the shim blocked null but not dangling. Fixed in three layers: conditional unregister in dealloc, a weak-reference live gate on the shim side, and clearing lane-switch state on open on the Dart side to prevent a race. One more pure experience item: the rectangle must be positioned before the window is mounted — the child window and swap chain sizes are pinned at mount time, and later means the picture shows only one corner.

The other half of this is HDR

In hindsight, abandoning textures was not only about curing blocks. Inside Flutter's compositor, a texture is always SDR — the compositor has no HDR path. For Dolby Vision highlights to actually push out of the screen, the pixels must leave through the engine's own presentation surface: a PQ swap chain on Windows, or an EDR Metal layer on Apple. “Video goes through a native window” is therefore a precondition for HDR. What I built on it are two render lanes — lanes split by output method, not by decode method: in HDR passthrough the decoded frame goes straight to the platform's native presentation, and everywhere else (including HDR films on an ordinary screen) goes through the Vulkan processing chain; switching lanes mid-playback by tearing down and re-mounting takes about 120 milliseconds — that is another article's subject.

When a Texture is still the right choice

Low frame rate, small size, a picture that needs to participate in UI transforms — thumbnails, previews, charts — Texture is still the shortest path, and my self-check page still uses it to verify GPU interop. For a full-screen, high-frame-rate, high-bit-depth video surface, the native window is the only stable answer I have validated. A rough but practical signal: when you find yourself writing the seventh synchronisation scheme for a texture, it is time to change roads.

The engine avox is open source on GitHub, and the window management and GPU pipeline described here are all in it. The rest of the series: the product introduction, From the open-source engine avox to a product: the cross-platform player Panvox.

Originally published in Chinese on Zhihu (October 2026); product progress follows the product page.