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.
| Approach | Asset loading | Code loading | iOS safe | Android safe | Complexity | Best for |
|---|---|---|---|---|---|---|
| Scene loading | Built-in | No | Yes | Yes | Low | Switching game contexts |
| Prefab instantiation | Yes | No | Yes | Yes | Low | Loading individual models on-demand |
| AssetBundles | Yes | No | Yes | Yes | High | Legacy projects, full control |
| Addressables + CCD | Yes | No | Yes | Yes | Medium | Primary content delivery system |
| glTF/GLB runtime | Yes | No | Yes | Yes | Medium | User-generated content, NFTs, external 3D |
| AR Foundation | N/A | No | Yes | Yes | Medium | AR 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:
| Method | App size impact | Update without release | Complexity | Use case |
|---|---|---|---|---|
| Scenes in build | Baked into the APK/IPA | Only if the scene comes from Addressables/remote | Low | Switching whole contexts |
| Prefabs via Addressables | Out of the base build (remote group) | Yes | Low | Individual models on demand |
| Resources folder | Increases the build (all packed, no lazy load) | No | Low | Discouraged — avoid |
| Raw AssetBundles | Out of the base build (CDN / StreamingAssets) | Yes (manual versioning) | High | Legacy / full control |
| Addressables + remote catalog | Small base + remote bundles | Yes (recommended) | Medium | Primary content delivery |
| glTF/GLB at runtime | Models never in the build | Yes | Medium | External / UGC / NFT models |
| AR Foundation | ARKit/ARCore plugins add size; models via Addressables | Content yes, framework no | Medium | Camera 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: 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 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:
// ═══ 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();
}// ═══ 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.sceneStream → SceneInfo (name, buildIndex, isLoaded, isValid).
| Pros | Cons |
|---|---|
| Full context switching — change the whole world at once | Scenes in Build Settings increase APK/IPA size |
| Async loading = zero lag with a correct implementation | Large scenes = memory spikes (peak RAM during loading) |
| Additive = modularity, isolation, team collaboration | Lightmaps / navmesh / reflection probes need managing |
| Scenes from Addressables = remote loading | Only one scene is "active" at a time (skybox, lighting) |
Progress tracking via AsyncOperation.progress | Synchronous 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.
// ═══ 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: 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 ?? {});
}| Scenario | Method | Source | Details |
|---|---|---|---|
| Single model preview | LoadModel | Addressables | Load into scene with pedestal, orbit camera, lighting |
| Collection (grid) | Loop InstantiateAsync | Addressables | Load thumbnails (LOD 2) on grid; full model only after tap |
| Swipe carousel | SwapModel | Addressables | Preload next/previous; swap without an empty frame |
| NFT from marketplace | LoadModel(gltf) | glTF from URL | Model arrives as .glb from an API; no prefab in Unity |
| Customization | SetMaterial | Addressables | Load 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.
// 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.
// 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:
| Option | Size on disk | Load time | RAM during load | Usage |
|---|---|---|---|---|
| Uncompressed | Large | Fastest | Low | Dev / debug |
| LZMA | Smallest | Slow (full decompression) | High peak | Download, then re-compress to LZ4 |
| LZ4 (ChunkBased) | Medium | Fast (chunk-by-chunk) | Low | Recommended 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.
| Concept | Description |
|---|---|
| Address | String identifying an asset ("Models/Model_001"), independent of file path |
| Group | Collection of addresses built into one bundle; controls local/remote strategy |
| Label | Tag on assets; load groups by tag ("rare", "seasonal") |
| Catalog | JSON mapping addresses to bundle locations; can be remote and updatable |
| Content State | .bin file saving build state — critical for content-only updates |
// 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.
// 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 referenceThe 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.
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.
| Pros | Cons |
|---|---|
| Automatic dependencies — no manual tracking | Abstraction 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 call | First 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 (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:
// 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();| Feature | glTFast | UnityGLTF | GLTFUtility |
|---|---|---|---|
| Mobile performance | Best (Burst + Jobs) | Good | Good |
| Support | Unity official | Community (active) | Limited |
| Build size | ~200 KB | ~1–2 MB (Json.NET) | ~500 KB |
| Draco compression | Yes | No | No |
| KTX/Basis textures | Yes | No | No |
| 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.
| Feature | iOS (ARKit) | Android (ARCore) | Usage |
|---|---|---|---|
| Plane detection | Yes (horizontal + vertical) | Yes (horizontal + vertical) | Place model on floor/table |
| Image tracking | Yes (up to 100 ref images) | Yes (up to 20 ref images) | Scan a card → show a 3D model |
| Light estimation | Yes (directional, ambient, probes) | Yes (ambient, directional) | Realistic model lighting in AR |
| Occlusion | Yes (people + LiDAR) | Yes (Depth API, limited devices) | Model behind a real object |
| Anchors | Yes (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:
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:
/// 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 update | Method | iOS safe? | Android safe? |
|---|---|---|---|
| 3D models, textures, scenes | Addressables remote catalog | Yes | Yes |
| Parameters, feature flags | Remote Config | Yes | Yes |
| External 3D models | glTF from CDN | Yes | Yes |
| Animations, audio | Addressables | Yes | Yes |
| C# code (game logic) | HybridCLR | Gray area | Yes |
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:
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:
/// 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.
