Skip to content

Unreal on Apple: framework build, bridge, and engine tick - #5

Open
juicycleff wants to merge 20 commits into
mainfrom
unreal-apple-bridge
Open

Unreal on Apple: framework build, bridge, and engine tick#5
juicycleff wants to merge 20 commits into
mainfrom
unreal-apple-bridge

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

Unreal now builds as a linkable framework on Apple platforms, with a bridge that
carries messages both ways and drives the engine tick. Android moves its library
mode into UPL so it stops failing silently.

Read the last section before planning around this. iOS gets you a framework that
links and launches, and it does not yet render, for a reason that turned out to
be structural rather than a bug.

Android

Library mode used to be spliced into Epic's generated GameActivity.java after
packaging, keyed off one literal line of engine source. When that line moved
between versions the splice matched nothing, logged a warning, and reported
success. You got a clean export, a clean build, and a black rectangle.

It now lives in FlutterPlugin_Android_UPL.xml, which UnrealBuildTool applies
while it generates the intermediate project. If a schema changes under you, your
packaging breaks in the Editor with a node name you can search for, which is a
much better morning than a black rectangle. The file also carries ProGuard
keep rules, because the Kotlin controller reaches those methods by reflection
and R8 was free to strip every one of them out of a release build.

The bridge

Both Apple platforms had a stub that logged its arguments and returned. The
boundary is now a flat C ABI in Public/UnrealBridge.h, free of Unreal types, so
your app never needs CoreMinimal.h, UBT include paths or engine symbols of its
own.

iOS and Mac differ only in the names AFlutterBridge dispatches to, so you get
one implementation in Private/FlutterBridge_Apple.cpp with four shims at the
bottom, not two copies to keep in step.

On the Flutter side the pods resolve the ABI with dlsym instead of linking
against it. UnrealFramework comes from an export step you may not have run yet,
so linking would break your build before you ever got to the interesting part.
weak_import does not save you either: it only makes a symbol optional at load
time while the static linker still demands a definition.

macOS needed more than a rewrite. UnrealBridge.mm imported a Swift file into
Objective-C++, and since the podspec globs Classes/**/* that file was in your
build, so adding Unreal on macOS broke the pod outright. There were also two classes called UnrealBridge,
one there and one declared in Swift with every method returning false under a
comment promising the real version lived in the .mm.

Driving the engine

Building as a framework defines BUILD_EMBEDDED_APP, which switches on
FEmbeddedCommunication in Core. Nothing was using it, so a linked engine sat
there doing nothing.

The ABI grows Init, StartEngine, Tick, KeepAwake and the rest. You do
not have to call the tick yourself: both pods drive it from a display link, so
the engine's timing follows the display it renders to. On iOS the render view comes from the engine itself:
FlutterView_IOS.mm builds an FIOSView, registers it with the app delegate,
and hands it back, so Unreal renders into that view's CAMetalLayer with
nothing copied per frame.

View creation waits for the engine to say it is ready. FAppEntry broadcasts
inisareready once its config is loaded, and the plugin subscribes at module
load because that broadcast is one-shot.

Tests

engines/unreal/dart/ios/Tests/run_bridge_tests.sh
engines/unreal/dart/macos/Tests/run_bridge_tests.sh

Twenty-one and twenty-two checks against a mock framework exporting the real
ABI, covering both directions and the framework-absent path. They finish in
seconds, so run them before you touch the bridge. macOS runs natively, iOS in
the Simulator.

What does not work yet

Embedding Unreal on iOS needs an engine built from source.

We took a signed framework onto a device, so you do not have to. It links,
installs, launches, and Unreal's allocator comes up inside the host process. Then nothing: the binary
contains no inisareready, StartupEmbeddedUnreal exists only as a
message-send stub and throws unrecognized selector, and the embedded-only path
in IOSAppDelegate is compiled out.

An installed engine ships its modules prebuilt, so BUILD_EMBEDDED_APP reached
the project and not the engine. UnrealBuildTool objects to exactly this, and
bOverrideBuildEnvironment silences the objection without answering it, leaving
a binary where one half believes it is embedded and the other does not.

macOS is further off still. It has no embedded rendering path at all, and unlike
iOS that is not fixable from source, because the engine code does not exist.

The library mode additions to GameActivity.java used to be spliced in by
game-cli after packaging, keyed off a literal line of Epic's source. When that
line moved between engine versions the splice matched nothing, logged a
warning, and reported success, so you got a clean export, a clean build and a
black rectangle.

UPL runs inside UnrealBuildTool while it generates the intermediate Android
project, so the additions are declared here instead and are already present
before game-cli looks at the tree. A schema change now breaks packaging in the
Editor with a node name you can search for.

Also adds ProGuard keep rules for the entry points. The Kotlin controller
reaches them by reflection, so R8 sees no callers and would strip them out of a
minified release build, leaving a debug build that works and a release build
that does not.

The launcher intent removal deliberately stays in game-cli. UPL runs for every
Android build of the project, including plain standalone packaging, so
stripping it here would break normal APKs.
Both platforms had a stub. FlutterBridge_IOS.cpp and FlutterBridge_Mac.cpp
logged their arguments and returned, with the real work marked TODO, so nothing
flowed between Unreal and Flutter on either.

The boundary is now a flat C ABI, declared in Public/UnrealBridge.h and free of
Unreal types, so your app never needs CoreMinimal.h, UBT include paths or
engine symbols of its own. Messages go out through callbacks you register, and
come in through UnrealBridge_SendToUnreal and friends. Every entry point is
safe from any thread and hops to the game thread internally; callbacks fire on
the game thread, so the Objective-C layer copies and dispatches to main before
touching your controller.

iOS and Mac differ only in the names AFlutterBridge dispatches to, so the
implementation lives once in Private/FlutterBridge_Apple.cpp with four shims at
the bottom. The two old platform files are gone.

On the Flutter side the pods resolve the ABI with dlsym rather than linking
against it. UnrealFramework is produced by an export step that may not have run
yet, so linking would break those builds, and weak_import does not help because
it only makes a symbol optional at load time while the static linker still
demands a definition. Looking the symbols up at runtime keeps the pod
self-contained and turns a missing framework into one clear log line.

macOS needed more than a rewrite. UnrealBridge.mm did not compile at all: it
imported a Swift file into Objective-C++, and since the podspec globs
Classes/**/*, adding Unreal on macOS broke the pod build outright. There were
also two classes called UnrealBridge, one here and one declared in Swift with
every method returning false under a comment promising the real version lived
in the .mm. Two Objective-C classes under one name means the runtime picks one
without telling you, and the placeholder is the one the controller called. It
is deleted, and the class is now declared in UnrealBridge.h with NS_SWIFT_NAME
so the existing Swift call sites are unchanged.

Tests run in the Simulator on iOS and natively on macOS, against a mock
framework exporting the same ABI, covering both directions plus the
framework-absent path:

  engines/unreal/dart/ios/Tests/run_bridge_tests.sh
  engines/unreal/dart/macos/Tests/run_bridge_tests.sh

The engine half has not been compiled by UnrealBuildTool yet.
The blocker was never that Unreal cannot produce a linkable image. It was that
nothing had ever asked it to.

UE 5.8 links a dynamic library on both Apple platforms. iOS drives it from an
ini key that defaults to off, and UEBuildIOS copies that into
bShouldCompileAsDLL for you:

  [/Script/IOSRuntimeSettings.IOSRuntimeSettings]
  bBuildAsFramework=True

Mac has no equivalent switch, so the target asks directly. Note the escape
hatch: a unique build environment is rejected outright by a launcher-installed
engine, so use bOverrideBuildEnvironment instead.

Building the example project for Mac produces
Binaries/Mac/libGameFrameworkProject.dylib, filetype DYLIB, exporting all
eleven UnrealBridge_* entry points. Loading it with dlopen and driving it
through the pod's bridge runs every call cleanly.

Getting there meant fixing code that had never been compiled:

  - RotatingCube overrode GetFlutterTargetName and OnFlutterMessage_Implementation,
    neither of which the base class declares. It is GetFlutterTargetName_Implementation
    and HandleFlutterMessage_Implementation. It also assigned a FlutterTargetName
    member that does not exist, and was missing includes for UStaticMeshComponent
    and UMaterialInstanceDynamic.
  - FlutterGameMode declared a UFUNCTION GetLevel, which collides with AActor's,
    and used UWorld without including Engine/World.h.
  - FlutterAssetManager called a NewObject overload that does not exist.
  - FlutterBlueprintLibrary assumed FJsonObject::Values is keyed by FString. In
    UE 5.8 it is a shared string type and needs converting.

The bridge itself needed one fix, found by loading the real dylib. Every entry
point dispatched through the task graph before checking anything, and a host
can call in before the engine has initialised, where IsInGameThread and
AsyncTask read globals that do not exist yet. They now return early when no
AFlutterBridge has registered, which is what the callee did anyway.

The Unreal gitignore rules only matched unreal/Binaries, not
unreal/<Project>/Binaries, so a real project layout left 460MB of build output
untracked but visible.
Building as a framework defines BUILD_EMBEDDED_APP, which switches on
FEmbeddedCommunication in Core. That is Unreal's answer to not owning main or
the run loop, and until now nothing was using it, so a linked engine sat there
doing nothing.

The C ABI grows an engine lifecycle: Init, Tick, WakeGameThread, KeepAwake,
AllowSleep, and the two IsAwake queries. Tick deliberately does not marshal
threads, because TickGameThread has to run on whichever thread the host drives
the engine from.

Both pods now drive it from a display link, so the engine's timing follows the
display it renders to rather than an arbitrary timer, and the tick lands on the
main thread where an embedded engine expects to be driven. macOS prefers
NSScreen.displayLink and falls back to CVDisplayLink, which is deprecated from
macOS 15 but still needed at the 10.14 deployment target. iOS uses CADisplayLink,
which is already on the main run loop.

Internal dispatch moves from AsyncTask to FEmbeddedCommunication::RunOnGameThread,
which is documented safe before Init. Work queued during startup is now
delivered once the engine comes up instead of being dropped, and the earlier
guard against calling into an uninitialised engine is no longer load-bearing.

Verified against a real UE 5.8 build: a host linking UnrealFramework.framework
initialises the engine and ticks it, with TickGameThread reporting work done on
every frame.
…bedding

Unreal's embedded mode does not create its own view. LaunchIOS.cpp compiles that
branch out under BUILD_EMBEDDED_APP and says what it expects instead: "For
embedded apps, the UEEmbeddedView must have been created and set into the
AppDelegate as IOSView".

So Private/IOS/FlutterView_IOS.mm does that. It builds an FIOSView, registers it
with the app delegate, creates its framebuffer, and hands it back through the C
ABI as an opaque pointer. The pod casts it to UIView* and the engine renders
into that view's CAMetalLayer, with nothing copied per frame. Sizes cross in
points and the engine works in pixels, so the scale factor is applied on the way
in.

View creation waits for the engine. It depends on config that is not loaded at
startup, and Unreal announces when it is: FAppEntry broadcasts "inisareready"
on the embedded-to-native channel. The plugin subscribes at module load, which
is early enough because the module is PreDefault and the broadcast is one-shot.
UnrealBridge_SetEngineReadyCallback fires immediately if the announcement has
already been and gone, so registration order does not matter.

Something also has to start the engine, because the host owns main() and the app
delegate and Unreal's launch path never runs. That is UnrealBridge_StartEngine,
wrapping +[FIOSView StartupEmbeddedUnreal], which IOSView.cpp calls the
"LaunchIOS replacement".

Getting the iOS framework to build at all took three fixes. bBuildAsFramework
makes UEBuildIOS set bShouldCompileAsDLL and define BUILD_EMBEDDED_APP, but UBT
then objects that the target changes settings shared with UnrealGame's build
products. FlutterView_IOS.mm assumed an embedded build and broke plain iOS
compiles, so it has a non-embedded branch. And the framework link failed on
swiftCompatibility56, which Xcode adds when it drives an app link and UBT does
not when it links a dylib; the target adds the toolchain path, choosing device
or simulator by architecture.

Then we ran it on a device, and that is the part worth recording.

The framework links, installs, launches, and Unreal's allocator comes up inside
the host process. The engine never boots. The binary contains no "inisareready",
StartupEmbeddedUnreal exists only as a message-send stub and throws
unrecognized selector, and the embedded-only path in IOSAppDelegate is compiled
out. An installed engine ships its modules prebuilt, so BUILD_EMBEDDED_APP
reached this project and not the engine.

UBT's original objection was the accurate signal: that setting needs the engine
rebuilt. bOverrideBuildEnvironment silences the objection without answering it,
and yields a binary where one half believes it is embedded and the other does
not. Embedding Unreal on iOS requires an engine built from source. Everything
here is correct and verified as far as an installed engine can go.

Also fixes template bugs found along the way: two overrides in RotatingCube
naming methods the base class does not declare, a UFUNCTION GetLevel colliding
with AActor's, a NewObject overload that does not exist, and FJsonObject::Values
being keyed by a shared string type in UE 5.8 rather than FString.
The engine now boots inside the host process, which took getting past several
things that only fail once you build and run them.

Engine patch, recorded under engines/unreal/patches rather than applied
silently: six numeric conversion defects in EmbeddedCommunication.cpp, all
inside code that only compiles when BUILD_EMBEDDED_APP is defined, all errors
under UE 5.8's warning levels. Five are narrowing. One is a real bug, where
ForceTickMin and ForceTickMax are seconds parsed with Atoi, so -ForceTickMin=0.05
became zero. Six defects in one file says how rarely that path gets built.

The editor target sat on BuildSettingsVersion.V6 while the installed engine is
built with V7 defaults, so UBT refused it for modifying shared settings. Matching
the engine is the fix, not overriding it.

The game module used FJsonObject without depending on Json or JsonUtilities. A
monolithic game target pulls those in through the plugin and links fine, so the
omission only surfaced when the modular editor target tried to link and produced
a wall of undefined symbols. Cooking needs that editor target, so this blocked
content entirely.

Where it stands: the framework builds from a source engine, the host links and
launches it, and Unreal reads its cooked content and applies device profiles
inside the host process. It then crashes before RHI initialisation, with no
LogRHI output at all, which is where it wants the render view. The view is
currently created on the readiness signal, and that ordering looks wrong against
what FAppEntry does while it waits. Not yet resolved.
Two things were stopping it, and the second only became findable once the first
was fixed.

The readiness handshake could never complete. FEngineLoop::PreInit calls
PlatformInit around line 2886, which broadcasts "inisareready" and then blocks,
spinning until AppDelegate.IOSView exists. Plugin modules for the PreDefault
phase do not load until around line 4675, which execution never reaches. So the
engine announced readiness before anything in this plugin was alive to hear it,
then waited for a view that a host listening for that announcement would never
create. Both sides waiting on each other.

The engine polls for the view, so the host can just offer one every tick until
it takes, and UnrealBridge_CreateView now gates on the engine having been
started rather than on an announcement that cannot arrive. Before StartEngine is
still refused: Metal comes up during engine startup, and building a view without
it crashes, which was worth confirming on a device.

With that fixed the engine got through renderer init and stopped at "Failed to
load package ''", because the scaffolded GameDefaultMap was empty. It now points
at /Engine/Maps/Entry with FlutterGameMode as the default game mode.

Verified on an iPhone 16 Pro against a source-built UE 5.8.2: the engine boots,
loads the map, brings the world up for play with FlutterGameMode, registers our
router, and draws into the FIOSView handed across the C ABI. The default mobile
touch interface renders and responds to touch.

One caveat worth recording for whoever wires up the Flutter platform view. The
engine's view must be inside the visible hierarchy, not merely in the window. A
CAMetalLayer behind an opaque view is never composited, its drawables are never
released, and the game thread blocks on nextDrawable once the queue fills. That
presents as a static screen and an engine stuck at a couple of dozen frames,
which looks nothing like a parenting mistake.
The startup flow that got Unreal rendering on device now runs behind
GameWidget instead of a throwaway host app.

Three things changed in the pod. It starts the engine itself, because
nothing works until StartEngine runs and that is what brings Metal up. It
offers a render view from the display link tick rather than waiting to be
told when one can be made. And it takes a keep-awake so the engine
actually ticks.

The readiness callback is gone. It could never have worked: the engine
broadcasts from PreInit and then blocks waiting for a view, while plugin
modules only load later in that same PreInit, so by the time anything
could subscribe the broadcast has been and gone and the engine is already
stuck. Polling from the tick is how the engine expects to be handed a
view anyway, and CreateView returning non-NULL is the only readiness
signal that means anything.

Container resizes now reach the engine. GameEngineContainerView already
stretched the engine view on layout, but nobody told the engine its new
pixel size, so a rotation left Metal drawing at the startup resolution.
That shows up as a blurry or cropped scene rather than as a crash, which
is the kind of thing you chase for an afternoon. Engines opt in by
overriding engineViewDidResize, and Unity is untouched.

Tests follow the new contract: the bridge keeps offering while the engine
starts, hands the view to the controller exactly once, and stops asking
once it has one. That last one matters, since the poll runs at display
link rate. All five were checked by mutation, and the controller handoff
had no coverage at all before this.

INTEGRATION.md covers the one piece the pod cannot do for you. Unreal
insists, as a Fatal, that an embedding app's delegate subclasses
IOSAppDelegate, and Flutter apps normally subclass FlutterAppDelegate. You
cannot have both superclasses, so the doc gives you the runtime approach
that was verified on device.
Unreal will not start unless your app delegate descends from IOSAppDelegate.
It says so as a Fatal, and the fallback path Epic wrote for the alternative
is behind an #if 0 with a comment saying it is unlikely to work well. So
subclassing is the only route, and until now it meant either shipping engine
headers to every consumer or hand-editing main.m to build the class at
runtime.

The pod now ships UnrealAppDelegate.h, which declares just enough of
IOSAppDelegate to subclass it. Import it from your bridging header and write
`class AppDelegate: IOSAppDelegate`. No engine headers, and the pod itself
still links without UnrealFramework, which is checked: nothing in the new
code references the class by name, so no undefined symbol shows up in the
object file.

Redeclaring someone else's class can rot, so the bridge checks it at runtime
before starting the engine. It verifies the superclass is still UIResponder,
that both redeclared properties are still there, and that your delegate
really does descend from IOSAppDelegate. All of it goes through the runtime
with the class passed in as an argument, which is what lets the checks be
tested without a UIApplication. A spawned test binary has no sharedApplication
at all, which we measured rather than assumed.

Two things surfaced while testing that the docs would otherwise have got
wrong.

Swift could not see the window property. Unreal spells it with a capital W,
and Swift decides that is the pre-Swift-3 name for UIApplicationDelegate's
lowercase window and marks it obsoleted. It comes across as unrealWindow now.
Objective-C still sees Window.

The worse one: Unreal reads its Window for interface orientation but only
assigns it on the startup path an embedded app skips. What fills it in is a
naming coincidence, since the Window property generates a setWindow: setter,
which is the selector UIKit calls when the storyboard loads. Declare your own
window property and you take that selector over, Unreal's stays nil, and
orientation goes wrong with nothing reporting an error. The bridge warns about
it by name. A warning rather than a refusal, because a scene delegate driven
app legitimately has no window here.

Both example delegates in INTEGRATION.md were compiled against a stub of the
Flutter API before being written down. The first version did not build, since
IOSAppDelegate does not conform to FlutterPluginRegistry, so plugins register
against the FlutterViewController instead.

Eleven new checks, all mutation tested. The mock framework grew an
IOSAppDelegate for the same reason the real one exports one.
…them

Every control in the demo did nothing, and every layer reported success.
Dart sent, Swift dispatched, the pod handed the message to the framework,
the bridge logged "Received from Flutter", and the cube logged that it had
registered. Four boundaries crossed correctly, and then this:

    void AFlutterBridge::ReceiveFromFlutter(...)
    {
        UE_LOG(...);
        OnMessageFromFlutter(Target, Method, Data);
    }

A Blueprint event and nothing else. UFlutterMessageRouter::RouteMessage
exists, every AFlutterActor registers itself with the router by name and
waits to be called through it, and nothing ever called it. A project without
Blueprints receives nothing at all.

There was no error anywhere because nothing failed. A message with no route
is only a problem if somebody checks, and nobody did, so it now warns and
names the target it could not reach.

The rest is what it took to get an embedded engine far enough to notice.

Ticking. The host's tick is a display link on the main thread, not Unreal's
game loop, and it is not safe to treat it as one. It runs before the engine
has loaded its inis, where FEmbeddedCommunication::TickGameThread reads a
setting through GConfig without checking it, so the tick guards on that.
Anything needing the real game thread goes through a core ticker instead:
marshalling through RunOnGameThread does not help, because TickGameThread
runs queued work on whoever calls it, which is that same main thread.
Changing the resolution from there aborts the process inside the CVar write.

The render target. The engine builds its viewport before the host's view
exists, at a default 1280x720, and nothing corrects it. It then renders that
16:9 frame into a correctly sized portrait surface, which looks like the
scene has been cropped rather than like a render target of the wrong shape.
Resizing the scene viewport is what moves it. Asking for a resolution change
does not: the console manager refuses the r.SetRes write on priority grounds
and says so in the log.

Startup. The engine has to start during didFinishLaunching, because
IOSAppDelegate reads the command line when the app becomes active and
starting the engine is what sets it. The tick has to start with it, or the
engine blocks waiting for a view that only the tick offers while Flutter
waits for a frame it cannot produce.

Also: both podspecs now vendor their framework when it is sitting next to
them, which is the case after a sync with no separate game plugin in
between. Without that nothing embeds it, the app launches, and the class is
missing at runtime.
A scaffolded project has no level of its own and boots an engine map, which
is empty, so the demo rendered a correctly running engine showing nothing
and there was no way to tell that apart from a broken one.

AFlutterDemoScene spawns the lot at BeginPlay: sun and fill light, a floor,
the hero cube, five orbiting shapes, and a camera. Everything it uses ships
with the engine and is already cooked, so there is no asset to make first
and no reason to open the editor.

Four things in here are less obvious than they look.

The shapes came out grey because their own material is not cooked into this
build, so they fall back to WorldGridMaterial and DefaultMaterial, neither
of which exposes a colour. Setting a parameter a material does not have
fails silently and looks exactly like a scene of identical checkerboards, so
the scene asks candidates what they expose and uses one that answers.

ACameraActor's constructor pins the camera to 16:9 and turns the aspect
constraint on, and the engine then adds black bars. On a phone held upright
that is most of the screen. With the constraint off, which axis holds the
FOV decides what a tall screen shows, and the component's setting is ignored
unless the override is on, so both are set.

AFlutterBridge::GetInstance only ever looks for an actor. With no level
there is nothing to have placed it in, so the game mode spawns one before
anything asks, or every message from Flutter is dropped in silence.

The on-screen sticks are removed at runtime. Setting DefaultTouchInterface
in the ini would need a re-cook, and they only steal touches from the orbit.

Drag anywhere to look around, pinch to zoom. Both track deltas between
frames rather than absolutes, so a drag does not snap the camera to wherever
your finger landed, and pinch is checked first because during one the first
finger is moving too.

The example's AppDelegate now subclasses IOSAppDelegate through the header
the pod ships, which is what the engine requires of an embedding app.
Second half of the inbound path. With ReceiveFromFlutter finally calling the
router, messages reached it and died one step later:

    FString CacheKey = GetCacheKey(Target, Method);   // "Demo.setColor"
    if (TryRouteCached(CacheKey, Method, Data)) { ... }

Meanwhile every actor registers like this:

    Router->RegisterMethod(TargetName, TEXT("*"), MessageDelegate);

So the handler lives under "Demo.*" and the router only ever looked for the
exact method name. The wildcard was written on one side and never
implemented on the other, which made it a handler nothing could reach.

Route now falls back to the wildcard key after the exact one misses, for
text and binary alike. The actor still receives the real method name, so it
can dispatch on it as before.

Also traces the two ends of the game thread queue back to Flutter. The
engine's log file is buffered and mostly shows startup, so a message that
disappears between the bridge and an actor leaves nothing to read, and this
took far longer to find than it should have for exactly that reason. The
trace goes through the message callback rather than the bridge actor, so it
still arrives when the thing being diagnosed is the bridge actor itself.

Confirmed on device: setSpeed reaches the cube and it answers with
onSpeedChanged carrying the new value.
… the camera

Setting the cube's colour aborted the process. Setting its speed did not,
one line away in the same handler, through the same route, on the same
actor. That difference is not about delivery, it is about what the two
operations touch:

    ARotatingCube::SetColor
    UMaterialInstanceDynamic::SetVectorParameterValue
    GameThread_UpdateMIParameter<FVectorParameterValue>
    FDebug::CheckVerifyFailedImpl2

Every message from Flutter was running on the main thread. TickGameThread
drains its queue on whoever calls it, and the host drives that from a
display link, so the work landed on the wrong thread and mostly got away
with it. Writing a float to an actor is harmless. Touching a material is
not, and the renderer checks its caller.

The engine already drains that queue from its own core ticker, on the real
game thread, so the tick now leaves it alone unless it genuinely is that
thread. The call stays for a host that drives the engine itself, which is
the other arrangement this ABI supports.

Worth noting the two earlier routing fixes were working the whole time.
They delivered messages onto a thread that had been wrong since the start;
nothing had ever travelled far enough to expose it.

Also streams the camera to Flutter while the viewer moves it, as
Camera.moved with zoom as a fraction of the range, the raw distance, and the
orbit angles. Throttled, and silent while the camera drifts on its own,
since drift crosses any sensible threshold several times a second and would
otherwise report continuously about a camera nobody is touching.
Flutter has one place that sees all traffic: GameWidget's onMessage gets
everything and decides what to do with it. Unreal had no equivalent for C++.
You had to give an actor a name, register it with the router, and hope the
name matched, which for a single-scene app is ceremony around a switch
statement.

AFlutterBridge now exposes OnAnyMessageFromFlutter, a multicast that fires
for every message whatever it was addressed to, assignable from Blueprint
and from C++ with AddDynamic. It broadcasts after routing, so a named
handler still sees the message first and binding this takes delivery away
from nothing.

The router also accepts "*" as a target, so an actor can register as the
catch-all rather than being given a name of its own.

Tidying, both of which were debugging aids that should not have been left
on. The message trace is behind a console variable now, off by default,
since it doubles the traffic; turn it on from the host with
executeConsoleCommand("flutter.TraceMessages 1") when a control appears to
do nothing. And the demo's render state went from every three seconds to
once, on the first tick after the view is claimed rather than in BeginPlay,
where it reported zeroes because the viewport did not exist yet.
Written by "game sync unreal -p ios" now rather than by hand, which is the
point of the change in game-cli that generates it.
It printed client=0x0 scene=0x0, because claiming the view happens during
BeginPlay and the viewport does not exist until later, so the first tick
always beat it. A diagnostic that reports zeroes is worse than one that says
nothing: it looks like an answer.

Waits for a viewport with a size now.
The render state reports client=804x1748 now, which is right, and alongside
it fov=70. That is the landscape value. PositionCamera runs during BeginPlay,
where asking for the aspect ratio gives nothing, so it settles on the wrong
field of view and the scene starts tighter than intended.

Easy to miss, because it corrects itself the moment you drag.

Frames it again on the first tick where the viewport has a size, next to the
report that exposed it.
Binds OnAnyMessageFromFlutter in the demo scene, which never registers a name
for itself, and adds a "reset view" button that sends to NoSuchActor. No
actor is registered under that, so the router drops it, which is the point.

On device:

    Scene.saw NoSuchActor.resetCamera

The camera resets. A message reached an actor through a path the named
routing could not deliver, and the scene handled it without telling Flutter
what it is called, the same shape as GameWidget's onMessage on the other
side.

The two also coexist, which was the other thing worth knowing. Every message
to the cube now produces both GameFrameworkDemo.onColorChanged from the
router and Scene.saw from the bridge, so binding this takes delivery away
from nothing.
Pause did not pause. OnEnginePause set a flag and fired a Blueprint event,
which stops nothing: actors keep ticking, time keeps advancing, and the only
things that appear to pause are whatever checked the flag themselves. That is
why it looked like it paused the cube and nothing else.

Unity calls pause on the player and the whole thing stops, so this now calls
UGameplayStatics::SetGamePaused and drops the keep-awake with it. A paused
game that still renders every frame costs the same battery as a running one,
which rather defeats the point on a phone.

Unload is new, and honest about what Unreal allows. Unity's own comment says
iOS cannot unload without destroying, so it pauses instead; Unreal has the
same limit and this does not pretend otherwise. What it can do is stop: the
game pauses, and the render view goes away, which frees the drawable and its
buffers. That is most of what an idle engine costs while you are looking at
some other Flutter page. Reversible through reload(), which is a real API the
whole way down rather than a message: Dart, the channel, Swift, the pod.

Two things bit while building it.

Releasing the view is not enough on its own, because offering the engine a
view is exactly what the tick is for, so it rebuilt one on the next frame.
Unload needs to suppress that until reload, not just destroy once.

And AllowSleep is matched: it asserts when released without a KeepAwake to
match. Releasing it from both the pod and the bridge against a single
keep-awake aborted the process on unload. The engine side owns that pairing
now, and pause and resume are idempotent, which matters on its own because a
page lifecycle calls pause more than once.

The demo's Scene.saw echo is behind flutter.TraceMessages with the rest of the
tracing, and the lifecycle controls sit in the header where they cannot be
pushed off the bottom of a panel.

On device: unload, reload, and unload again, with the view rebuilt three
milliseconds after reload and no crash on the repeat.
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

… none of

Unreal has no embedded mode on Mac. UEBuildIOS is the only thing in
UnrealBuildTool that defines BUILD_EMBEDDED_APP, and the only embedded view
code in the engine is under ApplicationCore/*/IOS. So every one of the
nineteen guarded bodies in EmbeddedCommunication.cpp compiled away on Mac:
RunOnGameThread dropped the lambda, TickGameThread did nothing, and a host
talking to the engine was talking to no-ops that reported success.

The Mac target defines it now. It has to reach the engine's own modules and
not just this project, because FEmbeddedCommunication lives in Core, so the
target takes a unique build environment and the engine rebuilds with it. A
source engine allows that and an installed one refuses, the same constraint
embedding already has on iOS.

FlutterView_Mac.mm then does by hand what the Mac app delegate normally does.
There is no StartupEmbeddedUnreal to call, so StartEngine sets a command line
and hands GuardedMain to RunGameThread exactly as LaunchMac does. CreateView
waits for the engine to build its own FCocoaWindow, borrows that window's
content view, and orders the window out so it does not sit on screen empty
beside the Flutter one.

Borrowing rather than building. The engine already made a view with its Metal
layer configured the way it wants, and taking that is far less likely to be
wrong than assembling a second one next to it. Sizes are points here, not
pixels: AppKit scales for the backing store itself, and multiplying by the
scale factor again would render four times the area on any Retina display.

The Mac dylib exports all twenty five bridge symbols now, CreateView and
StartEngine among them, which it could not do at all before.

Two things this does not yet do. Nothing has run: the pieces that would let
Flutter show it do not exist on Mac yet, which is the next commit. And the
macOS bridge suite's display link check now fails wherever the display is
asleep, since CVDisplayLink does not fire then. That is the test depending on
the machine rather than on the code, and worth fixing on its own terms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Has there been any new progress on Unreal? [Bug]: libUnrealFlutterBridge.so not ships with generated .apk file

1 participant