diff --git a/thermion_dart/lib/src/filament/src/implementation/ffi_filament_app.dart b/thermion_dart/lib/src/filament/src/implementation/ffi_filament_app.dart index 4c8ace91..9cfb77c9 100644 --- a/thermion_dart/lib/src/filament/src/implementation/ffi_filament_app.dart +++ b/thermion_dart/lib/src/filament/src/implementation/ffi_filament_app.dart @@ -638,7 +638,29 @@ class FFIFilamentApp extends FilamentApp { // Future> getSwapChains() async { - return _swapChains; + // Return a snapshot, not the live list. Returning the live list lets + // mutations from concurrent createSwapChain / destroySwapChain calls + // leak back to callers — which is exactly what happened in + // thermion_flutter's Android `createTextureAndBindToView`: + // + // final swapChains = await FilamentApp.instance!.getSwapChains(); + // final swapChain = await FilamentApp.instance!.createSwapChain(...); + // if (swapChains.isNotEmpty) { + // await FilamentApp.instance!.destroySwapChain(swapChains.first); + // } + // + // The intent was "snapshot existing chains, create the new one, + // tear down the old one", but `swapChains` aliased `_swapChains`, + // so after `createSwapChain` appended, `swapChains.first` was the + // *new* swap chain. The plugin then destroyed it and attached the + // view to a freed pointer — the next render hit Filament's + // "SwapChain must remain valid until endFrame is called" assert + // and the process aborted on every viewer mount on Android. + // + // Returning an unmodifiable snapshot fixes this at the API layer + // and makes the function safe regardless of how callers are + // structured. + return List.unmodifiable(_swapChains); } final _hooks = []; diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart index f5122c2c..806fccb3 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart @@ -87,6 +87,14 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { // has been redirected to an internal RT (e.g., in composite highlight mode). static final _viewRenderTargets = {}; + // Track the SwapChain currently bound to each view (Android only). + // The Android branch of createTextureAndBindToView destroys this view's + // *previous* swap chain after creating the new one. Keying by view + // (rather than iterating FilamentApp's global swap-chain list) is what + // makes multi-viewer apps work — without this, mounting viewer #N + // would destroy viewer #(N-1)'s swap chain and freeze it. + static final _viewSwapChains = {}; + // Deferred Filament render target cleanup for Windows resize. // Old RT stays alive so native can Blit from it during the swap window. static final _deferredRenderTargets = <(RenderTarget, int)>[]; @@ -582,17 +590,27 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { // the viewport? // In fact we can probably do this for all platforms if (Platform.isAndroid) { - final swapChains = await FilamentApp.instance!.getSwapChains(); + // The OLD swap chain to destroy is the one *previously bound to + // this view* — not whatever happens to be at the front of + // FilamentApp's global list. Earlier code iterated + // `getSwapChains()` and destroyed `swapChains.first`, which in a + // multi-viewer app destroyed *another* viewer's swap chain, freezing + // its viewport. Tracking per-view in `_viewSwapChains` scopes the + // destroy to this view only. + final oldSwapChain = _viewSwapChains[view]; final swapChain = await FilamentApp.instance!.createSwapChain( Pointer.fromAddress(descriptor.windowHandle!), ); - if (swapChains.isNotEmpty) { - await FilamentApp.instance!.destroySwapChain(swapChains.first); - } - await FilamentApp.instance!.renderManager.attach(view, swapChain); + _viewSwapChains[view] = swapChain; + + // Destroy the old swap chain after the new one is attached so the + // view never has a window of being unattached. + if (oldSwapChain != null) { + await FilamentApp.instance!.destroySwapChain(oldSwapChain); + } // On other platforms, if a hardware texture ID is returned, this means // the texture is immediately available for rendering. @@ -732,4 +750,15 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { return texture; } + + @override + Future releaseTextureBindingForView(View view) async { + // Only Android keeps per-view swap-chain bookkeeping in this plugin + // (see `_viewSwapChains` and the size-change branch of + // `createTextureAndBindToView`). Other platforms are no-ops. + final swapChain = _viewSwapChains.remove(view); + if (swapChain != null) { + await FilamentApp.instance!.destroySwapChain(swapChain); + } + } } diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_web.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_web.dart index 063edd93..c0913b08 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_web.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_web.dart @@ -144,6 +144,12 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { return newTexture!; } + @override + Future releaseTextureBindingForView(View view) async { + // No per-view swap-chain bookkeeping on the web plugin. Surfaces + // are managed via the canvas + WebGL context lifecycle. + } + @override Future createTextureAndBindToView( View view, diff --git a/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart b/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart index ae6199fd..fb015ff5 100644 --- a/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart +++ b/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart @@ -57,6 +57,18 @@ abstract class ThermionFlutterPlugin { int height, ); + /// Release plugin-side resources bound to this view (e.g. the per-view + /// `SwapChain` the Android plugin tracks). Call this BEFORE destroying + /// the underlying texture / surface descriptor — otherwise on Android + /// the SurfaceTexture is released first, the swap chain's native + /// window becomes invalid, and Filament's next `eglSwapBuffers` call + /// for that swap chain returns `EGL_BAD_SURFACE` until the widget + /// finishes unmounting. + /// + /// Safe to call on platforms that don't track per-view bindings (e.g. + /// the web plugin) — it's a no-op there. + Future releaseTextureBindingForView(View view); + static Future createViewer( {bool destroySwapchain = true}) async { _logger.finest("Creating viewer"); diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_widget.dart index 2c5d8d9b..6ec09dc3 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_widget.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_widget.dart @@ -86,7 +86,20 @@ class _ThermionWidgetInternalState extends State { _debounceTimer?.cancel(); var texture = _texture; _texture = null; - texture?.destroy(); + // Tear down per-view plugin bindings (Android: SwapChain bound to + // this view) BEFORE releasing the underlying texture. The + // SurfaceTexture release frees the swap chain's native window, so + // if the swap chain is still attached to the RenderManager when + // that happens Filament's next render fires `eglSwapBuffers` on a + // null buffer and the emulator/EGL stack logs `EGL_BAD_SURFACE` + // until the widget unmount finishes propagating. Sequencing the + // two awaits inside an unawaited closure keeps `dispose()` synchronous + // for the framework while still ordering the calls. + final view = widget.view; + () async { + await ThermionFlutterPlugin.instance.releaseTextureBindingForView(view); + await texture?.destroy(); + }(); } @override