Repository navigation
filament-kmp 0.7.0: from four actuals per class to one API generated from C++
#185
Pinned
Erkko68
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
0.7.0 is out. On the surface it's a source-breaking minor release. Underneath, it rebuilds how the library
binds Filament. This post explains what changed, why we did it, how we got there one step at a time, and
what we learned wrapping a large C++ engine for Kotlin Multiplatform. If you're binding a big native
library in KMP yourself, most of the lessons at the end apply to you.
TL;DR
expect classincommonMainwith four hand-writtenactuals. Eachone sat on a different underlying binding: upstream's Android Java, our hand-written C (JVM through
Panama, iOS through cinterop), and upstream's embind
filament.json web.commonMain, and calls oneFila*C API generated fromFilament's C++ headers. Only the way an
external funreaches its C symbol differs per platform, andnobody writes that part by hand.
breaking part. The migration guide
lists every change.
Where we were in 0.6.0
KMP's textbook answer to "same API, different implementation per platform" is
expect/actual, andthat's how filament-kmp started. Here is
Scenein 0.6.0, trimmed to one method:flowchart TB E["commonMain: expect class Scene"] E --> A["androidMain actual"] --> AJ["upstream Java (filament-android AAR)"] --> F[Filament C++] E --> J["jvmMain actual"] --> P["Panama / jextract"] --> C["hand-written C"] --> F E --> N["nativeMain actual"] --> CI["cinterop"] --> C E --> W["webMain actual"] --> EM["upstream embind filament.js"] --> FThat's three different underlying APIs, each written by someone else or by hand, for every class. It worked,
and 0.1 to 0.6 shipped on it. The cost grew with every release, though:
@PlatformGapannotations: methods that did nothing, did something different, or threw on someplatform. The web
actualabove keeps its own copy of the scene's entities because embind can't bindScene::forEach.filament.jsexposed.Closing a web gap meant an upstream PR. On Android we were tied to upstream's Java API and to when Google
published the AARs. 0.5.0 had to force
compileSdk = 37on everyone because the AARs required it.check-common-api.sh) comparedcommonMainagainstupstream's Android Java API, which is itself a lossy, hand-curated copy of the C++ headers.
cinterop call, an Android forwarder and an embind lookup, each with its own types (
MemorySegment,CPointer, a Java object, a JS object), its own memory handling and its own bugs. The 0.5.0 changeloghas a "Fixed" list that's mostly one-platform bugs: callbacks leaking on JVM/iOS,
Engine.configwrong on JVM/iOS,
Filamat.init()a no-op on iOS, texture formats falling back on web…By 0.6.0 we had about 22,500 lines of platform-specific Kotlin (android 4.6k, jvm 5.4k, native 5.5k,
web 7.0k) and roughly 4,900
actualdeclarations.Where we are in 0.7.0
flowchart TB H["Filament C++ headers"] -->|"generateCApi"| C["Fila* C API (generated, committed)"] C -->|"generateKotlinExternals"| X["commonMain capi: internal external fun FilaScene_addEntity(…)"] K["commonMain: class Scene { fun addEntity(e) = FilaScene_addEntity(handle, e) }"] --> X X -->|"iOS: @SymbolName, direct call"| C X -->|"JVM + Android: JNI, forwarders generated"| C X -->|"js + wasmJs: wasm export installed as a global"| CScenetoday:It calls an external generated from the C header:
The external binds a C function generated from the C++ header:
The only
expect/actualleft is a small interop runtime: what a pointer is, how an array getsinto native memory, and how a callback gets back out.
NativePointerLongLongInt(wasm32 address)NullPointer0L0L0ExternalSymbolName@SymbolNameInteropScopesingleThreadedfalsefalsetrueHow we got there
We didn't plan this as one rewrite. It happened in five steps, each shipped on
mainwith every testsuite green on all five targets before the next one started.
1. Web moves onto our C API (#154)
Web was the platform with the most gaps and the least control, so it went first. We compiled our C layer
with Emscripten into our own
filament-kmp.wasmand dropped embind entirely. Web suddenly had the sameAPI as JVM and iOS: shadow types, HDR decoding, IBL prefiltering, morph targets,
readPixelsand more,all at once.
MaterialBuilderworks on web too, through an optionalfilamat-kmp.wasm.That PR also proved the idea behind everything that followed. Once the Kotlin externals were generated
from the C headers, web had the same API as the other platforms by construction.
2. Android moves onto our C API (#159)
Android dropped upstream's
filament-android,gltfio-android,filament-utils-androidandfilamat-androidAARs and bound our C API through JNI instead, building onelibfilament-c.soper ABIfrom upstream's native prebuilts. Upgrades stopped waiting for Google Maven, and Android-only bugs went
away because Android now runs the same C code as everything else. Porting also turned up real bugs: the
generated
uberarchive.hwas falling through to the Linux size on Android, soUbershaderProviderfoundno materials.
After this step every platform called the same C API, but each still had its own
actual, callingthat C API with its own pointer type.
3. Write each class once (#162)
This is the step that removed
expect/actualfrom the API, and the model comes straight fromskiko, which binds Skia the same way. In
commonMainyou candeclare an
external fun, and each Kotlin backend reads it differently:@SymbolName(through an optional expectation, so common code can use it) thecall goes straight to the C symbol. No cinterop stubs; the cinterop only packs the static libraries into
the klib.
nativemethod. A build task scans the common externals and writes oneJava_…forwarder each. The forwarders include the real C headers, so the C compiler rejects a Kotlindeclaration that disagrees with the C prototype.
external funwith no module resolves to a global of the same name. The wasmruntime copies every
_FilaXexport ontoglobalThis.FilaXat startup.Because JVM desktop and Android now share a
jniMainsource set, Project Panama and jextract wentaway, and the desktop JDK floor dropped from 22 to 17. The same PR collapsed the build around one
registry of native targets and one CMake entry point, and deleted ~38k lines.
4. Generate the C API from the C++ headers (#163, #171)
At this point the C layer itself was the last hand-written translation of Filament's API, and the last
thing tying us to a reference that wasn't the headers. So we built a generator:
A few decisions shaped it:
c/api-headers.txtlists the headers that count as API, and-filament::View::setDebugCamera-style lines leave declarations out. Every skip carries a commentsaying why, and a skip line that matches nothing fails the build, so the list can't quietly go stale
across upgrades.
BloomOptions,Engine::Config) become handles with_create/_destroyand a getter/setter per field, seeded withC++'s defaults, so struct layouts never cross the boundary.
std::optionalbecomes a nullable pointeror a
boolplus out-pointer.std::functionbecomes a C function pointer plusvoid* user. Overloadsget suffixed by the parameter types that tell them apart. Templates are bound for the instantiations a
table lists.
written by hand in
c/<module>/manual, and the generated header leaves a note with the C++ signatureeach one replaces. Today that's about 600 lines, next to ~14,000 generated.
c/*/generatedandcapiisthe review.
Then we moved the modules over one record at a time: filamat, gltfio, filament-utils, then filament's
buffers,
Camera,Scene, the managers,Material,View, and finallyEngine, after which thehand-written filament C layer was deleted.
This is also where the Kotlin API changed. With the C API generated from the headers, keeping a Kotlin
API modeled on Android's Java made no sense. The Kotlin API now follows the C++ headers: C++'s class
names, method names, owners, overloads and default values, adapted to Kotlin only where Kotlin has a
shape for it (properties for get/set pairs, nullable results,
AutoCloseable). Soengine.destroyView(v)is now
engine.destroy(v),View.BloomOptionsis now the top-levelBloomOptionsfromOptions.h, andEngine.create()returnsEngine?. The migration guide's rule of thumb covers anything it doesn't list:look the class up in the C++ header. The Kotlin name is the C++ name.
5. Measure what's left (#178, #179)
A generator tells you what it bound. It doesn't tell you what it missed, or whether anyone wraps what it
bound. Two reports answer that:
apiGapschecks from the other side. It lists Filament's public C++ methods from clang's AST(inline ones included), compiles our C objects at
-O0, and compares symbols. It never parses names anddoesn't trust the generator.
apiCoveragewrites the committedc/api-coverage.txt, one line per C function:wrapped(publicKotlin calls it),
unwrapped(bound but no wrapper yet),todo(needs hand-written C) orskipped(on purpose, with the reason). After an upgrade,
git diff c/api-coverage.txtshows what upstream addedand how far each addition got.
#179 then closed the real gaps it found: unsigned
MaterialInstanceparameters,computeAABB,FramePacer,FramePipelineEstimator,InstanceBuffer,Exposure, color spaces, and the async API(
runCommandAsync,setBufferAsync,setImageAsync, …). Today: 1,797 wrapped, 222 unwrapped (alldeliberate: values Kotlin holds itself, overloads another path covers, deprecated fields), 5 todo, 83
skipped.
By the numbers
actualdeclarations@PlatformGapannotationsFrom the 0.6.0 tag to 0.7.0: 149 commits, 1,254 files, +47.7k / −48.7k lines.
Lessons for anyone wrapping a big C++ library in KMP
1.
expect/actualis for the interop runtime, not the API. If every class has Nactuals, you'remaintaining N libraries that happen to share an interface. Push the per-platform part down until it's only
"what is a pointer, how do bytes cross, how do callbacks come back." For us that's five declarations.
2. Pick one source of truth, and make it the real one. Another platform's binding (Android Java,
embind) is someone else's curated, lossy copy of the API. The headers are the API. Once everything was
generated from them, arguments about naming and shape mostly went away.
3. Design a C ABI that every Kotlin target can call as-is. With no per-platform glue, one Kotlin
signature has to match the C ABI everywhere. Each of these rules cost us a crash to learn:
size_t,longorptrdiff_t:size_tis 64-bit on JVM, Android arm64 and iOS but 32-bit onwasm32. Use
uint32_tfor counts.Kotlin
Intpassed where C takes auint8_tshifts every later stack argument. It only crashes on iOS,and only with more than 8 arguments.
Longis an object, and a wasm i64 can't beturned back into one.
differences.
boolresults come back as0/1(andtrue == 1is false in Kotlin/JS), andfloatresults come back as doubles. A generated table tells the runtime which exports to wrap.
4. Generate, commit the output, review the diff. Committed generated code turns an upstream upgrade
into a regular code review, and a generator change into a visible diff.
5. Declare scope explicitly and make staleness fail. A skip list where each entry has a reason, and an
entry that matches nothing breaks the build, stays honest across upgrades. Inferring scope from symbol
visibility didn't.
6. Leave a hand-written escape hatch, and keep it small and visible. Some C++ will never map
mechanically (iterator ranges,
std::variant, callbacks taking C++ types). Let the generator say exactlywhere hand-written code stands in for it, and treat each one as debt.
7. Check coverage with a tool that doesn't trust the generator.
apiGapsworks from linker symbols,not from the generator's model, so it catches what the generator silently drops.
8. Migrate incrementally. Web first, then Android, then one class body, then one record at a time,
with every target's tests green after each step. Five targets × four modules is too much surface to switch
in one jump.
Trade-offs we accepted
no compatibility shims. We think one big break toward upstream is better than many small ones.
@SymbolNamedoesn't switch Kotlin/Native's thread state the way a cinterop call does, so a long Ccall (a blocking
flushAndWait) holds up a stop-the-world GC on other threads until it returns. skikomakes the same trade-off.
filament-kmp.js/.wasm), not inside the klibs, becausewebpack never sees klib resources. Web apps download them from the matching GitHub release.
c/api-coverage.txt.What it means for you
filament-composeonly: follow the setup steps for your platforms. Raw-API code insideFilamentEffect { }needs the renames.section 2 of the migration guide.
filament.js/.wasmwithfilament-kmp.js/.wasmfrom the release.windows-arm64runtime.com.google.android.filamentclasses no longerinteroperate:
nativeObjectis our C handle now..filamatfiles keep working.For contributors, The Generated C API
and Native Bindings cover
the whole pipeline. Adding API is now almost always a regenerate plus one Kotlin method.
What's next
google/filament#10410 annotates the headers and
#10426 generates the Android Java from them. We read
the same headers, so where upstream's annotations say what is API, our skip list can follow them.
1.77.2 will be the first real test.
See the roadmap.
Questions, migration trouble, or your own KMP-wrapper war stories? Reply here. If you hit a rename the
migration guide misses, or an API that behaves differently on one platform, please open an issue.
All reactions