A Flutter plugin that integrates Unity's rendering capabilities into desktop applications via shared memory textures.
It provides an API to run Unity applications as a background process, manages their lifecycle, and displays Unity textures directly within your Flutter app as a standard widget.
Why this exists: Unity does not currently provide an official way to embed its applications into desktop environments, and no such features are expected in the near future.
Simplified plugin architecture scheme.
- Platforms: Windows 10/11 and macOS (10.11+).
- Rendering: Requires Metal on macOS and DirectX 11+ on Windows.
- Unity version: Tested on Unity 6 (6000.3.10f1 Editor), but expected to work on older versions too.
- Performance & CPU Usage: This plugin utilizes the CPU to transfer Unity textures from the engine to Flutter via RAM.
- Windows: Highly efficient. It maps shared memory directly into Flutter’s pixel buffer, resulting in minimal CPU overhead.
- macOS: Due to system security policies and the requirement for Metal-compatible
CVPixelBufferencapsulation, additional buffering and alignment are required. This results in higher CPU usage compared to the Windows implementation.
To use this plugin you don't need any special configurations on Flutter side. Your main entry point here is UnityDesktopEmbedder that creates connection with Unity, controls it and returns textureId after successful initialize method call.
Additionally the plugin provides UnityDesktopTexture — it's a lightweight wrapper on Texture widget that automatically flips it on the correct axis and can be easily configured. But you can use the plugin without it and manipulate textureId directly as you want.
- Create .cs script in your assets and paste the following code into it:
Flutter bridge unity script
using System;
using System.Runtime.InteropServices;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine;
using UnityEngine.Rendering;
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
using System.IO.MemoryMappedFiles;
using System.Threading;
#endif
public class FlutterUnityBridge : MonoBehaviour
{
public static class SharedMemoryProtocol {
public const int HeaderSize = 64;
public const int SlotMetadataSize = 16;
public const int LatestSequenceOffset = 8;
public const int LatestSlotIndexOffset = 16;
public const int LastAckedSequenceOffset = 24;
public const int SlotStatusOffset = 0;
public const int SlotSequenceOffset = 8;
}
public enum SlotStatus : int { Empty = 0, Writing = 1, Ready = 2 }
public Camera targetCamera;
private const int BytesPerPixel = 4;
private const int SlotCount = 3;
private const int MaxRequests = 2;
private int _width, _height, _slotDataSize, _sharedMemorySize;
private RenderTexture _renderTexture;
private int _requestsInFlight = 0;
#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
private IntPtr _semaphore = IntPtr.Zero, _sharedMemoryPtr = IntPtr.Zero;
private int _sharedMemoryFd = -1, _lastWrittenSlot = -1;
private ulong _nextSequence = 1;
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
private MemoryMappedFile _mmf;
private MemoryMappedViewAccessor _accessor;
private EventWaitHandle _frameEvent;
#endif
void Awake()
{
Application.runInBackground = true;
QualitySettings.vSyncCount = Application.platform == RuntimePlatform.OSXPlayer ? 1 : 0;
Application.targetFrameRate = 60;
}
void Start()
{
if (!ParseArguments(out string shmName, out string semName)) return;
_slotDataSize = _width * _height * BytesPerPixel;
InitializeIPC(shmName, semName);
if (targetCamera == null) targetCamera = Camera.main;
_renderTexture = new RenderTexture(_width, _height, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
_renderTexture.Create();
targetCamera.targetTexture = _renderTexture;
targetCamera.enabled = false;
}
void LateUpdate()
{
if (!IsIPCReady() || _requestsInFlight >= MaxRequests) return;
targetCamera.Render();
_requestsInFlight++;
AsyncGPUReadback.Request(_renderTexture, 0, TextureFormat.BGRA32, OnReadbackComplete);
}
private void OnReadbackComplete(AsyncGPUReadbackRequest request)
{
if (this == null) return;
_requestsInFlight--;
if (!request.hasError) WriteToSharedMemory(request.GetData<byte>());
}
void OnDestroy() => DisposeIPC();
#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
private static class Posix {
[DllImport("libc")] public static extern IntPtr sem_open(string name, int oflag);
[DllImport("libc")] public static extern int sem_post(IntPtr sem);
[DllImport("libc")] public static extern int sem_close(IntPtr sem);
[DllImport("libc")] public static extern int shm_open(string name, int oflag, uint mode);
[DllImport("libc")] public static extern int close(int fd);
[DllImport("libc")] public static extern IntPtr mmap(IntPtr addr, UIntPtr length, int prot, int flags, int fd, IntPtr offset);
[DllImport("libc")] public static extern int munmap(IntPtr addr, UIntPtr length);
}
private void InitializeIPC(string shmName, string semName) {
_sharedMemorySize = SharedMemoryProtocol.HeaderSize + SlotCount * _slotDataSize;
_semaphore = Posix.sem_open(semName, 0);
_sharedMemoryFd = Posix.shm_open(shmName, 0x0002, 0);
if (_sharedMemoryFd != -1) _sharedMemoryPtr = Posix.mmap(IntPtr.Zero, (UIntPtr)_sharedMemorySize, 0x01 | 0x02, 0x0001, _sharedMemoryFd, IntPtr.Zero);
}
private bool IsIPCReady() => _sharedMemoryPtr != IntPtr.Zero && _sharedMemoryPtr != (IntPtr)(-1);
private unsafe void WriteToSharedMemory(NativeArray<byte> nativeData) {
int slotIndex = FindWritableSlot();
if (slotIndex == -1) return;
byte* basePtr = (byte*)_sharedMemoryPtr.ToPointer();
byte* slotMetaPtr = basePtr + SharedMemoryProtocol.HeaderSize + (slotIndex * SharedMemoryProtocol.SlotMetadataSize);
byte* dataPtr = basePtr + SharedMemoryProtocol.HeaderSize + (slotIndex * _slotDataSize);
*(int*)slotMetaPtr = (int)SlotStatus.Writing;
System.Buffer.MemoryCopy(NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(nativeData), dataPtr, _slotDataSize, nativeData.Length);
ulong sequence = _nextSequence++;
*(long*)(slotMetaPtr + SharedMemoryProtocol.SlotSequenceOffset) = (long)sequence;
*(int*)slotMetaPtr = (int)SlotStatus.Ready;
*(long*)(basePtr + SharedMemoryProtocol.LatestSequenceOffset) = (long)sequence;
*(int*)(basePtr + SharedMemoryProtocol.LatestSlotIndexOffset) = slotIndex;
_lastWrittenSlot = slotIndex;
if (_semaphore != IntPtr.Zero && _semaphore != (IntPtr)(-1)) Posix.sem_post(_semaphore);
}
private unsafe int FindWritableSlot() {
byte* basePtr = (byte*)_sharedMemoryPtr.ToPointer();
ulong lastAcked = *(ulong*)(basePtr + SharedMemoryProtocol.LastAckedSequenceOffset);
for (int i = 1; i <= SlotCount; i++) {
int idx = (_lastWrittenSlot + i) % SlotCount;
byte* meta = basePtr + SharedMemoryProtocol.HeaderSize + (idx * SharedMemoryProtocol.SlotMetadataSize);
if (*(int*)meta == (int)SlotStatus.Empty || *(ulong*)(meta + SharedMemoryProtocol.SlotSequenceOffset) <= lastAcked) return idx;
}
return -1;
}
private void DisposeIPC() {
if (_sharedMemoryPtr != IntPtr.Zero && _sharedMemoryPtr != (IntPtr)(-1)) Posix.munmap(_sharedMemoryPtr, (UIntPtr)_sharedMemorySize);
if (_sharedMemoryFd != -1) Posix.close(_sharedMemoryFd);
if (_semaphore != IntPtr.Zero && _semaphore != (IntPtr)(-1)) Posix.sem_close(_semaphore);
}
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
private void InitializeIPC(string shmName, string semName) {
_sharedMemorySize = _slotDataSize;
_mmf = MemoryMappedFile.CreateOrOpen(shmName, _sharedMemorySize);
_accessor = _mmf.CreateViewAccessor();
_frameEvent = new EventWaitHandle(false, EventResetMode.AutoReset, semName);
}
private bool IsIPCReady() => _accessor != null;
private unsafe void WriteToSharedMemory(NativeArray<byte> nativeData) {
byte* destPtr = null;
_accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref destPtr);
try { System.Buffer.MemoryCopy(NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(nativeData), destPtr, _sharedMemorySize, nativeData.Length); }
finally { _accessor.SafeMemoryMappedViewHandle.ReleasePointer(); }
_frameEvent?.Set();
}
private void DisposeIPC() { _accessor?.Dispose(); _mmf?.Dispose(); _frameEvent?.Dispose(); }
#endif
private bool ParseArguments(out string shmName, out string semName) {
string[] args = Environment.GetCommandLineArgs();
shmName = semName = ""; string w = "", h = "";
for (int i = 0; i < args.Length - 1; i++) {
if (args[i] == "-shmName") shmName = args[i + 1];
else if (args[i] == "-semName") semName = args[i + 1];
else if (args[i] == "-width") w = args[i + 1];
else if (args[i] == "-height") h = args[i + 1];
}
return int.TryParse(w, out _width) && int.TryParse(h, out _height) && !string.IsNullOrEmpty(shmName) && !string.IsNullOrEmpty(semName);
}
}- Create Render Texture in Unity assets
Render texture parameters that were used in our tests.
- Attach created texture and script to your main camera in scene.
Demonstration of elements that should be attached to the camera.
Flutter and Unity communicate via POSIX semaphores and shared memory. In a sandboxed (distributed) macOS app, both processes must belong to the same App Group, and IPC resource names must be prefixed with that group identifier. Pass the names explicitly when initializing the plugin:
// The width and height should be the same as in the texture settings in Unity.
await embedder.initialize(
width: 1280,
height: 720,
executablePath: '/path/to/Unity.app',
shmName: 'group.com.company.app/funity_shm',
semName: 'group.com.company.app/vsem',
);If shmName and semName are omitted, the plugin generates unique names automatically — this works fine during development but will not function in a sandboxed distribution build.
Check out the example folder for a demonstration of the plugin's usage. In main.dart you can see how to configure and use UnityDesktopEmbedder and UnityDesktopTexture widgets.
The following items represent the envisioned evolution of the plugin. These are not guaranteed milestones, but rather directions for future development:
- Linux Support: Extending the plugin to support Linux desktop environments.
- GPU Zero-Copy: Transitioning from RAM-based texture sharing to GPU-based sharing using DXGI Shared Handles (Windows) and IOSurface APIs (macOS).
