Open Source

Flutter package

unity_kit

Embed Unity 3D in Flutter with a typed, testable bridge — JSON or binary protocol, an enforced lifecycle, AR Foundation, and CDN asset streaming. Runs on Android and iOS (web/WebGL experimental).

v2.0.222.6k / month21 likesMITUnity 2022.3 LTS → Unity 6

Content loading

All the ways to get content into Unity at runtime: scenes, prefabs, AssetBundles, Addressables, glTF and AR.

source: doc/unity_integrations.md

Content loading

Choosing a loading method

There are several ways to get 3D content into a running Unity instance, and unity_kit supports all of them through the same message bridge: your Dart code sends a command, a Unity-side manager loads the asset, and progress and results come back as messages. This page walks each method the integration doc describes — scenes baked into the build, prefabs and Resources, raw AssetBundles, Addressables, glTF/GLB at runtime, and AR overlays — with when to reach for it, the Dart calls, the Unity-side setup, and the trade-offs.

Platform truth

unity_kit ships for Android and iOS in production. WebGL is not production-ready, there is no desktop target, and Unity does not render on the iOS Simulator — verify every loading path on a physical device. The iOS Safe / Android Safe columns below are about App Store / Play policy, not whether the API compiles.

ApproachAsset loadingCode loadingiOS safeAndroid safeComplexityBest for
Scene loadingBuilt-inNoYesYesLowSwitching game contexts
Prefab instantiationYesNoYesYesLowLoading individual models on-demand
AssetBundlesYesNoYesYesHighLegacy projects, full control
Addressables + CCDYesNoYesYesMediumPrimary content delivery system
glTF/GLB runtimeYesNoYesYesMediumUser-generated content, NFTs, external 3D
AR FoundationN/ANoYesYesMediumAR camera experiences

Read the same rows through the lens that matters most in production — how each method affects app size and whether it lets you ship new content without an App Store / Play submission:

MethodApp size impactUpdate without releaseComplexityUse case
Scenes in buildBaked into the APK/IPAOnly if the scene comes from Addressables/remoteLowSwitching whole contexts
Prefabs via AddressablesOut of the base build (remote group)YesLowIndividual models on demand
Resources folderIncreases the build (all packed, no lazy load)NoLowDiscouraged — avoid
Raw AssetBundlesOut of the base build (CDN / StreamingAssets)Yes (manual versioning)HighLegacy / full control
Addressables + remote catalogSmall base + remote bundlesYes (recommended)MediumPrimary content delivery
glTF/GLB at runtimeModels never in the buildYesMediumExternal / UGC / NFT models
AR FoundationARKit/ARCore plugins add size; models via AddressablesContent yes, framework noMediumCamera AR experiences

The download, caching and CDN plumbing that AssetBundles and Addressables sit on top of has its own page — see Asset streaming & Addressables for the SHA-256 verified cache, CDN manifest and the unity_kit streaming layer.

Built-in

Scenes in the build

A scene is a .unity container holding GameObjects, lighting, lightmaps, navmesh and skybox. Load a scene when you want to swap an entire context at once — main menu to gameplay, or between levels. LoadSceneMode.Single replaces everything; LoadSceneMode.Additive layers a scene on top of the current one, which is the key to a modular "one persistent Core scene + swappable modules" architecture.

From Flutter you drive scenes with bridge commands and read progress and load/unload events back off the streams:

Flutter → Unity: scene commandsdart
// Flutter: sending scene commands to Unity

// Load scene (additive)
bridge.send(UnityMessage.command('LoadScene', {
  'sceneName': 'GameShowroom',
  'mode': 'additive',  // or 'single'
}));

// Unload scene
bridge.send(UnityMessage.command('UnloadScene', {
  'sceneName': 'ItemCollection',
}));

// Swap scene
bridge.send(UnityMessage.command('SwapScene', {
  'oldScene': 'ItemCollection',
  'newScene': 'GameShowroom',
}));
Flutter: listening for scene eventsdart
// Flutter: listening for scene events

// Track scene loading (progress bar in Flutter)
bridge.messageStream
  .where((msg) => msg.type == 'scene_loading_progress')
  .listen((msg) {
    final progress = msg.data?['progress'] as double? ?? 0;
    final sceneName = msg.data?['sceneName'] as String? ?? '';
    setState(() {
      _loadingProgress = progress;
      _loadingScene = sceneName;
    });
  });

// Scene loaded
bridge.sceneStream.listen((SceneInfo info) {
  debugPrint('Scene ${info.name} loaded');
  debugPrint('  buildIndex: ${info.buildIndex}');
  debugPrint('  isLoaded: ${info.isLoaded}');
  debugPrint('  isValid: ${info.isValid}');

  // Update Flutter application state
  context.read<SceneCubit>().onSceneLoaded(info);
});

// Scene unloaded
bridge.messageStream
  .where((msg) => msg.type == 'scene_unloaded')
  .listen((msg) {
    context.read<SceneCubit>().onSceneUnloaded(msg.data?['sceneName']);
  });

On the Unity side, always use the async API — a synchronous LoadScene on a large scene freezes the frame and can trigger an Android ANR. A scene can come from Build Settings, an AssetBundle, or Addressables:

Unity: async load + unload (memory hygiene)csharp
// ═══ ASYNCHRONOUS — basic (recommended on mobile) ═══
AsyncOperation op = SceneManager.LoadSceneAsync("GameShowroom", LoadSceneMode.Additive);
// Scene loads in the background — Unity allocates time per frame.
// progress: 0.0 → 0.9 = loading, 0.9 = ready for activation.
op.completed += (asyncOp) => {
    Debug.Log("Scene GameShowroom loaded!");
};

// ═══ UNLOADING a scene (CRITICAL on mobile — memory!) ═══
IEnumerator UnloadScene(string sceneName)
{
    AsyncOperation op = SceneManager.UnloadSceneAsync(sceneName);
    yield return op;

    // After unloading the scene, assets may still remain in memory!
    yield return Resources.UnloadUnusedAssets();
    System.GC.Collect();
}
Unity: scene sourcescsharp
// ═══ From Build Settings ═══
SceneManager.LoadSceneAsync("GameShowroom", LoadSceneMode.Additive);
// Scene MUST be in File > Build Settings > Scenes In Build

// ═══ From AssetBundle ═══
// Step 1: load the bundle containing the scene
AssetBundle sceneBundle = AssetBundle.LoadFromFile(
    Path.Combine(Application.persistentDataPath, "scenes/gameshowroom")
);
// Step 2: load the scene from the bundle (by name, not path)
SceneManager.LoadSceneAsync("GameShowroom", LoadSceneMode.Additive);
// Step 3: unload the bundle after loading the scene
sceneBundle.Unload(false);

// ═══ From Addressables ═══
var sceneHandle = Addressables.LoadSceneAsync(
    "Scenes/GameShowroom",
    LoadSceneMode.Additive,
    activateOnLoad: true
);
// sceneHandle.Result = SceneInstance (for later unload)
SceneInstance sceneInstance = await sceneHandle.Task;

// Unloading:
Addressables.UnloadSceneAsync(sceneHandle);

Heads up

Unity scene state is invisible to Flutter — Unity must send a return message. unity_kit handles this automatically: SceneTracker.cs hooks SceneManager.sceneLoaded / sceneUnloaded and forwards events, which arrive on Dart as bridge.sceneStreamSceneInfo (name, buildIndex, isLoaded, isValid).

ProsCons
Full context switching — change the whole world at onceScenes in Build Settings increase APK/IPA size
Async loading = zero lag with a correct implementationLarge scenes = memory spikes (peak RAM during loading)
Additive = modularity, isolation, team collaborationLightmaps / navmesh / reflection probes need managing
Scenes from Addressables = remote loadingOnly one scene is "active" at a time (skybox, lighting)
Progress tracking via AsyncOperation.progressSynchronous LoadScene blocks the UI — always use async

On-demand

Prefabs & Resources

A prefab is a template GameObject (mesh, materials, animations, scripts, colliders) instantiated into an already running scene — a single object placed into an existing context, rather than a whole new world. The doc lists four ways to load one; use them in this order of preference: Addressables (recommended), AssetBundle (lower level, full control), Resources (discouraged), and glTF/GLB for external files.

Unity: the four prefab-loading methodscsharp
// ═══ METHOD 1: Addressables — RECOMMENDED ═══

// A. Load + Instantiate separately (when you need a reference to the prefab)
var loadHandle = Addressables.LoadAssetAsync<GameObject>("Models/Model_001");
GameObject prefab = await loadHandle.Task;
GameObject instance1 = Instantiate(prefab, pos1, Quaternion.identity, parent);
// Release prefab when you no longer need more instances (existing instances survive)
Addressables.Release(loadHandle);

// B. InstantiateAsync — load + instantiate in one step
var instHandle = Addressables.InstantiateAsync(
    "Models/Model_001",
    position: Vector3.zero,
    rotation: Quaternion.identity,
    parent: modelContainer.transform
);
GameObject model = await instHandle.Task;
// ReleaseInstance destroys the object AND releases the asset
Addressables.ReleaseInstance(model);

// ═══ METHOD 2: AssetBundle — lower level, full control ═══
string bundlePath = Path.Combine(Application.persistentDataPath, "bundles/models");
AssetBundle bundle = await AssetBundle.LoadFromFileAsync(bundlePath);
GameObject prefab = bundle.LoadAsset<GameObject>("Model_001");
GameObject instance = Instantiate(prefab, Vector3.zero, Quaternion.identity);
bundle.Unload(false); // false = do not destroy loaded assets in memory

// ═══ METHOD 3: Resources — NOT RECOMMENDED (but worth knowing) ═══
// Files MUST be in the Assets/Resources/ folder.
// All Resources are packed into the build — they increase APK size.
GameObject prefab = Resources.Load<GameObject>("Models/Model_001");
Instantiate(prefab);
// Why not: everything goes into the build, no lazy loading, no remote, no cache.

// ═══ METHOD 4: glTF/GLB runtime — models from external sources ═══
var gltf = new GltfImport();
await gltf.Load("https://api.example.com/models/model.glb");
gltf.InstantiateMainScene(modelContainer.transform);

Avoid Resources

Everything under Assets/Resources/ is packed into the build, so it grows the APK/IPA and can never be lazy-loaded, updated remotely or cached. Reach for Addressables instead — it is the same effort with none of the downsides.

The Unity-side ModelManager extends FlutterMonoBehaviour, so it auto-registers with the router and receives your messages in OnFlutterMessage. From Flutter you talk to it through a thin controller — this is the full API the doc ships:

Flutter: ModelController — full APIdart
// Flutter: full API for managing models

class ModelController {
  final UnityBridge _bridge;

  ModelController(this._bridge);

  // Load model from Addressables
  void loadModel(String modelId) {
    _bridge.send(UnityMessage.to('ModelManager', 'LoadModel', {
      'modelId': modelId,
      'source': 'addressables',
    }));
  }

  // Load model from URL (glTF)
  void loadModelFromUrl(String modelId, String glbUrl) {
    _bridge.send(UnityMessage.to('ModelManager', 'LoadModel', {
      'modelId': modelId,
      'source': 'gltf',
      'url': glbUrl,
    }));
  }

  // Preload (download to cache without displaying)
  void preloadModel(String modelId) {
    _bridge.send(UnityMessage.to('ModelManager', 'PreloadModel', {
      'modelId': modelId,
    }));
  }

  // Swap model for another (smooth swap)
  void swapModel(String newModelId) {
    _bridge.send(UnityMessage.to('ModelManager', 'SwapModel', {
      'modelId': newModelId,
      'source': 'addressables',
    }));
  }

  // Remove current model
  void unloadModel() {
    _bridge.send(UnityMessage.command('UnloadModel', {}));
  }

  // Set position/rotation/scale
  void setTransform({
    double x = 0, double y = 0, double z = 0,
    double rx = 0, double ry = 0, double rz = 0,
    double sx = 1, double sy = 1, double sz = 1,
  }) {
    _bridge.send(UnityMessage.to('ModelManager', 'SetModelTransform', {
      'x': x, 'y': y, 'z': z,
      'rx': rx, 'ry': ry, 'rz': rz,
      'sx': sx, 'sy': sy, 'sz': sz,
    }));
  }

  // Set animation
  void setAnimation(String name, {double speed = 1.0}) {
    _bridge.send(UnityMessage.to('ModelManager', 'SetModelAnimation', {
      'animationName': name,
      'speed': speed,
    }));
  }

  // Change material
  void setMaterial(String materialAddress) {
    _bridge.send(UnityMessage.to('ModelManager', 'SetModelMaterial', {
      'materialAddress': materialAddress,
    }));
  }

  // Event streams
  Stream<Map<String, dynamic>> get onModelLoaded =>
      _bridge.messageStream
          .where((msg) => msg.type == 'model_loaded')
          .map((msg) => msg.data ?? {});

  Stream<Map<String, dynamic>> get onModelLoading =>
      _bridge.messageStream
          .where((msg) => msg.type == 'model_loading')
          .map((msg) => msg.data ?? {});

  Stream<Map<String, dynamic>> get onModelError =>
      _bridge.messageStream
          .where((msg) => msg.type == 'model_error')
          .map((msg) => msg.data ?? {});
}
ScenarioMethodSourceDetails
Single model previewLoadModelAddressablesLoad into scene with pedestal, orbit camera, lighting
Collection (grid)Loop InstantiateAsyncAddressablesLoad thumbnails (LOD 2) on grid; full model only after tap
Swipe carouselSwapModelAddressablesPreload next/previous; swap without an empty frame
NFT from marketplaceLoadModel(gltf)glTF from URLModel arrives as .glb from an API; no prefab in Unity
CustomizationSetMaterialAddressablesLoad a material variant; swap on the renderer

For scrolling collections, recycle instances through an object pool instead of Destroy/Instantiate, and precompile shader variants with a ShaderVariantCollection.WarmUp() at startup to kill the 50–200 ms first-render stutter.

Low-level

Raw AssetBundles

AssetBundles are platform-specific archives of serialized assets loaded at runtime — the low-level foundation Addressables is built on. Reach for raw bundles only when you need full control over packing, versioning and caching, when you already have a CDN pipeline you do not want to abstract away, or when migrating a legacy project. In most cases, prefer Addressables (next section), which manage bundles for you.

Unity: build (LZ4, per platform)csharp
// Recommended build: LZ4 (ChunkBased) compression, one build per platform.
// A bundle built for Android does NOT work on iOS — build separately.
BuildPipeline.BuildAssetBundles(
    outputPath,
    BuildAssetBundleOptions.ChunkBasedCompression, // LZ4
    BuildTarget.Android                            // OR BuildTarget.iOS
);

One build per platform

A bundle built for Android does not work on iOS — you build, host and version each platform separately. Raw bundles also have no built-in reference counting, so dependencies (shared materials, textures) and cache invalidation are all manual — miss a dependency and you get magenta "missing material" models.

Unity: unload semanticscsharp
// Unload(false): release ONLY the bundle from memory.
// Loaded assets (prefabs, textures) REMAIN in memory.
bundle.Unload(false);

// Unload(true): release bundle + ALL loaded assets.
// Instances in the scene lose materials/textures (magenta). Use with care.
bundle.Unload(true);

// Cleanup orphaned assets (after Unload(false) + Destroy instances)
yield return Resources.UnloadUnusedAssets();
System.GC.Collect();

There is no separate Dart API for raw bundles — the same ModelController commands drive them, and download progress surfaces as bundle_download_progress messages on bridge.messageStream. Pick a compression mode by target:

OptionSize on diskLoad timeRAM during loadUsage
UncompressedLargeFastestLowDev / debug
LZMASmallestSlow (full decompression)High peakDownload, then re-compress to LZ4
LZ4 (ChunkBased)MediumFast (chunk-by-chunk)LowRecommended on mobile

Platform notes

Android: StreamingAssets is compressed inside the APK, so AssetBundle.LoadFromFile() does not work there — use UnityWebRequest. Save downloaded bundles to Application.persistentDataPath. For apps >150 MB use Google Play Asset Delivery (install-time / fast-follow / on-demand).

iOS: Apple blocks cellular downloads over ~200 MB — keep per-model bundles <10 MB or require Wi-Fi. Background downloads need a native NSURLSession. Save to Application.persistentDataPath.

The unity_kit streaming layer (UnityAssetLoader, StreamingController, ContentBundle with sha256 integrity checks) wraps exactly this — see Asset streaming & Addressables.

Recommended

Addressables

Addressables is a higher-level system built on top of AssetBundles: you reference assets by string address, and it manages dependencies, memory (reference counting) and remote/local loading for you. It is the recommended system for new projects, and its remote catalog is what lets you ship new models, scenes and materials without an app update.

ConceptDescription
AddressString identifying an asset ("Models/Model_001"), independent of file path
GroupCollection of addresses built into one bundle; controls local/remote strategy
LabelTag on assets; load groups by tag ("rare", "seasonal")
CatalogJSON mapping addresses to bundle locations; can be remote and updatable
Content State.bin file saving build state — critical for content-only updates
Unity: basic loadingcsharp
// A. Load prefab by address
var handle = Addressables.LoadAssetAsync<GameObject>("Models/Model_001");
GameObject prefab = await handle.Task;
GameObject instance = Instantiate(prefab);
// IMPORTANT: handle must be Release()'d when you no longer need more instances

// B. Load + Instantiate in one step
var instance = await Addressables.InstantiateAsync("Models/Model_001").Task;
// Cleanup: Addressables.ReleaseInstance(instance) → destroys AND releases

// C. Load a scene
var sceneHandle = Addressables.LoadSceneAsync(
    "Scenes/GameShowroom",
    LoadSceneMode.Additive,
    activateOnLoad: true
);
SceneInstance scene = await sceneHandle.Task;

// E. Load by label (all models with label "rare")
var listHandle = Addressables.LoadAssetsAsync<GameObject>(
    "rare",
    (GameObject prefab) => Debug.Log(prefab.name)
);
IList<GameObject> rareModels = await listHandle.Task;

Always Release()

Reference counting only works if you release what you load — forget it and the asset never leaves RAM. Prefer InstantiateAsync / ReleaseInstance, which count references automatically.

csharp
// WRONG — memory leak:
var handle = Addressables.LoadAssetAsync<GameObject>("Models/Model_001");
await handle.Task;
// ← forgot Release() → asset will NEVER be freed from RAM!

// CORRECT:
var handle = Addressables.LoadAssetAsync<GameObject>("Models/Model_001");
GameObject prefab = await handle.Task;
GameObject instance = Instantiate(prefab);
Destroy(instance);
Addressables.Release(handle); // ← release reference

// EVEN BETTER — InstantiateAsync (auto reference counting):
var instance = await Addressables.InstantiateAsync("Models/Model_001").Task;
Addressables.ReleaseInstance(instance); // destroys AND releases reference

The remote catalog is the most important feature: build a content-only update against the previous build's addressables_content_state.bin, upload the changed bundles and new catalog, and on next launch each client picks up the new addresses.

Workflow for publishing new contenttext
1. Artist creates a new model in Unity Editor
2. Mark prefab as Addressable: "Models/NewModel_042"
3. Add to group "RemoteModels_Common", label "common"

4. Build content update (NOT a new app build!):
   Addressables > Build > Update Previous Build
   → Point to addressables_content_state.bin from the last build
   → Generates ONLY changed bundles + new catalog

5. Upload to CDN/CCD:
   ├── New catalog JSON + hash
   └── Only changed bundles (new model)

6. Users:
   ├── Open app → CatalogUpdater checks hash
   ├── New hash → download new catalog (a few KB)
   ├── "Models/NewModel_042" is now available
   └── When user wants to see it → download bundle (on demand)

No submission to App Store / Google Play!

Keep the .bin

addressables_content_state.bin must be archived after every app build — without it you cannot generate a content-only update. A new app build produces a new .bin; archive that one for the next content update. On Dart, the update finishes as a catalog_updated message on bridge.messageStream.

ProsCons
Automatic dependencies — no manual trackingAbstraction adds debugging complexity
Reference counting → no leaks (if you Release())Learning curve (groups, profiles, labels)
Content update without an app rebuild (remote catalog)addressables_content_state.bin must be archived
Labels → load groups of assets in one callFirst build is slower than raw AssetBundles
Integration with Unity CCD (zero custom CDN)Cache invalidation can be tricky

Progressive/LOD loading, download-size checks, predownload and the CDN / Unity CCD hosting options are covered in depth on the Asset streaming & Addressables page.

External 3D

glTF / GLB at runtime

glTF 2.0 (and its binary form, GLB) is the industry standard for 3D exchange — every DCC tool exports it. Loading it at runtime means you render models that were never converted to native Unity assets and never shipped in the build: perfect for NFTs and marketplace models, an external Blender/Maya art pipeline, user-generated content, or a backend that returns a model URL the app didn't know about in advance. Prefer GLB — a single binary file loads faster than multi-file .gltf.

Flutter: load an external .glb by URLdart
// Flutter: load an external .glb (e.g. an NFT model) by URL.
// Uses the same ModelController — source 'gltf' routes to glTFast in Unity.
modelController.loadModelFromUrl('nft_042', 'https://api.example.com/models/model.glb');

// Track parsing/download + result
modelController.onModelLoading.listen((data) => setState(() => _loading = true));
modelController.onModelLoaded.listen((data) => setState(() => _loading = false));
modelController.onModelError.listen((data) => showError(data['error'] as String?));

On the Unity side, glTFast (com.unity.cloud.gltfast) is the recommended library — it is the official Unity package and uses Burst + Jobs for ~3–5x faster parsing than pure-C# alternatives on mobile ARM:

Unity: glTFast — install & loadcsharp
// Package Manager → Add by name: com.unity.cloud.gltfast
using GLTFast;

var gltf = new GltfImport();
bool success = await gltf.Load("https://cdn.example.com/models/model_001.glb");

if (success) {
    // Instantiate MAIN scene from the glTF file
    var instantiator = new GameObjectInstantiator(gltf, parentTransform);
    await gltf.InstantiateMainSceneAsync(instantiator);

    // OR simpler version:
    gltf.InstantiateMainScene(parentTransform);
}

// Cleanup — free native resources (textures, meshes)
gltf.Dispose();
FeatureglTFastUnityGLTFGLTFUtility
Mobile performanceBest (Burst + Jobs)GoodGood
SupportUnity officialCommunity (active)Limited
Build size~200 KB~1–2 MB (Json.NET)~500 KB
Draco compressionYesNoNo
KTX/Basis texturesYesNoNo
Parse time, 10 MB GLB~200 ms (mobile)~600 ms (mobile)~500 ms (mobile)

Shrink and speed up GLBs

Enable Draco mesh compression on export (~70% smaller files, ~50 ms Burst decompression) and ship KTX/Basis textures (com.unity.cloud.ktx) so textures go straight to the GPU without a decompression step — glTFast auto-detects both. Peak RAM matters: parsing a 20 MB GLB can momentarily use ~60 MB, so load large files async with a progress indicator.

Platform note: on iOS, local file URIs must carry the file:// prefix and you should declare network usage in the Privacy Manifest; on Android, downloaded models live in Application.persistentDataPath and reading StreamingAssets needs UnityWebRequest.

Camera overlay

AR overlay (AR Foundation)

AR Foundation is Unity's cross-platform AR API — one code path over ARKit (iOS) and ARCore (Android) — that composites your 3D content onto the device's camera feed. In unity_kit the Unity view renders full-screen (camera passthrough + placed model), and Flutter widgets overlay the controls on top with a Stack: place button, scale/rotate sliders, capture button.

FeatureiOS (ARKit)Android (ARCore)Usage
Plane detectionYes (horizontal + vertical)Yes (horizontal + vertical)Place model on floor/table
Image trackingYes (up to 100 ref images)Yes (up to 20 ref images)Scan a card → show a 3D model
Light estimationYes (directional, ambient, probes)Yes (ambient, directional)Realistic model lighting in AR
OcclusionYes (people + LiDAR)Yes (Depth API, limited devices)Model behind a real object
AnchorsYes (persistent)Yes (Cloud Anchors)Save model position → return later

The Dart side is a normal full-screen widget: send EnableAR, react to ar_plane_detected / ar_model_placed, and drive scale over the bridge. This is the production overlay the doc ships:

Flutter: AR UI overlay (ARScreen)dart
class ARScreen extends StatefulWidget {
  final UnityBridge bridge;
  final String modelId;

  const ARScreen({required this.bridge, required this.modelId});

  @override
  State<ARScreen> createState() => _ARScreenState();
}

class _ARScreenState extends State<ARScreen> {
  bool _planeDetected = false;
  bool _modelPlaced = false;
  double _modelScale = 1.0;

  @override
  void initState() {
    super.initState();
    widget.bridge.send(UnityMessage.command('EnableAR', {'modelId': widget.modelId}));
    widget.bridge.messageStream.listen(_onARMessage);
  }

  void _onARMessage(UnityMessage msg) {
    switch (msg.type) {
      case 'ar_plane_detected':
        setState(() => _planeDetected = true);
        break;
      case 'ar_model_placed':
        setState(() => _modelPlaced = true);
        break;
      case 'ar_screenshot_taken':
        final path = msg.data?['path'] as String? ?? '';
        _showScreenshotPreview(path);
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        UnityView(bridge: widget.bridge),
        SafeArea(
          child: Column(
            children: [
              Row(
                children: [
                  IconButton(
                    icon: const Icon(Icons.arrow_back, color: Colors.white),
                    onPressed: () {
                      widget.bridge.send(UnityMessage.command('DisableAR', {}));
                      Navigator.pop(context);
                    },
                  ),
                  const Spacer(),
                  if (_modelPlaced)
                    IconButton(
                      icon: const Icon(Icons.camera_alt, color: Colors.white),
                      onPressed: () {
                        widget.bridge.send(UnityMessage.command('TakeScreenshot', {}));
                      },
                    ),
                ],
              ),
              const Spacer(),
              if (!_planeDetected)
                _buildInstruction('Point camera at a flat surface'),
              if (_planeDetected && !_modelPlaced)
                _buildInstruction('Tap the surface to place the model'),
              if (_modelPlaced)
                Slider(
                  value: _modelScale,
                  min: 0.2,
                  max: 3.0,
                  onChanged: (value) {
                    setState(() => _modelScale = value);
                    widget.bridge.send(
                      UnityMessage.to('ARModelManager', 'ScaleModel', {'scale': value}),
                    );
                  },
                ),
            ],
          ),
        ),
      ],
    );
  }

  @override
  void dispose() {
    widget.bridge.send(UnityMessage.command('DisableAR', {}));
    super.dispose();
  }
}

The Unity-side ARModelManager extends FlutterMonoBehaviour, enables the AR session, loads the model hidden, and places it where a raycast hits a detected plane:

Unity: ARModelManager (core)csharp
/// Manages the AR session and communication with Flutter.
/// Mount on a GameObject with ARSession, XROrigin.
public class ARModelManager : FlutterMonoBehaviour
{
    [SerializeField] private ARRaycastManager raycastManager;
    [SerializeField] private ARPlaneManager planeManager;

    private GameObject _currentModel;
    private readonly List<ARRaycastHit> _raycastHits = new();

    // ─── Enable AR Session ───
    private async Task EnableAR(string modelId)
    {
        arSession.enabled = true;
        planeManager.enabled = true;
        planeManager.planesChanged += OnPlanesChanged;

        _currentModel = await Addressables.InstantiateAsync($"Models/{modelId}").Task;
        _currentModel.SetActive(false); // Hide until user taps

        SendToFlutter("ar_enabled", JsonUtility.ToJson(new {
            modelId = modelId,
            message = "Point camera at a surface"
        }));
    }

    // ─── Place Model at Screen Point (tap) ───
    private void PlaceModelAtScreenPoint(Vector2 screenPoint)
    {
        if (raycastManager.Raycast(screenPoint, _raycastHits, TrackableType.PlaneWithinPolygon))
        {
            Pose hitPose = _raycastHits[0].pose;
            _currentModel.transform.position = hitPose.position;
            _currentModel.transform.rotation = hitPose.rotation;
            _currentModel.SetActive(true);

            SendToFlutter("ar_model_placed", JsonUtility.ToJson(new {
                message = "Model placed! Use gestures to move/rotate."
            }));
        }
    }
}

Ask for the camera in Flutter first

Request camera permission on the Flutter side (e.g. permission_handler) before sending EnableAR. iOS requires NSCameraUsageDescription in the Privacy Manifest; Android needs Google Play Services for AR (ARCore) and a device from the supported list. AR sessions drain battery, so limit session time or warn the user.

Update without release

Shipping content without a store submission

"Hot update" just means changing content or behavior without an App Store / Play submission. The safe, cross-platform answer is: update assets, never compiled code. What you want to change decides the method — and only the last rows carry iOS policy risk:

What to updateMethodiOS safe?Android safe?
3D models, textures, scenesAddressables remote catalogYesYes
Parameters, feature flagsRemote ConfigYesYes
External 3D modelsglTF from CDNYesYes
Animations, audioAddressablesYesYes
C# code (game logic)HybridCLRGray areaYes

iOS Guideline 3.3.2

Apple forbids downloading executable/interpreted code — but assets are fine. Addressables, Remote Config and glTF from a CDN are all accepted on both stores. Downloading C# (HybridCLR) or Lua scripts on iOS is a gray area at best; if you must, restrict it to small bug-fixes, and never download native code. A safer pattern than hot-loading logic is data-driven design: ship a ScriptableObject behavior config through Addressables that your fixed C# already knows how to interpret.

Putting it together

Production example — bridge, load, error handling

This is the end-to-end flow the doc ships. On the Flutter side, a screen initializes the bridge, waits for Unity readiness (catalog update + remote config done), asks ModelController to load a model from either Addressables or a glTF URL, and reacts to every state — download progress, loaded, and both model_error / gltf_error cases:

Flutter: ModelViewerScreen — complete flowdart
class ModelViewerScreen extends StatefulWidget {
  final String modelId;
  final String? modelSource; // 'addressables' or 'gltf'
  final String? glbUrl;      // if source == 'gltf'

  const ModelViewerScreen({
    required this.modelId,
    this.modelSource = 'addressables',
    this.glbUrl,
  });

  @override
  State<ModelViewerScreen> createState() => _ModelViewerScreenState();
}

class _ModelViewerScreenState extends State<ModelViewerScreen> {
  late final UnityBridge _bridge;
  late final ModelController _modelController;

  bool _isReady = false;
  bool _isModelLoaded = false;
  bool _isDownloading = false;
  double _downloadProgress = 0;
  String? _errorMessage;

  @override
  void initState() {
    super.initState();
    _bridge = UnityBridgeImpl();
    _modelController = ModelController(_bridge);
    _initializeUnity();
  }

  Future<void> _initializeUnity() async {
    // 1. Initialize bridge
    await _bridge.initialize();

    // 2. Wait for Unity readiness (catalog update + remote config done)
    _bridge.eventStream
      .where((e) => e.type == UnityEventType.loaded)
      .first
      .then((_) {
        setState(() => _isReady = true);
        _loadModel();
      });

    // 3. Listen for events
    _bridge.messageStream.listen(_onMessage);
  }

  void _onMessage(UnityMessage msg) {
    switch (msg.type) {
      case 'model_loading':
        setState(() {
          _isModelLoaded = false;
          _errorMessage = null;
        });
        break;

      case 'model_download_required':
        setState(() {
          _isDownloading = true;
          _downloadProgress = 0;
        });
        break;

      case 'bundle_download_progress':
      case 'gltf_download_progress':
        setState(() {
          _downloadProgress = (msg.data?['progress'] as num?)?.toDouble() ?? 0;
        });
        break;

      case 'model_loaded':
      case 'gltf_loaded':
        setState(() {
          _isModelLoaded = true;
          _isDownloading = false;
        });
        break;

      case 'model_error':
      case 'gltf_error':
        setState(() {
          _errorMessage = msg.data?['error'] as String? ?? 'Unknown error';
          _isDownloading = false;
        });
        break;

      case 'remote_config_updated':
        // Config updated — Unity applied settings itself
        break;

      case 'catalog_updated':
        // New content available — optionally show "NEW" badge
        break;
    }
  }

  void _loadModel() {
    if (widget.modelSource == 'gltf' && widget.glbUrl != null) {
      _modelController.loadModelFromUrl(widget.modelId, widget.glbUrl!);
    } else {
      _modelController.loadModel(widget.modelId);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          // Unity view (full screen)
          if (_isReady)
            UnityView(
              bridge: _bridge,
              config: UnityConfig(
                sceneName: 'GameShowroom',
                targetFrameRate: 60,
              ),
            ),

          // Loading overlay
          if (!_isReady || _isDownloading)
            Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const CircularProgressIndicator(),
                  const SizedBox(height: 16),
                  Text(
                    _isDownloading
                      ? 'Downloading model... ${(_downloadProgress * 100).toInt()}%'
                      : 'Initializing...',
                  ),
                ],
              ),
            ),

          // Error
          if (_errorMessage != null)
            Center(child: Text('Error: $_errorMessage')),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _modelController.unloadModel();
    _bridge.dispose();
    super.dispose();
  }
}

On the Unity side, a persistent GameManager in the Core scene coordinates startup — device tier, shader warmup, catalog update and remote config — and only then signals ready to Flutter, which is the event the screen above waits on:

Unity: GameManager bootstrapcsharp
/// Main game manager. Mount on a GameObject in the Core scene (persistent).
/// Coordinates: catalog update, remote config, shader warmup, readiness.
public class GameManager : MonoBehaviour
{
    [SerializeField] private ShaderVariantCollection shaderVariants;

    private CatalogUpdater _catalogUpdater;
    private RemoteConfigManager _remoteConfigManager;
    private DeviceTierDetector _tierDetector;

    private async void Start()
    {
        // 1. Detect device tier → set quality
        _tierDetector = new DeviceTierDetector();
        _tierDetector.ApplyQualitySettings();

        // 2. Shader warmup (eliminates stutter on first render)
        if (shaderVariants != null)
            shaderVariants.WarmUp();

        // 3. Catalog update (check for new content)
        _catalogUpdater = gameObject.AddComponent<CatalogUpdater>();
        await _catalogUpdater.CheckAndUpdate();

        // 4. Remote Config (fetch settings)
        _remoteConfigManager = gameObject.AddComponent<RemoteConfigManager>();
        await _remoteConfigManager.Initialize();

        // 5. Ready! Notify Flutter
        NativeAPI.SendToFlutter(JsonUtility.ToJson(new {
            type = "ready",
            deviceTier = _tierDetector.Tier.ToString(),
            catalogUpdated = _catalogUpdater.WasUpdated,
        }));
    }
}

How this maps to unity_kit

Dart UnityMessage.to() / .command() reach a FlutterMonoBehaviour via FlutterBridge + MessageRouter; Unity replies through NativeAPI.SendToFlutter(), which lands on bridge.messageStream (models, config, progress) and bridge.sceneStream (scene events). Readiness and queuing before Unity is up are handled by LifecycleManager and ReadinessGuard — you just await the loaded event.

Let's make something together.

If you have any questions about a new project or other inquiries, feel free to contact us. We will get back to you as soon as possible.

Codigee
We are using cookies. Learn more