Unreal on Apple: framework build, bridge, and engine tick - #5
Open
juicycleff wants to merge 20 commits into
Open
Unreal on Apple: framework build, bridge, and engine tick#5juicycleff wants to merge 20 commits into
juicycleff wants to merge 20 commits into
Conversation
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.
This was
linked to
issues
Sep 4, 2026
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.javaafterpackaging, 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 applieswhile 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, soyour app never needs
CoreMinimal.h, UBT include paths or engine symbols of itsown.
iOS and Mac differ only in the names
AFlutterBridgedispatches to, so you getone implementation in
Private/FlutterBridge_Apple.cppwith four shims at thebottom, not two copies to keep in step.
On the Flutter side the pods resolve the ABI with
dlsyminstead of linkingagainst it.
UnrealFrameworkcomes 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_importdoes not save you either: it only makes a symbol optional at loadtime while the static linker still demands a definition.
macOS needed more than a rewrite.
UnrealBridge.mmimported a Swift file intoObjective-C++, and since the podspec globs
Classes/**/*that file was in yourbuild, 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 onFEmbeddedCommunicationin Core. Nothing was using it, so a linked engine satthere doing nothing.
The ABI grows
Init,StartEngine,Tick,KeepAwakeand the rest. You donot 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.mmbuilds anFIOSView, registers it with the app delegate,and hands it back, so Unreal renders into that view's
CAMetalLayerwithnothing copied per frame.
View creation waits for the engine to say it is ready.
FAppEntrybroadcastsinisarereadyonce its config is loaded, and the plugin subscribes at moduleload because that broadcast is one-shot.
Tests
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,StartupEmbeddedUnrealexists only as amessage-send stub and throws
unrecognized selector, and the embedded-only pathin
IOSAppDelegateis compiled out.An installed engine ships its modules prebuilt, so
BUILD_EMBEDDED_APPreachedthe project and not the engine. UnrealBuildTool objects to exactly this, and
bOverrideBuildEnvironmentsilences the objection without answering it, leavinga 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.