From 021859b153138d72c9fd5ed83f799c1ace33085e Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Thu, 20 Aug 2026 16:40:40 +0800 Subject: [PATCH 01/16] fix(linux): bootstrap Filament on Flutter EGL display --- .../integration_test/lifecycle_test.dart | 21 ++- .../include/opengl/linux/LinuxOpenGLContext.h | 4 +- .../src/opengl/linux/LinuxOpenGLContext.cpp | 37 +++-- ...d_channel_platform_texture_descriptor.dart | 25 +++ .../src/native_texture_surface_manager.dart | 11 ++ .../platform_texture_descriptor_registry.dart | 18 +- ...rm_texture_descriptor_registry_native.dart | 25 ++- .../thermion_flutter_plugin_initializer.dart | 157 ++++++++++++++++++ .../src/thermion_flutter_plugin_native.dart | 30 ++++ .../lib/src/thermion_flutter_plugin.dart | 17 +- .../lib/src/widgets/src/viewer_widget.dart | 20 +-- .../thermion_flutter/linux/egl_texture.cc | 14 ++ .../thermion_flutter/linux/egl_texture.h | 7 + .../linux/thermion_flutter_plugin.cc | 117 ++++++++----- ...rmion_flutter_plugin_initializer_test.dart | 114 +++++++++++++ 15 files changed, 538 insertions(+), 79 deletions(-) create mode 100644 thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart create mode 100644 thermion_flutter/thermion_flutter/test/thermion_flutter_plugin_initializer_test.dart diff --git a/examples/flutter/quickstart/integration_test/lifecycle_test.dart b/examples/flutter/quickstart/integration_test/lifecycle_test.dart index e7963af2f..db156b379 100644 --- a/examples/flutter/quickstart/integration_test/lifecycle_test.dart +++ b/examples/flutter/quickstart/integration_test/lifecycle_test.dart @@ -38,6 +38,23 @@ import 'package:thermion_flutter/src/platform/src/frame_scheduler.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + Future pumpUntilCompleted( + WidgetTester tester, + Completer completion, { + Duration timeout = const Duration(seconds: 30), + }) async { + final stopwatch = Stopwatch()..start(); + while (!completion.isCompleted && stopwatch.elapsed < timeout) { + // Linux OpenGL initialization needs a frame containing the deferred + // bootstrap Texture before Filament can import Flutter's EGL context. + await tester.pump(const Duration(milliseconds: 16)); + } + if (!completion.isCompleted) { + throw TimeoutException('Future not completed', timeout); + } + await completion.future; + } + Future pumpViewer(WidgetTester tester) async { final sun = DirectLight.sun(direction: Vector3(0.7, -1, -0.8).normalized()); await tester.pumpWidget( @@ -247,11 +264,11 @@ void main() { ), ); - await available.future.timeout(const Duration(seconds: 30)); + await pumpUntilCompleted(tester, available); await tester.pump(); await tester.pumpWidget(const SizedBox.shrink()); - await disposalStarted.future.timeout(const Duration(seconds: 30)); + await pumpUntilCompleted(tester, disposalStarted); // Viewer disposal continues with scene/view/camera destruction after // onDispose callbacks. Give that render-thread work time to drain before diff --git a/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h b/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h index f61ddde46..eb3d98d10 100644 --- a/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h +++ b/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h @@ -19,7 +19,9 @@ namespace thermion::opengl::linux_platform { */ class LinuxOpenGLContext { public: - LinuxOpenGLContext(); + // When provided, eglDisplay is borrowed from Flutter and is never + // terminated by this context. + explicit LinuxOpenGLContext(void* eglDisplay = nullptr); ~LinuxOpenGLContext(); int64_t CreateRenderingSurface(uint32_t width, uint32_t height); diff --git a/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp b/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp index 52d0fabac..6fb0d4729 100644 --- a/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp +++ b/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp @@ -35,10 +35,10 @@ class LinuxOpenGLContext::Impl { _context = EGL_NO_CONTEXT; } - if (_display != EGL_NO_DISPLAY) { + if (_ownsDisplay && _display != EGL_NO_DISPLAY) { eglTerminate(_display); - _display = EGL_NO_DISPLAY; } + _display = EGL_NO_DISPLAY; if (_gbmDevice) { gbm_device_destroy(_gbmDevice); @@ -51,7 +51,7 @@ class LinuxOpenGLContext::Impl { } } - Impl() { + explicit Impl(void* borrowedDisplay) { std::cerr << "[ThermionGL:Context] Initializing EGL/GBM..." << std::endl; // Step 1: Open DRM render node @@ -76,17 +76,24 @@ class LinuxOpenGLContext::Impl { // Using eglGetPlatformDisplay(EGL_PLATFORM_GBM_KHR, ...) ties the // display to the GPU that owns the render node. On NVIDIA systems // this selects NVIDIA's EGL instead of Mesa's software fallback. - PFNEGLGETPLATFORMDISPLAYEXTPROC eglGetPlatformDisplayEXT = - (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress("eglGetPlatformDisplayEXT"); - if (eglGetPlatformDisplayEXT) { - _display = eglGetPlatformDisplayEXT(EGL_PLATFORM_GBM_KHR, - _gbmDevice, nullptr); - } + _display = static_cast(borrowedDisplay); if (_display == EGL_NO_DISPLAY) { - // Fallback to default display - std::cerr << "[ThermionGL:Context] GBM platform display failed, " - << "trying default" << std::endl; - _display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + _ownsDisplay = true; + PFNEGLGETPLATFORMDISPLAYEXTPROC eglGetPlatformDisplayEXT = + (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress("eglGetPlatformDisplayEXT"); + if (eglGetPlatformDisplayEXT) { + _display = eglGetPlatformDisplayEXT(EGL_PLATFORM_GBM_KHR, + _gbmDevice, nullptr); + } + if (_display == EGL_NO_DISPLAY) { + // Fallback to default display + std::cerr << "[ThermionGL:Context] GBM platform display failed, " + << "trying default" << std::endl; + _display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + } + } else { + std::cerr << "[ThermionGL:Context] Reusing Flutter EGLDisplay" + << std::endl; } if (_display == EGL_NO_DISPLAY) { LOG_ERROR("Failed to get EGL display"); @@ -226,6 +233,7 @@ class LinuxOpenGLContext::Impl { private: EGLDisplay _display = EGL_NO_DISPLAY; + bool _ownsDisplay = false; EGLContext _context = EGL_NO_CONTEXT; struct gbm_device* _gbmDevice = nullptr; int _drmFd = -1; @@ -237,7 +245,8 @@ class LinuxOpenGLContext::Impl { // Public API delegates to Impl -LinuxOpenGLContext::LinuxOpenGLContext() : pImpl(std::make_unique()) {} +LinuxOpenGLContext::LinuxOpenGLContext(void* eglDisplay) + : pImpl(std::make_unique(eglDisplay)) {} LinuxOpenGLContext::~LinuxOpenGLContext() = default; diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/method_channel_platform_texture_descriptor.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/method_channel_platform_texture_descriptor.dart index 5c7cecf6f..fa37cdcc1 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/method_channel_platform_texture_descriptor.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/method_channel_platform_texture_descriptor.dart @@ -55,6 +55,31 @@ class MethodChannelPlatformTextureDescriptor extends PlatformTextureDescriptor { ); } + /// Registers a Flutter texture whose GL name is allocated by Flutter's + /// raster context during the first populate callback. + /// + /// This allocation deliberately bypasses the normal native texture creation + /// path because Filament's OpenGL context does not exist yet. + static Future + allocateContextBootstrap(MethodChannel channel, int width, int height) async { + final flutterTextureId = await channel.invokeMethod( + 'createContextBootstrap', + [width, height], + ); + if (flutterTextureId == null || flutterTextureId < 0) { + throw StateError('Failed to create Flutter context bootstrap texture'); + } + return MethodChannelPlatformTextureDescriptor( + channel, + flutterTextureId: flutterTextureId, + hardwareId: 0, + windowHandle: 0, + width: width, + height: height, + deferred: true, + ); + } + /// Waits for populate() to create the GL texture (deferred path). /// Returns the hardware texture ID once ready. @override diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/native_texture_surface_manager.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/native_texture_surface_manager.dart index 4f91bfc48..e75d9bc3b 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/native_texture_surface_manager.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/native_texture_surface_manager.dart @@ -79,6 +79,17 @@ class NativeTextureSurfaceManager { bool get hasUnavailableSurfaces => registry.hasUnavailableSurfaces; + /// Creates the pre-engine texture that Flutter populates on its raster + /// thread, allowing native code to capture the EGL context that Filament + /// must share with. + Future createContextBootstrap() { + return registry.serialized(registry.createContextBootstrap); + } + + Future destroyContextBootstrap(PlatformTextureDescriptor descriptor) { + return registry.serialized(() => registry.destroy(descriptor)); + } + Future getFilamentRenderingContext( Backend backend, ) { diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry.dart index bfc2a73ab..f8bd9b3d6 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry.dart @@ -34,8 +34,22 @@ class PlatformTextureDescriptorRegistry { bool contains(PlatformTextureDescriptor descriptor) => _descriptors.any((candidate) => identical(candidate, descriptor)); - Future create(int width, int height) async { - final descriptor = await _allocator(width, height); + Future create(int width, int height) { + return createWith(_allocator, width, height); + } + + /// Allocates and tracks a descriptor using a specialized allocator. + /// + /// Most descriptors use the registry's default allocator. Initialization + /// textures are the exception: Linux OpenGL must register a deferred Flutter + /// texture before the Filament context exists, so it cannot use the normal + /// platform-surface allocation path. + Future createWith( + PlatformTextureDescriptorAllocator allocator, + int width, + int height, + ) async { + final descriptor = await allocator(width, height); _descriptors.add(descriptor); return descriptor; } diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry_native.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry_native.dart index 6e4bdc2d0..71ba659be 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry_native.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry_native.dart @@ -11,9 +11,8 @@ import 'method_channel_platform_texture_descriptor.dart'; import 'platform_texture_descriptor.dart'; import 'platform_texture_descriptor_registry.dart'; -typedef TextureMutationRunner = Future Function( - Future Function() operation, -); +typedef TextureMutationRunner = + Future Function(Future Function() operation); class FilamentRenderingContext { const FilamentRenderingContext({ @@ -94,6 +93,26 @@ class NativePlatformTextureDescriptorRegistry throw UnsupportedError('Platform textures are not supported on $Platform'); } + /// Registers the texture used to import Flutter's actual raster EGL context + /// before Filament initializes. + Future createContextBootstrap() { + if (!Platform.isLinux) { + throw UnsupportedError( + 'Flutter context bootstrapping is only supported on Linux', + ); + } + return createWith( + (width, height) => + MethodChannelPlatformTextureDescriptor.allocateContextBootstrap( + channel, + width, + height, + ), + 1, + 1, + ); + } + Future getFilamentRenderingContext( Backend backend, ) async { diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart new file mode 100644 index 000000000..146bad260 --- /dev/null +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'platform_texture_descriptor.dart'; + +typedef ContextBootstrapAllocator = + Future Function(); +typedef ContextBootstrapDestroyer = + Future Function(PlatformTextureDescriptor descriptor); + +/// Hosts the Flutter-side prerequisites for initializing the native plugin, +/// then runs [initialize]. +/// +/// Linux OpenGL needs a real [Texture] layer before Filament can initialize. +/// Other platforms skip that handshake and invoke [initialize] immediately. +/// Keeping the layer and descriptor lifecycle here prevents viewer widgets +/// from depending on EGL or deferred texture details. +class ThermionFlutterPluginInitializer extends StatefulWidget { + const ThermionFlutterPluginInitializer({ + super.key, + required this.initialize, + required this.child, + this.createContextBootstrap, + this.destroyContextBootstrap, + }); + + final Future Function() initialize; + final Widget child; + final ContextBootstrapAllocator? createContextBootstrap; + final ContextBootstrapDestroyer? destroyContextBootstrap; + + @override + State createState() => + _ThermionFlutterPluginInitializerState(); +} + +class _ThermionFlutterPluginInitializerState + extends State { + PlatformTextureDescriptor? _descriptor; + Future? _destroyFuture; + bool _disposing = false; + + @override + void initState() { + super.initState(); + unawaited( + _bootstrap().catchError((Object error, StackTrace stack) { + if (_disposing) return; + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'thermion_flutter', + context: ErrorDescription( + 'while initializing a Thermion Flutter widget', + ), + ), + ); + }), + ); + } + + Future _bootstrap() async { + final descriptor = await widget.createContextBootstrap?.call(); + _descriptor = descriptor; + + try { + if (_disposing) return; + + if (descriptor != null) { + if (mounted) { + setState(() {}); + } + try { + descriptor.hardwareId = await descriptor.awaitTextureReady(); + } catch (_) { + // Destroying the descriptor is how dispose() cancels a pending + // native populate handshake. + if (_disposing) return; + rethrow; + } + } + + if (_disposing) return; + await widget.initialize(); + } finally { + if (identical(_descriptor, descriptor)) { + _descriptor = null; + } + if (descriptor != null && mounted && !_disposing) { + // Remove the Texture layer before unregistering its native texture. + setState(() {}); + await WidgetsBinding.instance.endOfFrame; + } + if (descriptor != null) { + await _destroy(descriptor); + } + } + } + + Future _destroy(PlatformTextureDescriptor descriptor) { + return _destroyFuture ??= + widget.destroyContextBootstrap?.call(descriptor) ?? + descriptor.destroy(); + } + + @override + void dispose() { + _disposing = true; + final descriptor = _descriptor; + if (descriptor != null) { + unawaited( + _destroy(descriptor).catchError((Object error, StackTrace stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'thermion_flutter', + context: ErrorDescription( + 'while cancelling Flutter context initialization', + ), + ), + ); + }), + ); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final descriptor = _descriptor; + if (descriptor == null) { + return widget.child; + } + + return Stack( + fit: StackFit.expand, + alignment: Alignment.topLeft, + children: [ + widget.child, + Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 1, + child: Texture( + textureId: descriptor.flutterTextureId, + filterQuality: FilterQuality.none, + freeze: false, + ), + ), + ), + ], + ); + } +} 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 d9044c714..ec140262d 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 @@ -1,12 +1,15 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' hide View; // ignore: implementation_imports import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; import 'native_rendering_lifecycle_controller.dart'; import 'native_texture_surface_manager.dart'; import 'platform_texture_descriptor.dart'; +import 'thermion_flutter_plugin_initializer.dart'; import '../../../thermion_flutter.dart'; /// Initializes the native Filament application and delegates frame lifecycle @@ -159,6 +162,33 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { _lifecycle.resumeExplicitly(); } + Future _createContextBootstrap() async { + if (!Platform.isLinux || + _resolveBackend() != Backend.OPENGL || + FilamentApp.instance != null) { + return null; + } + return _textureSurfaces.createContextBootstrap(); + } + + Future _destroyContextBootstrap(PlatformTextureDescriptor descriptor) { + return _textureSurfaces.destroyContextBootstrap(descriptor); + } + + @internal + @override + Widget buildInitializationScope({ + required Future Function() initialize, + required Widget child, + }) { + return ThermionFlutterPluginInitializer( + initialize: initialize, + child: child, + createContextBootstrap: _createContextBootstrap, + destroyContextBootstrap: _destroyContextBootstrap, + ); + } + @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 9c4753033..c79f9fdef 100644 --- a/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart +++ b/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart @@ -1,11 +1,12 @@ import 'dart:async'; - +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' hide View; import 'package:thermion_dart/thermion_dart.dart'; // ignore: implementation_imports import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; import 'package:thermion_flutter/src/options.dart'; import 'package:thermion_flutter/src/platform/src/platform_texture_descriptor.dart'; - +import 'package:thermion_flutter/src/platform/src/thermion_flutter_plugin_initializer.dart'; import 'platform/platform.dart'; import 'package:logging/logging.dart'; @@ -83,6 +84,18 @@ abstract class ThermionFlutterPlugin { FilamentApp.instance?.setTargetFramerate(fps); } + /// Hosts any Flutter-side prerequisites while [initialize] creates a viewer. + @internal + Widget buildInitializationScope({ + required Future Function() initialize, + required Widget child, + }) { + return ThermionFlutterPluginInitializer( + initialize: initialize, + child: child, + ); + } + /// Creates a rendering surface and binds to the given [View]. /// This is an internal method, don't call this yourself unless you are a /// thermion package developer. diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart index 44f169778..83a892f9e 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart @@ -90,16 +90,7 @@ class _ViewerWidgetState extends State { late final _logger = Logger(runtimeType.toString()); - @override - void initState() { - super.initState(); - _initialization = _createViewer(); - unawaited( - _initialization!.catchError((Object error, StackTrace stack) { - _reportAsyncError('initialization', error, stack); - }), - ); - } + Future _initializeViewer() => _initialization ??= _createViewer(); Future _createViewer() async { // Override options if this widget needs highlights @@ -123,6 +114,7 @@ class _ViewerWidgetState extends State { ); } + if (_disposing) return; final viewer = await ThermionFlutterPlugin.createViewer(); this.viewer = viewer; if (_disposing) { @@ -418,6 +410,12 @@ class _ViewerWidgetState extends State { @override Widget build(BuildContext context) { - return viewport != null ? SizedBox.expand(child: viewport) : widget.initial; + final child = viewport == null + ? widget.initial + : SizedBox.expand(child: viewport); + return ThermionFlutterPlugin.instance.buildInitializationScope( + initialize: _initializeViewer, + child: child, + ); } } diff --git a/thermion_flutter/thermion_flutter/linux/egl_texture.cc b/thermion_flutter/thermion_flutter/linux/egl_texture.cc index 197564d6a..72f6b0b7f 100644 --- a/thermion_flutter/thermion_flutter/linux/egl_texture.cc +++ b/thermion_flutter/thermion_flutter/linux/egl_texture.cc @@ -338,6 +338,20 @@ ThermionTextureGL* thermion_texture_gl_create( return textureGL; } +ThermionTextureGL* thermion_texture_gl_create_context_bootstrap( + uint32_t width, + uint32_t height, + FlTextureRegistrar* registrar) +{ + auto textureGL = THERMION_TEXTURE_GL( + g_object_new(thermion_texture_gl_get_type(), nullptr)); + textureGL->width = width; + textureGL->height = height; + textureGL->registrar = registrar; + textureGL->use_direct_sharing = TRUE; + return textureGL; +} + ThermionTextureGL* thermion_texture_gl_create_shared( uint32_t width, uint32_t height, GLuint gl_texture_id, diff --git a/thermion_flutter/thermion_flutter/linux/egl_texture.h b/thermion_flutter/thermion_flutter/linux/egl_texture.h index 14c285a78..e4c1180f1 100644 --- a/thermion_flutter/thermion_flutter/linux/egl_texture.h +++ b/thermion_flutter/thermion_flutter/linux/egl_texture.h @@ -69,6 +69,13 @@ FLUTTER_PLUGIN_EXPORT ThermionTextureGL* thermion_texture_gl_create( int64_t surface_id, FlTextureRegistrar* registrar); +// A 1x1 texture populated on Flutter's raster thread solely to capture the +// EGLDisplay/EGLContext before Filament initializes. +FLUTTER_PLUGIN_EXPORT ThermionTextureGL* thermion_texture_gl_create_context_bootstrap( + uint32_t width, + uint32_t height, + FlTextureRegistrar* registrar); + // EGLImage bridge path: wraps a GL texture + EGLImage. On first populate, // the EGLImage is imported into a new texture on Flutter's own GL context. FLUTTER_PLUGIN_EXPORT ThermionTextureGL* thermion_texture_gl_create_shared( diff --git a/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc b/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc index 074b3ccee..4dce2a3d3 100644 --- a/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc +++ b/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc @@ -94,13 +94,14 @@ static void destroy_all_contexts(ThermionFlutterPlugin *self) delete self->vulkan_context; self->vulkan_context = nullptr; } + if (self->utility_egl_context != EGL_NO_CONTEXT && + self->egl_display != EGL_NO_DISPLAY) + { + eglDestroyContext(self->egl_display, self->utility_egl_context); + self->utility_egl_context = EGL_NO_CONTEXT; + } if (self->use_direct_opengl) { - if (self->utility_egl_context != EGL_NO_CONTEXT) - { - eglDestroyContext(self->egl_display, self->utility_egl_context); - self->utility_egl_context = EGL_NO_CONTEXT; - } self->flutter_egl_context = EGL_NO_CONTEXT; self->egl_display = EGL_NO_DISPLAY; self->egl_config = nullptr; @@ -143,40 +144,7 @@ static void ensure_opengl_context(ThermionFlutterPlugin *self) EGLContext flutterCtx = thermion_flutter_render_context; EGLDisplay flutterDpy = thermion_flutter_render_display; - if (flutterCtx == EGL_NO_CONTEXT) { - // No deferred populate yet. Try to get GDK's GL context deterministically - // rather than relying on whatever happens to be current on this thread. - GdkDisplay *gdkDisplay = gdk_display_get_default(); - if (gdkDisplay) { - GdkGLContext *gdkCtx = gdk_gl_context_get_current(); - if (!gdkCtx) { - // Create a temporary GDK GL context and make it current so we can - // query the underlying EGL state. - GdkWindow *gdkWindow = gdk_screen_get_root_window( - gdk_display_get_default_screen(gdkDisplay)); - if (gdkWindow) { - GError *error = nullptr; - gdkCtx = gdk_window_create_gl_context(gdkWindow, &error); - if (gdkCtx && !error) { - gdk_gl_context_make_current(gdkCtx); - std::cerr << "[ThermionGL] Made GDK GL context current" << std::endl; - } else { - if (error) { - std::cerr << "[ThermionGL] GDK GL context creation failed: " - << error->message << std::endl; - g_error_free(error); - } - } - } - } - } - flutterCtx = eglGetCurrentContext(); - flutterDpy = eglGetCurrentDisplay(); - if (flutterCtx != EGL_NO_CONTEXT) { - std::cerr << "[ThermionGL] Using GDK EGL context (no deferred populate yet)" - << std::endl; - } - } else { + if (flutterCtx != EGL_NO_CONTEXT) { std::cerr << "[ThermionGL] Using Flutter render context=" << (void*)flutterCtx << std::endl; } @@ -202,7 +170,9 @@ static void ensure_opengl_context(ThermionFlutterPlugin *self) EGLint configAttribs[] = { EGL_CONFIG_ID, configId, EGL_NONE }; eglChooseConfig(flutterDpy, configAttribs, &config, 1, &numConfigs); - if (numConfigs > 0 && config != nullptr) + if (clientType == EGL_OPENGL_API && + (glMajor > 4 || (glMajor == 4 && glMinor >= 1)) && + numConfigs > 0 && config != nullptr) { // Bind the appropriate API (GL or GLES) to match Flutter eglBindAPI(clientType == EGL_OPENGL_ES_API ? EGL_OPENGL_ES_API : EGL_OPENGL_API); @@ -250,10 +220,18 @@ static void ensure_opengl_context(ThermionFlutterPlugin *self) } } - // Fallback: use the standalone LinuxOpenGLContext (GBM/DMA-BUF path) - std::cerr << "[ThermionGL] Using LinuxOpenGLContext fallback (DMA-BUF)" << std::endl; - self->opengl_context = new thermion::opengl::linux_platform::LinuxOpenGLContext(); - self->backend_type = BACKEND_OPENGL; + // Flutter normally uses GLES on Linux, while Filament requires desktop GL. + // Keep the existing DMA-BUF transport, but create Filament's desktop context + // on Flutter's already-initialized display instead of racing a second one. + if (thermion_flutter_render_display != EGL_NO_DISPLAY) + { + std::cerr << "[ThermionGL] Using Flutter EGLDisplay with DMA-BUF transport" + << std::endl; + self->opengl_context = + new thermion::opengl::linux_platform::LinuxOpenGLContext( + reinterpret_cast(thermion_flutter_render_display)); + self->backend_type = BACKEND_OPENGL; + } } static FlMethodResponse *handle_get_driver_platform(ThermionFlutterPlugin *self, FlMethodCall *method_call) @@ -267,6 +245,13 @@ static FlMethodResponse *handle_get_driver_platform(ThermionFlutterPlugin *self, if (backend == BACKEND_OPENGL) { ensure_opengl_context(self); + if (!self->use_direct_opengl && !self->opengl_context) + { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "CONTEXT_NOT_READY", + "Display the Linux OpenGL bootstrap texture before initialization", + nullptr)); + } if (self->use_direct_opengl) { platform = reinterpret_cast(self->thermion_platform); @@ -297,6 +282,13 @@ static FlMethodResponse *handle_get_shared_context(ThermionFlutterPlugin *self, if (backend == BACKEND_OPENGL) { ensure_opengl_context(self); + if (!self->use_direct_opengl && !self->opengl_context) + { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "CONTEXT_NOT_READY", + "Display the Linux OpenGL bootstrap texture before initialization", + nullptr)); + } if (self->use_direct_opengl) { // Direct sharing: return Flutter's context (Filament contexts share with it) @@ -507,6 +499,14 @@ static FlMethodResponse *handle_create_texture_opengl(ThermionFlutterPlugin *sel { ensure_opengl_context(self); + if (!self->use_direct_opengl && !self->opengl_context) + { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "CONTEXT_NOT_READY", + "Display the Linux OpenGL bootstrap texture before initialization", + nullptr)); + } + if (self->use_direct_opengl) { return handle_create_texture_opengl_direct(self, width, height); @@ -514,6 +514,31 @@ static FlMethodResponse *handle_create_texture_opengl(ThermionFlutterPlugin *sel return handle_create_texture_opengl_dmabuf(self, width, height); } +static FlMethodResponse *handle_create_context_bootstrap( + ThermionFlutterPlugin *self, FlMethodCall *method_call) +{ + FlValue *args = fl_method_call_get_args(method_call); + int width = fl_value_get_int(fl_value_get_list_value(args, 0)); + int height = fl_value_get_int(fl_value_get_list_value(args, 1)); + ThermionTextureGL *textureGL = + thermion_texture_gl_create_context_bootstrap( + static_cast(width), static_cast(height), + self->texture_registrar); + FlTexture *flTexture = FL_TEXTURE(textureGL); + if (!fl_texture_registrar_register_texture(self->texture_registrar, flTexture)) + { + g_object_unref(textureGL); + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "REGISTER_FAILED", "Failed to register context bootstrap", nullptr)); + } + self->textures->push_back(textureGL); + self->backend_type = BACKEND_OPENGL; + fl_texture_registrar_mark_texture_frame_available( + self->texture_registrar, flTexture); + return FL_METHOD_RESPONSE(fl_method_success_response_new( + fl_value_new_int(fl_texture_get_id(flTexture)))); +} + static FlMethodResponse *handle_create_texture(ThermionFlutterPlugin *self, FlMethodCall *method_call) { @@ -699,6 +724,10 @@ static void thermion_flutter_plugin_handle_method_call( { response = handle_get_driver_platform(self, method_call); } + else if (strcmp(method, "createContextBootstrap") == 0) + { + response = handle_create_context_bootstrap(self, method_call); + } else if (strcmp(method, "getSharedContext") == 0) { response = handle_get_shared_context(self, method_call); diff --git a/thermion_flutter/thermion_flutter/test/thermion_flutter_plugin_initializer_test.dart b/thermion_flutter/thermion_flutter/test/thermion_flutter_plugin_initializer_test.dart new file mode 100644 index 000000000..8299b279a --- /dev/null +++ b/thermion_flutter/thermion_flutter/test/thermion_flutter_plugin_initializer_test.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thermion_flutter/src/platform/src/platform_texture_descriptor.dart'; +import 'package:thermion_flutter/src/platform/src/thermion_flutter_plugin_initializer.dart'; + +void main() { + testWidgets( + 'initializes only after the bootstrap texture is ready and then destroys it', + (tester) async { + final descriptor = _BootstrapDescriptor(); + final events = []; + descriptor.events = events; + + await tester.pumpWidget( + ThermionFlutterPluginInitializer( + createContextBootstrap: () async { + events.add('create'); + return descriptor; + }, + destroyContextBootstrap: (descriptor) async { + events.add('destroy'); + await descriptor.destroy(); + }, + initialize: () async { + events.add('initialize'); + }, + child: const SizedBox.expand(), + ), + ); + await tester.pump(); + + expect(find.byType(Texture), findsOneWidget); + expect(events, ['create', 'await']); + + descriptor.completeReady(42); + await tester.pump(); + await tester.pump(); + + expect(descriptor.hardwareId, 42); + expect(find.byType(Texture), findsNothing); + expect(events, ['create', 'await', 'initialize', 'destroy']); + expect(descriptor.destroyCount, 1); + }, + ); + + testWidgets('disposing cancels a pending bootstrap exactly once', ( + tester, + ) async { + final descriptor = _BootstrapDescriptor(); + var initializeCount = 0; + + await tester.pumpWidget( + ThermionFlutterPluginInitializer( + createContextBootstrap: () async => descriptor, + initialize: () async { + initializeCount++; + }, + child: const SizedBox.expand(), + ), + ); + await tester.pump(); + expect(find.byType(Texture), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + expect(descriptor.destroyCount, 1); + expect(initializeCount, 0); + expect(tester.takeException(), isNull); + }); +} + +class _BootstrapDescriptor extends PlatformTextureDescriptor { + _BootstrapDescriptor() + : super(flutterTextureId: 7, hardwareId: 0, width: 1, height: 1); + + final _ready = Completer(); + int destroyCount = 0; + bool _destroyed = false; + List? events; + + @override + bool get deferred => true; + + @override + bool get destroyed => _destroyed; + + @override + Future awaitTextureReady() { + events?.add('await'); + return _ready.future; + } + + void completeReady(int textureId) { + if (!_ready.isCompleted) { + _ready.complete(textureId); + } + } + + @override + Future destroy() async { + if (_destroyed) return; + _destroyed = true; + destroyCount++; + if (!_ready.isCompleted) { + _ready.completeError(StateError('destroyed')); + } + } + + @override + void markTextureFrameAvailable() {} +} From a2cac9db7dfb48ceacc470500e3846b76d7207d4 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Thu, 20 Aug 2026 16:43:01 +0800 Subject: [PATCH 02/16] fix(linux): isolate desktop EGL operations --- .../include/opengl/linux/LinuxOpenGLContext.h | 14 +- .../include/opengl/linux/LinuxOpenGLTexture.h | 4 +- .../src/opengl/linux/LinuxOpenGLContext.cpp | 387 +++++++++--- .../src/opengl/linux/LinuxOpenGLTexture.cpp | 89 ++- .../thermion_flutter/linux/egl_texture.cc | 128 ++-- .../thermion_flutter/linux/egl_texture.h | 20 +- .../linux/thermion_flutter_plugin.cc | 593 ++++++++++++++---- 7 files changed, 934 insertions(+), 301 deletions(-) diff --git a/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h b/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h index eb3d98d10..7fc0c7171 100644 --- a/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h +++ b/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h @@ -15,15 +15,21 @@ namespace thermion::opengl::linux_platform { * PlatformEGL can create its own context in the same share group — GL texture * IDs are then valid in both contexts. * - * GetPlatform() returns nullptr so Filament auto-creates a default PlatformEGL. + * GetPlatform() returns Thermion's EGLHeadless platform bound to the same + * EGLDisplay as the producer context. */ class LinuxOpenGLContext { public: - // When provided, eglDisplay is borrowed from Flutter and is never - // terminated by this context. + // If eglDisplay is non-null, it is borrowed and must already be + // initialized. This is the preferred Flutter path: Filament gets a + // desktop-GL context on Flutter's existing EGLDisplay without starting a + // second NVIDIA EGL display alongside the raster thread. explicit LinuxOpenGLContext(void* eglDisplay = nullptr); ~LinuxOpenGLContext(); + bool IsValid() const; + const char* GetLastError() const; + int64_t CreateRenderingSurface(uint32_t width, uint32_t height); void DestroyRenderingSurface(int64_t surfaceId); @@ -31,7 +37,7 @@ class LinuxOpenGLContext { SurfaceExportInfo GetSurfaceExportInfo(int64_t surfaceId); void* GetSharedContext(); // Returns our EGLContext for Filament sharing - void* GetPlatform(); // Returns nullptr (Filament creates default PlatformEGL) + void* GetPlatform(); // Returns ThermionPlatformEGLHeadless private: class Impl; diff --git a/thermion_dart/native/include/opengl/linux/LinuxOpenGLTexture.h b/thermion_dart/native/include/opengl/linux/LinuxOpenGLTexture.h index f54ba0a6f..eca685127 100644 --- a/thermion_dart/native/include/opengl/linux/LinuxOpenGLTexture.h +++ b/thermion_dart/native/include/opengl/linux/LinuxOpenGLTexture.h @@ -30,7 +30,7 @@ class LinuxOpenGLTexture { ~LinuxOpenGLTexture(); static std::unique_ptr create( - EGLDisplay display, EGLContext context, + EGLDisplay display, EGLContext context, EGLSurface surface, struct gbm_device* gbm, uint32_t width, uint32_t height); GLuint GetGLTextureId() const { return _glTextureId; } @@ -58,6 +58,8 @@ class LinuxOpenGLTexture { // Store display for cleanup EGLDisplay _display = EGL_NO_DISPLAY; + EGLContext _context = EGL_NO_CONTEXT; + EGLSurface _surface = EGL_NO_SURFACE; }; } // namespace thermion::opengl::linux_platform diff --git a/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp b/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp index 6fb0d4729..718df0802 100644 --- a/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp +++ b/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp @@ -1,9 +1,17 @@ #include "opengl/linux/LinuxOpenGLContext.h" #include "opengl/linux/LinuxOpenGLTexture.h" +#include +#include +#include +#include #include -#include #include +#include +#include +#include +#include +#include #include #include @@ -19,26 +27,82 @@ namespace thermion::opengl::linux_platform { +class ScopedEglThreadState { +public: + ScopedEglThreadState() + : _display(eglGetCurrentDisplay()), + _context(eglGetCurrentContext()), + _draw(eglGetCurrentSurface(EGL_DRAW)), + _read(eglGetCurrentSurface(EGL_READ)), + _api(eglQueryAPI()) { + if (_context != EGL_NO_CONTEXT && _display != EGL_NO_DISPLAY) { + eglMakeCurrent( + _display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + } + + ~ScopedEglThreadState() { + EGLDisplay currentDisplay = eglGetCurrentDisplay(); + if (currentDisplay != EGL_NO_DISPLAY) { + eglMakeCurrent(currentDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, + EGL_NO_CONTEXT); + } + eglBindAPI(_api); + if (_context != EGL_NO_CONTEXT && _display != EGL_NO_DISPLAY) { + eglMakeCurrent(_display, _draw, _read, _context); + } + } + + ScopedEglThreadState(const ScopedEglThreadState&) = delete; + ScopedEglThreadState& operator=(const ScopedEglThreadState&) = delete; + +private: + EGLDisplay _display; + EGLContext _context; + EGLSurface _draw; + EGLSurface _read; + EGLenum _api; +}; + class LinuxOpenGLContext::Impl { public: ~Impl() { - _surfaces.clear(); + // The desktop producer context never becomes current on Flutter's + // platform or raster threads. Keep destruction on an isolated EGL + // thread for the same reason as initialization and texture creation. + if (_eglThread.joinable()) { + RunOnEglThread([this]() { + _surfaces.clear(); + }); + } if (_platform) { ThermionPlatformEGLHeadless_Destroy(_platform); _platform = nullptr; } - if (_context != EGL_NO_CONTEXT && _display != EGL_NO_DISPLAY) { - eglMakeCurrent(_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); - eglDestroyContext(_display, _context); - _context = EGL_NO_CONTEXT; - } - - if (_ownsDisplay && _display != EGL_NO_DISPLAY) { - eglTerminate(_display); + if (_eglThread.joinable()) { + RunOnEglThread([this]() { + if (_context != EGL_NO_CONTEXT && + _display != EGL_NO_DISPLAY) { + eglBindAPI(EGL_OPENGL_API); + eglMakeCurrent( + _display, EGL_NO_SURFACE, EGL_NO_SURFACE, + EGL_NO_CONTEXT); + if (_producerSurface != EGL_NO_SURFACE) { + eglDestroySurface(_display, _producerSurface); + _producerSurface = EGL_NO_SURFACE; + } + eglDestroyContext(_display, _context); + _context = EGL_NO_CONTEXT; + } + if (_ownsDisplay && _display != EGL_NO_DISPLAY) { + eglTerminate(_display); + } + _display = EGL_NO_DISPLAY; + }); + StopEglThread(); } - _display = EGL_NO_DISPLAY; if (_gbmDevice) { gbm_device_destroy(_gbmDevice); @@ -57,6 +121,7 @@ class LinuxOpenGLContext::Impl { // Step 1: Open DRM render node _drmFd = open("/dev/dri/renderD128", O_RDWR); if (_drmFd < 0) { + _lastError = "Failed to open /dev/dri/renderD128"; LOG_ERROR("Failed to open /dev/dri/renderD128"); return; } @@ -65,6 +130,7 @@ class LinuxOpenGLContext::Impl { // Step 2: Create GBM device _gbmDevice = gbm_create_device(_drmFd); if (!_gbmDevice) { + _lastError = "Failed to create GBM device"; LOG_ERROR("Failed to create GBM device"); close(_drmFd); _drmFd = -1; @@ -72,36 +138,53 @@ class LinuxOpenGLContext::Impl { } std::cerr << "[ThermionGL:Context] GBM device created OK" << std::endl; - // Step 3: Get EGL display from the GBM device. - // Using eglGetPlatformDisplay(EGL_PLATFORM_GBM_KHR, ...) ties the - // display to the GPU that owns the render node. On NVIDIA systems - // this selects NVIDIA's EGL instead of Mesa's software fallback. + // Step 3: Reuse Flutter's initialized display when available. NVIDIA's + // EGL implementation can corrupt the concurrently rendering Flutter + // context if another platform display is initialized in-process. _display = static_cast(borrowedDisplay); - if (_display == EGL_NO_DISPLAY) { - _ownsDisplay = true; + if (_display != EGL_NO_DISPLAY) { + _ownsDisplay = false; + // eglInitialize is idempotent for an initialized EGLDisplay. Do + // this only after populate() has captured Flutter's real display: + // on NVIDIA it also makes the desktop-GL client API usable on the + // display that Flutter initialized for GLES. + EGLint major = 0; + EGLint minor = 0; + if (!eglInitialize(_display, &major, &minor)) { + _lastError = + "Failed to initialize Flutter's captured EGLDisplay"; + LOG_ERROR("Failed to initialize Flutter EGL display"); + _display = EGL_NO_DISPLAY; + return; + } + std::cerr << "[ThermionGL:Context] Using Flutter EGL display=" + << _display << " (" << major << "." << minor << ")" + << std::endl; + } else { + // Non-Flutter fallback: obtain a display tied to the GBM device. PFNEGLGETPLATFORMDISPLAYEXTPROC eglGetPlatformDisplayEXT = - (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress("eglGetPlatformDisplayEXT"); + (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress( + "eglGetPlatformDisplayEXT"); if (eglGetPlatformDisplayEXT) { - _display = eglGetPlatformDisplayEXT(EGL_PLATFORM_GBM_KHR, - _gbmDevice, nullptr); + _display = eglGetPlatformDisplayEXT( + EGL_PLATFORM_GBM_KHR, _gbmDevice, nullptr); } if (_display == EGL_NO_DISPLAY) { - // Fallback to default display - std::cerr << "[ThermionGL:Context] GBM platform display failed, " - << "trying default" << std::endl; + std::cerr + << "[ThermionGL:Context] GBM platform display failed, " + "trying default" + << std::endl; _display = eglGetDisplay(EGL_DEFAULT_DISPLAY); } - } else { - std::cerr << "[ThermionGL:Context] Reusing Flutter EGLDisplay" - << std::endl; - } - if (_display == EGL_NO_DISPLAY) { - LOG_ERROR("Failed to get EGL display"); - return; - } - { + if (_display == EGL_NO_DISPLAY) { + _lastError = "Failed to obtain an EGLDisplay"; + LOG_ERROR("Failed to get EGL display"); + return; + } + _ownsDisplay = true; EGLint major, minor; if (!eglInitialize(_display, &major, &minor)) { + _lastError = "Failed to initialize EGLDisplay"; LOG_ERROR("Failed to initialize EGL display"); _display = EGL_NO_DISPLAY; return; @@ -110,75 +193,129 @@ class LinuxOpenGLContext::Impl { << major << "." << minor << std::endl; } - // Step 4: Choose EGL config - // Must bind EGL_OPENGL_API (not ES) to match Filament's PlatformEGLHeadless - // which uses full OpenGL 4.1 on Linux desktop. - eglBindAPI(EGL_OPENGL_API); - - EGLint configAttribs[] = { - EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, - EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, - EGL_RED_SIZE, 8, - EGL_GREEN_SIZE, 8, - EGL_BLUE_SIZE, 8, - EGL_ALPHA_SIZE, 8, - EGL_NONE - }; - - EGLConfig config; - EGLint numConfigs; - if (!eglChooseConfig(_display, configAttribs, &config, 1, &numConfigs) || numConfigs == 0) { - LOG_ERROR("Failed to choose EGL config"); - return; - } + // Initialize desktop EGL on an isolated thread. Flutter's platform + // thread can retain GTK/GDK EGL state, and NVIDIA returns + // EGL_BAD_ACCESS when a second client API is activated there. + StartEglThread(); + RunOnEglThread([this]() { + // Step 4: Choose EGL config + // Must bind EGL_OPENGL_API (not ES) to match Filament's + // PlatformEGLHeadless which uses full OpenGL 4.1 on Linux desktop. + ScopedEglThreadState eglThreadState; + if (!eglBindAPI(EGL_OPENGL_API)) { + _lastError = "Failed to bind desktop OpenGL"; + LOG_ERROR("Failed to bind desktop OpenGL"); + return; + } - // Step 5: Create EGL context (OpenGL 4.1 to match Filament's PlatformEGLHeadless) - EGLint contextAttribs[] = { - EGL_CONTEXT_MAJOR_VERSION, 4, - EGL_CONTEXT_MINOR_VERSION, 1, - EGL_NONE - }; + EGLint configAttribs[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_NONE + }; - _context = eglCreateContext(_display, config, EGL_NO_CONTEXT, contextAttribs); - if (_context == EGL_NO_CONTEXT) { - EGLint err = eglGetError(); - std::cerr << "[ThermionGL:Context] Failed to create EGL context, error: 0x" - << std::hex << err << std::dec << std::endl; - return; - } + EGLConfig config; + EGLint numConfigs; + if (!eglChooseConfig( + _display, configAttribs, &config, 1, &numConfigs) || + numConfigs == 0) { + _lastError = "Failed to choose a desktop OpenGL EGLConfig"; + LOG_ERROR("Failed to choose EGL config"); + return; + } - // Verify the context works by briefly making it current, then restore - // whatever context was active (likely Flutter/GDK's). - EGLDisplay prevDisplay = eglGetCurrentDisplay(); - EGLContext prevContext = eglGetCurrentContext(); - EGLSurface prevDraw = eglGetCurrentSurface(EGL_DRAW); - EGLSurface prevRead = eglGetCurrentSurface(EGL_READ); - - if (!eglMakeCurrent(_display, EGL_NO_SURFACE, EGL_NO_SURFACE, _context)) { - // Some drivers require a pbuffer surface - EGLint pbufferAttribs[] = { - EGL_WIDTH, 1, - EGL_HEIGHT, 1, + // Step 5: Create EGL context (OpenGL 4.1 to match Filament's + // PlatformEGLHeadless). + EGLint contextAttribs[] = { + EGL_CONTEXT_MAJOR_VERSION, 4, + EGL_CONTEXT_MINOR_VERSION, 1, EGL_NONE }; - EGLSurface pbuffer = eglCreatePbufferSurface(_display, config, pbufferAttribs); - if (pbuffer != EGL_NO_SURFACE) { - eglMakeCurrent(_display, pbuffer, pbuffer, _context); + + _context = eglCreateContext( + _display, config, EGL_NO_CONTEXT, contextAttribs); + if (_context == EGL_NO_CONTEXT) { + _lastError = "Failed to create desktop OpenGL 4.1 EGLContext"; + EGLint err = eglGetError(); + std::cerr + << "[ThermionGL:Context] Failed to create EGL context, " + "error: 0x" + << std::hex << err << std::dec << std::endl; + return; + } + + EGLint surfacelessError = EGL_SUCCESS; + if (!eglMakeCurrent( + _display, EGL_NO_SURFACE, EGL_NO_SURFACE, _context)) { + surfacelessError = eglGetError(); + // Some drivers require a pbuffer surface. + EGLint pbufferAttribs[] = { + EGL_WIDTH, 1, + EGL_HEIGHT, 1, + EGL_NONE + }; + _producerSurface = + eglCreatePbufferSurface(_display, config, pbufferAttribs); + if (_producerSurface == EGL_NO_SURFACE) { + EGLint pbufferError = eglGetError(); + std::ostringstream message; + message + << "Failed to create desktop OpenGL producer pbuffer " + << "(surfaceless error 0x" << std::hex + << surfacelessError << ", pbuffer error 0x" + << pbufferError << ")"; + _lastError = message.str(); + LOG_ERROR("Failed to create EGL producer pbuffer"); + eglDestroyContext(_display, _context); + _context = EGL_NO_CONTEXT; + return; + } + if (!eglMakeCurrent( + _display, _producerSurface, _producerSurface, + _context)) { + EGLint pbufferCurrentError = eglGetError(); + std::ostringstream message; + message + << "Failed to make desktop OpenGL producer context " + "current (surfaceless error 0x" + << std::hex << surfacelessError + << ", pbuffer error 0x" << pbufferCurrentError << ")"; + _lastError = message.str(); + LOG_ERROR("Failed to make EGL context current"); + eglDestroySurface(_display, _producerSurface); + _producerSurface = EGL_NO_SURFACE; + eglDestroyContext(_display, _context); + _context = EGL_NO_CONTEXT; + return; + } } - } - std::cerr << "[ThermionGL:Context] EGL context created OK" << std::endl; + std::cerr << "[ThermionGL:Context] EGL context created OK" + << std::endl; + }); + } - // Restore the previous context so we don't clobber Flutter/GDK's state - if (prevContext != EGL_NO_CONTEXT && prevDisplay != EGL_NO_DISPLAY) { - eglMakeCurrent(prevDisplay, prevDraw, prevRead, prevContext); - } else { - eglMakeCurrent(_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); - } + bool IsValid() const { + return _display != EGL_NO_DISPLAY && + _context != EGL_NO_CONTEXT && + _gbmDevice != nullptr; + } + + const char* GetLastError() const { + return _lastError.c_str(); } int64_t CreateRenderingSurface(uint32_t width, uint32_t height) { - auto texture = LinuxOpenGLTexture::create(_display, _context, _gbmDevice, width, height); + std::unique_ptr texture; + RunOnEglThread([&]() { + texture = LinuxOpenGLTexture::create( + _display, _context, _producerSurface, _gbmDevice, width, + height); + }); if (!texture) { LOG_ERROR("Failed to create OpenGL rendering surface"); return -1; @@ -195,7 +332,9 @@ class LinuxOpenGLContext::Impl { } void DestroyRenderingSurface(int64_t surfaceId) { - _surfaces.erase(surfaceId); + RunOnEglThread([this, surfaceId]() { + _surfaces.erase(surfaceId); + }); } uint32_t GetGLTextureId(int64_t surfaceId) { @@ -232,12 +371,68 @@ class LinuxOpenGLContext::Impl { } private: + void StartEglThread() { + _eglThread = std::thread([this]() { + while (true) { + std::function task; + { + std::unique_lock lock(_taskMutex); + _taskReady.wait(lock, [this]() { + return _stopEglThread || !_tasks.empty(); + }); + if (_stopEglThread && _tasks.empty()) { + break; + } + task = std::move(_tasks.front()); + _tasks.pop_front(); + } + task(); + } + eglReleaseThread(); + }); + } + + void RunOnEglThread(std::function task) { + auto completed = std::make_shared>(); + auto result = completed->get_future(); + { + std::lock_guard lock(_taskMutex); + _tasks.emplace_back( + [task = std::move(task), completed = std::move(completed)]() { + try { + task(); + completed->set_value(); + } catch (...) { + completed->set_exception(std::current_exception()); + } + }); + } + _taskReady.notify_one(); + result.get(); + } + + void StopEglThread() { + { + std::lock_guard lock(_taskMutex); + _stopEglThread = true; + } + _taskReady.notify_one(); + _eglThread.join(); + } + EGLDisplay _display = EGL_NO_DISPLAY; - bool _ownsDisplay = false; EGLContext _context = EGL_NO_CONTEXT; + EGLSurface _producerSurface = EGL_NO_SURFACE; + bool _ownsDisplay = false; + std::string _lastError; struct gbm_device* _gbmDevice = nullptr; int _drmFd = -1; ThermionPlatformEGLHeadlessHandle _platform = nullptr; + std::thread _eglThread; + std::mutex _taskMutex; + std::condition_variable _taskReady; + std::deque> _tasks; + bool _stopEglThread = false; std::unordered_map> _surfaces; int64_t _nextSurfaceId = 1; @@ -250,6 +445,14 @@ LinuxOpenGLContext::LinuxOpenGLContext(void* eglDisplay) LinuxOpenGLContext::~LinuxOpenGLContext() = default; +bool LinuxOpenGLContext::IsValid() const { + return pImpl->IsValid(); +} + +const char* LinuxOpenGLContext::GetLastError() const { + return pImpl->GetLastError(); +} + int64_t LinuxOpenGLContext::CreateRenderingSurface(uint32_t width, uint32_t height) { return pImpl->CreateRenderingSurface(width, height); } diff --git a/thermion_dart/native/src/opengl/linux/LinuxOpenGLTexture.cpp b/thermion_dart/native/src/opengl/linux/LinuxOpenGLTexture.cpp index ee27d9560..d629db0d6 100644 --- a/thermion_dart/native/src/opengl/linux/LinuxOpenGLTexture.cpp +++ b/thermion_dart/native/src/opengl/linux/LinuxOpenGLTexture.cpp @@ -26,9 +26,68 @@ static void ensureExtensionFunctions() { namespace thermion::opengl::linux_platform { +class ScopedEglContext { +public: + ScopedEglContext( + EGLDisplay display, EGLContext context, EGLSurface surface) + : _targetDisplay(display), + _targetSurface(surface), + _previousDisplay(eglGetCurrentDisplay()), + _previousContext(eglGetCurrentContext()), + _previousDraw(eglGetCurrentSurface(EGL_DRAW)), + _previousRead(eglGetCurrentSurface(EGL_READ)), + _previousApi(eglQueryAPI()) { + if (_previousContext != EGL_NO_CONTEXT && + _previousDisplay != EGL_NO_DISPLAY) { + eglMakeCurrent(_previousDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, + EGL_NO_CONTEXT); + } + eglBindAPI(EGL_OPENGL_API); + _current = eglMakeCurrent( + _targetDisplay, _targetSurface, _targetSurface, context); + } + + ~ScopedEglContext() { + if (_targetDisplay != EGL_NO_DISPLAY) { + eglMakeCurrent(_targetDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, + EGL_NO_CONTEXT); + } + eglBindAPI(_previousApi); + if (_previousContext != EGL_NO_CONTEXT && + _previousDisplay != EGL_NO_DISPLAY) { + eglMakeCurrent(_previousDisplay, _previousDraw, _previousRead, + _previousContext); + } + } + + bool current() const { return _current == EGL_TRUE; } + + ScopedEglContext(const ScopedEglContext&) = delete; + ScopedEglContext& operator=(const ScopedEglContext&) = delete; + +private: + EGLDisplay _targetDisplay; + EGLSurface _targetSurface; + EGLDisplay _previousDisplay; + EGLContext _previousContext; + EGLSurface _previousDraw; + EGLSurface _previousRead; + EGLenum _previousApi; + EGLBoolean _current = EGL_FALSE; +}; + LinuxOpenGLTexture::~LinuxOpenGLTexture() { if (_glTextureId != 0) { - glDeleteTextures(1, &_glTextureId); + ScopedEglContext context(_display, _context, _surface); + if (context.current()) { + glDeleteTextures(1, &_glTextureId); + } else { + std::cerr + << "[ThermionGL:Texture] Failed to make owner context current " + "while deleting texture " + << _glTextureId << ", EGL error: 0x" << std::hex + << eglGetError() << std::dec << std::endl; + } _glTextureId = 0; } @@ -49,7 +108,7 @@ LinuxOpenGLTexture::~LinuxOpenGLTexture() { } std::unique_ptr LinuxOpenGLTexture::create( - EGLDisplay display, EGLContext context, + EGLDisplay display, EGLContext context, EGLSurface surface, struct gbm_device* gbm, uint32_t width, uint32_t height) { ensureExtensionFunctions(); @@ -128,13 +187,16 @@ std::unique_ptr LinuxOpenGLTexture::create( return nullptr; } - // Step 4: Make our context current so we can create GL objects - EGLSurface prevDrawSurface = eglGetCurrentSurface(EGL_DRAW); - EGLSurface prevReadSurface = eglGetCurrentSurface(EGL_READ); - EGLContext prevContext = eglGetCurrentContext(); - - if (eglGetCurrentContext() != context) { - eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context); + // Step 4: Make our context current so we can create GL objects. + ScopedEglContext scopedContext(display, context, surface); + if (!scopedContext.current()) { + std::cerr << "[ThermionGL:Texture] Failed to make owner context " + "current, EGL error: 0x" + << std::hex << eglGetError() << std::dec << std::endl; + s_eglDestroyImageKHR(display, eglImage); + close(dmaBufFd); + gbm_bo_destroy(bo); + return nullptr; } // Step 5: Create GL texture and bind EGLImage to it @@ -159,18 +221,11 @@ std::unique_ptr LinuxOpenGLTexture::create( s_eglDestroyImageKHR(display, eglImage); close(dmaBufFd); gbm_bo_destroy(bo); - // Restore previous context - eglMakeCurrent(display, prevDrawSurface, prevReadSurface, prevContext); return nullptr; } glBindTexture(GL_TEXTURE_2D, 0); - // Restore previous context - if (prevContext != context) { - eglMakeCurrent(display, prevDrawSurface, prevReadSurface, prevContext); - } - std::cerr << "[ThermionGL:Texture] GL texture created: id=" << glTextureId << " " << width << "x" << height << std::endl; @@ -187,6 +242,8 @@ std::unique_ptr LinuxOpenGLTexture::create( texture->_drmFormat = drmFormat; texture->_drmModifier = drmModifier; texture->_display = display; + texture->_context = context; + texture->_surface = surface; return texture; } diff --git a/thermion_flutter/thermion_flutter/linux/egl_texture.cc b/thermion_flutter/thermion_flutter/linux/egl_texture.cc index 72f6b0b7f..c0c5db7a6 100644 --- a/thermion_flutter/thermion_flutter/linux/egl_texture.cc +++ b/thermion_flutter/thermion_flutter/linux/egl_texture.cc @@ -19,6 +19,22 @@ static PFNEGLCREATEIMAGEKHRPROC s_eglCreateImageKHR = nullptr; static PFNEGLDESTROYIMAGEKHRPROC s_eglDestroyImageKHR = nullptr; static PFNGLEGLIMAGETARGETTEXTURE2DOESPROC s_glEGLImageTargetTexture2DOES = nullptr; +struct DeferredReadyResponse { + FlMethodCall* method_call; + int64_t texture_id; +}; + +static gboolean respond_texture_ready(gpointer user_data) { + auto* response = static_cast(user_data); + g_autoptr(FlValue) result = fl_value_new_int(response->texture_id); + fl_method_call_respond( + response->method_call, + FL_METHOD_RESPONSE(fl_method_success_response_new(result)), nullptr); + g_object_unref(response->method_call); + delete response; + return G_SOURCE_REMOVE; +} + static void ensure_egl_procs() { if (!s_eglCreateImageKHR) { s_eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR"); @@ -69,49 +85,69 @@ thermion_texture_populate(FlTextureGL *texture, // Flutter's render context (which is guaranteed current during populate). if (self->use_direct_sharing) { if (self->gl_texture_id == 0) { - // First populate — create GL texture on Flutter's render context - // Fill with solid green for visual verification - size_t pixelCount = self->width * self->height; - std::vector greenPixels(pixelCount * 4); - for (size_t i = 0; i < pixelCount; i++) { - greenPixels[i * 4 + 0] = 0; // R - greenPixels[i * 4 + 1] = 255; // G - greenPixels[i * 4 + 2] = 0; // B - greenPixels[i * 4 + 3] = 255; // A + EGLContext flutterContext = eglGetCurrentContext(); + EGLDisplay flutterDisplay = eglGetCurrentDisplay(); + if (flutterContext == EGL_NO_CONTEXT || + flutterDisplay == EGL_NO_DISPLAY) { + g_set_error(error, g_quark_from_static_string("thermion"), 1, + "Flutter did not make an EGL context current"); + return FALSE; } + + // First populate — create a transparent GL texture on Flutter's + // raster context. Its purpose is to import the actual context, not + // to display application content. + std::vector pixels(self->width * self->height * 4, 0); + while (glGetError() != GL_NO_ERROR) {} glGenTextures(1, &self->gl_texture_id); glBindTexture(GL_TEXTURE_2D, self->gl_texture_id); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, self->width, self->height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, greenPixels.data()); + GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glBindTexture(GL_TEXTURE_2D, 0); + GLenum glError = glGetError(); + if (self->gl_texture_id == 0 || glError != GL_NO_ERROR) { + if (self->gl_texture_id != 0) { + glDeleteTextures(1, &self->gl_texture_id); + self->gl_texture_id = 0; + } + g_set_error( + error, g_quark_from_static_string("thermion"), 2, + "Failed to create Flutter context bootstrap texture " + "(GL error 0x%x)", + glError); + return FALSE; + } self->surface_id = static_cast(self->gl_texture_id); - TRACE( "[DirectPop] Created deferred GL texture %u (%ux%u) on ctx=%p\n", + TRACE( "[DirectPop] Created bootstrap GL texture %u (%ux%u) on ctx=%p\n", self->gl_texture_id, self->width, self->height, - (void*)eglGetCurrentContext()); + (void*)flutterContext); // Capture Flutter's render context for Filament initialization. // This is the ONLY place where Flutter's render context is current. - if (thermion_flutter_render_context == EGL_NO_CONTEXT) { - thermion_flutter_render_context = eglGetCurrentContext(); - thermion_flutter_render_display = eglGetCurrentDisplay(); - TRACE( "[DirectPop] Captured Flutter render context=%p display=%p\n", - (void*)thermion_flutter_render_context, - (void*)thermion_flutter_render_display); - } - - // Resolve pending awaitTextureReady method call + thermion_flutter_render_context = flutterContext; + thermion_flutter_render_display = flutterDisplay; + TRACE( "[DirectPop] Captured Flutter render context=%p display=%p\n", + (void*)thermion_flutter_render_context, + (void*)thermion_flutter_render_display); + + // Do not resolve awaitTextureReady from inside populate(). Dart + // may immediately initialize another EGL client API when the + // Future completes. Queue the response on Flutter's platform loop + // so this raster callback has fully returned first. if (self->pending_ready_call) { - g_autoptr(FlValue) result = fl_value_new_int( - static_cast(self->gl_texture_id)); - fl_method_call_respond(self->pending_ready_call, - FL_METHOD_RESPONSE(fl_method_success_response_new(result)), nullptr); - g_object_unref(self->pending_ready_call); + auto* response = new DeferredReadyResponse{ + self->pending_ready_call, + static_cast(self->gl_texture_id), + }; self->pending_ready_call = nullptr; + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, respond_texture_ready, response, + nullptr); } } @@ -122,10 +158,9 @@ thermion_texture_populate(FlTextureGL *texture, return TRUE; } - // EGLImage bridge path: the source texture lives on Filament's context, - // but Flutter's rendering context is in a different share group. - // Bridge via EGLImage: on first populate, import the EGLImage into a new - // GL texture on Flutter's current context. + // EGLImage bridge path: the source texture lives on Filament's desktop + // context. On first populate, bind its EGLImage to a Flutter-owned texture + // name on Flutter's current context. if (self->use_egl_image) { if (!self->initialized) { ensure_egl_procs(); @@ -316,6 +351,7 @@ void thermion_texture_gl_init(ThermionTextureGL* self) { self->surface_id = -1; self->use_egl_image = FALSE; self->use_direct_sharing = FALSE; + self->is_context_bootstrap = FALSE; self->pending_ready_call = nullptr; } @@ -338,20 +374,6 @@ ThermionTextureGL* thermion_texture_gl_create( return textureGL; } -ThermionTextureGL* thermion_texture_gl_create_context_bootstrap( - uint32_t width, - uint32_t height, - FlTextureRegistrar* registrar) -{ - auto textureGL = THERMION_TEXTURE_GL( - g_object_new(thermion_texture_gl_get_type(), nullptr)); - textureGL->width = width; - textureGL->height = height; - textureGL->registrar = registrar; - textureGL->use_direct_sharing = TRUE; - return textureGL; -} - ThermionTextureGL* thermion_texture_gl_create_shared( uint32_t width, uint32_t height, GLuint gl_texture_id, @@ -372,6 +394,24 @@ ThermionTextureGL* thermion_texture_gl_create_shared( return textureGL; } +ThermionTextureGL* thermion_texture_gl_create_context_bootstrap( + uint32_t width, uint32_t height, + FlTextureRegistrar* registrar) +{ + auto textureGL = THERMION_TEXTURE_GL( + g_object_new(thermion_texture_gl_get_type(), nullptr)); + textureGL->width = width; + textureGL->height = height; + textureGL->registrar = registrar; + textureGL->use_direct_sharing = TRUE; + textureGL->is_context_bootstrap = TRUE; + // populate() creates gl_texture_id while Flutter's raster context is + // current and resolves the pending awaitTextureReady call. + textureGL->gl_texture_id = 0; + + return textureGL; +} + void thermion_texture_gl_destroy(ThermionTextureGL* texture) { if (texture && texture->registrar) { fl_texture_registrar_unregister_texture(texture->registrar, FL_TEXTURE(texture)); diff --git a/thermion_flutter/thermion_flutter/linux/egl_texture.h b/thermion_flutter/thermion_flutter/linux/egl_texture.h index e4c1180f1..b9a282121 100644 --- a/thermion_flutter/thermion_flutter/linux/egl_texture.h +++ b/thermion_flutter/thermion_flutter/linux/egl_texture.h @@ -47,6 +47,8 @@ struct _ThermionTextureGL { GLuint flutter_gl_texture_id; // Direct sharing path: same EGL share group as Flutter, no EGLImage needed gboolean use_direct_sharing; + // Pre-engine texture used only to capture Flutter's raster EGL context. + gboolean is_context_bootstrap; // Deferred "awaitTextureReady" response (stored until populate creates the GL texture) FlMethodCall* pending_ready_call; }; @@ -69,13 +71,6 @@ FLUTTER_PLUGIN_EXPORT ThermionTextureGL* thermion_texture_gl_create( int64_t surface_id, FlTextureRegistrar* registrar); -// A 1x1 texture populated on Flutter's raster thread solely to capture the -// EGLDisplay/EGLContext before Filament initializes. -FLUTTER_PLUGIN_EXPORT ThermionTextureGL* thermion_texture_gl_create_context_bootstrap( - uint32_t width, - uint32_t height, - FlTextureRegistrar* registrar); - // EGLImage bridge path: wraps a GL texture + EGLImage. On first populate, // the EGLImage is imported into a new texture on Flutter's own GL context. FLUTTER_PLUGIN_EXPORT ThermionTextureGL* thermion_texture_gl_create_shared( @@ -85,11 +80,18 @@ FLUTTER_PLUGIN_EXPORT ThermionTextureGL* thermion_texture_gl_create_shared( int64_t surface_id, FlTextureRegistrar* registrar); +// Pre-engine initialization path. The GL texture is created lazily from +// populate(), while Flutter's raster EGL context is current. +FLUTTER_PLUGIN_EXPORT ThermionTextureGL* +thermion_texture_gl_create_context_bootstrap( + uint32_t width, uint32_t height, + FlTextureRegistrar* registrar); + FLUTTER_PLUGIN_EXPORT void thermion_texture_gl_destroy(ThermionTextureGL* texture); // Flutter's render context, captured during the first deferred populate(). -// Used by ensure_opengl_context() to create Filament contexts in the correct -// EGL share group (Flutter's Group A, not GDK's Group B). +// Used by ensure_opengl_context() to select the compatible direct or DMA-BUF +// pathway on Flutter's actual EGLDisplay. extern EGLContext thermion_flutter_render_context; extern EGLDisplay thermion_flutter_render_display; diff --git a/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc b/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc index 4dce2a3d3..88fee72c4 100644 --- a/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc +++ b/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,34 @@ #define EGL_GL_TEXTURE_LEVEL_KHR 0x30BC #endif +// Calling Epoxy's eglDestroyImageKHR wrapper without a current EGL context +// makes provider selection depend on thread-local state. Teardown deliberately +// runs after Flutter releases its texture, so resolve the EGL entry point +// directly and let EGL dispatch from the explicit display argument. +static bool destroy_egl_image(EGLDisplay display, EGLImage image) +{ + if (display == EGL_NO_DISPLAY || image == EGL_NO_IMAGE_KHR) + { + return true; + } + + static PFNEGLDESTROYIMAGEKHRPROC destroyImage = + reinterpret_cast( + eglGetProcAddress("eglDestroyImageKHR")); + if (!destroyImage) + { + std::cerr << "[ThermionGL] eglDestroyImageKHR is unavailable" << std::endl; + return false; + } + if (!destroyImage(display, image)) + { + std::cerr << "[ThermionGL] eglDestroyImageKHR failed: 0x" + << std::hex << eglGetError() << std::dec << std::endl; + return false; + } + return true; +} + #include "vulkan/linux/LinuxVulkanContext.h" #include "LinuxOpenGLContext.h" #include "ThermionPlatformEGLHeadlessAPI.h" @@ -39,16 +68,33 @@ struct EglContextGuard { EGLContext prevCtx; EGLSurface prevDraw; EGLSurface prevRead; - EGLDisplay display; + EGLenum prevApi; + EGLDisplay prevDisplay; + EGLDisplay targetDisplay; EglContextGuard(EGLDisplay dpy) : prevCtx(eglGetCurrentContext()), prevDraw(eglGetCurrentSurface(EGL_DRAW)), prevRead(eglGetCurrentSurface(EGL_READ)), - display(dpy) {} + prevApi(eglQueryAPI()), + prevDisplay(eglGetCurrentDisplay()), + targetDisplay(dpy) { + // EGL does not allow switching client APIs while any context is current on + // the thread. Flutter's platform thread can have a GLES context current, + // so release it before binding desktop GL for plugin operations. + if (prevCtx != EGL_NO_CONTEXT && prevDisplay != EGL_NO_DISPLAY) { + eglMakeCurrent( + prevDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + } ~EglContextGuard() { - eglMakeCurrent(display, prevDraw, prevRead, prevCtx); + eglMakeCurrent( + targetDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + eglBindAPI(prevApi); + if (prevCtx != EGL_NO_CONTEXT && prevDisplay != EGL_NO_DISPLAY) { + eglMakeCurrent(prevDisplay, prevDraw, prevRead, prevCtx); + } } EglContextGuard(const EglContextGuard&) = delete; @@ -70,16 +116,22 @@ struct _ThermionFlutterPlugin thermion::vulkan::linux_platform::LinuxVulkanContext *vulkan_context; std::unordered_map *external_images; - // OpenGL path — direct sharing with Flutter's EGL context (preferred) + // OpenGL path — imports Flutter's EGL context before Filament starts. EGLContext flutter_egl_context; // Flutter's own context (captured, not owned) - EGLContext utility_egl_context; // our context in Flutter's share group (for GL ops) - EGLDisplay egl_display; // shared EGL display + EGLenum flutter_egl_api; + // Used for plugin GL operations when Flutter exposes desktop OpenGL. + EGLContext utility_egl_context; + // Used to release Flutter-owned texture names when Flutter exposes GLES and + // Filament must run through the cross-API DMA-BUF pathway. + EGLContext flutter_utility_egl_context; + EGLDisplay egl_display; // Flutter's EGL display EGLConfig egl_config; // config matching Flutter's context - gboolean use_direct_opengl; // TRUE if direct sharing path succeeded + gboolean use_direct_opengl; // TRUE only for compatible desktop GL void* thermion_platform; // standalone OpenGLPlatform (EGLHeadless) // OpenGL path — fallback (LinuxOpenGLContext with GBM/DMA-BUF) thermion::opengl::linux_platform::LinuxOpenGLContext *opengl_context; + std::string opengl_initialization_error; // Shared std::vector *textures; @@ -94,29 +146,36 @@ static void destroy_all_contexts(ThermionFlutterPlugin *self) delete self->vulkan_context; self->vulkan_context = nullptr; } + if (self->thermion_platform) + { + ThermionPlatformEGLHeadless_Destroy(self->thermion_platform); + self->thermion_platform = nullptr; + } + if (self->flutter_utility_egl_context != EGL_NO_CONTEXT && + self->egl_display != EGL_NO_DISPLAY) + { + eglDestroyContext( + self->egl_display, self->flutter_utility_egl_context); + self->flutter_utility_egl_context = EGL_NO_CONTEXT; + } if (self->utility_egl_context != EGL_NO_CONTEXT && self->egl_display != EGL_NO_DISPLAY) { eglDestroyContext(self->egl_display, self->utility_egl_context); self->utility_egl_context = EGL_NO_CONTEXT; } - if (self->use_direct_opengl) - { - self->flutter_egl_context = EGL_NO_CONTEXT; - self->egl_display = EGL_NO_DISPLAY; - self->egl_config = nullptr; - self->use_direct_opengl = FALSE; - if (self->thermion_platform) - { - ThermionPlatformEGLHeadless_Destroy(self->thermion_platform); - self->thermion_platform = nullptr; - } - } if (self->opengl_context) { delete self->opengl_context; self->opengl_context = nullptr; } + self->flutter_egl_context = EGL_NO_CONTEXT; + self->flutter_egl_api = EGL_NONE; + self->egl_display = EGL_NO_DISPLAY; + self->egl_config = nullptr; + self->use_direct_opengl = FALSE; + thermion_flutter_render_context = EGL_NO_CONTEXT; + thermion_flutter_render_display = EGL_NO_DISPLAY; self->backend_type = 0; } @@ -129,109 +188,221 @@ static void ensure_vulkan_context(ThermionFlutterPlugin *self) } } -static void ensure_opengl_context(ThermionFlutterPlugin *self) +static bool ensure_opengl_context(ThermionFlutterPlugin *self) { if (self->use_direct_opengl || self->opengl_context) { - return; // already initialized + return true; // already initialized + } + self->opengl_initialization_error.clear(); + + // Flutter's raster context is only guaranteed to be current inside an + // FlTextureGL populate callback. Dart must display and await the deferred + // context-bootstrap texture before requesting Filament's driver platform. + EGLContext flutterCtx = thermion_flutter_render_context; + EGLDisplay flutterDpy = thermion_flutter_render_display; + if (flutterCtx == EGL_NO_CONTEXT || flutterDpy == EGL_NO_DISPLAY) + { + std::cerr + << "[ThermionGL] Flutter raster context is not ready; " + "create and await the context bootstrap texture before " + "initializing Filament" + << std::endl; + self->opengl_initialization_error = + "Flutter's raster EGL context has not been imported"; + return false; + } + + EGLint clientType = 0; + EGLint glMajor = 0; + EGLint glMinor = 0; + EGLint configId = 0; + if (!eglQueryContext( + flutterDpy, flutterCtx, EGL_CONTEXT_CLIENT_TYPE, &clientType) || + !eglQueryContext( + flutterDpy, flutterCtx, EGL_CONTEXT_MAJOR_VERSION_KHR, &glMajor) || + !eglQueryContext( + flutterDpy, flutterCtx, EGL_CONTEXT_MINOR_VERSION_KHR, &glMinor) || + !eglQueryContext(flutterDpy, flutterCtx, EGL_CONFIG_ID, &configId)) + { + std::cerr << "[ThermionGL] Could not query Flutter's EGL context: 0x" + << std::hex << eglGetError() << std::dec << std::endl; + self->opengl_initialization_error = + "Could not query Flutter's raster EGL context"; + return false; } - // === Use Flutter's render context === - // The deferred texture path captures Flutter's render context during the - // first populate() call. Fall back to the current context (GDK) if no - // deferred texture has been populated yet. + std::cerr << "[ThermionGL] Imported Flutter raster context=" + << (void *)flutterCtx + << " display=" << (void *)flutterDpy + << " type=0x" << std::hex << clientType << std::dec + << " (" << (clientType == EGL_OPENGL_ES_API ? "GLES" : "GL") << ")" + << " version=" << glMajor << "." << glMinor + << " config_id=" << configId << std::endl; + + EGLConfig flutterConfig = nullptr; + EGLint numConfigs = 0; + EGLint configAttribs[] = { EGL_CONFIG_ID, configId, EGL_NONE }; + if (!eglChooseConfig( + flutterDpy, configAttribs, &flutterConfig, 1, &numConfigs) || + numConfigs == 0 || flutterConfig == nullptr) { - EGLContext flutterCtx = thermion_flutter_render_context; - EGLDisplay flutterDpy = thermion_flutter_render_display; + std::cerr << "[ThermionGL] Could not find Flutter's EGL config " + << configId << ": 0x" << std::hex << eglGetError() + << std::dec << std::endl; + self->opengl_initialization_error = + "Could not resolve Flutter's EGLConfig"; + return false; + } + + if (clientType != EGL_OPENGL_API && + clientType != EGL_OPENGL_ES_API) + { + std::cerr << "[ThermionGL] Unsupported Flutter EGL client API: 0x" + << std::hex << clientType << std::dec << std::endl; + self->opengl_initialization_error = + "Flutter uses an unsupported EGL client API"; + return false; + } - if (flutterCtx != EGL_NO_CONTEXT) { - std::cerr << "[ThermionGL] Using Flutter render context=" - << (void*)flutterCtx << std::endl; + EGLint ctxAttribs[] = { + EGL_CONTEXT_MAJOR_VERSION, glMajor > 0 ? glMajor : 3, + EGL_CONTEXT_MINOR_VERSION, glMinor > 0 ? glMinor : 0, + EGL_NONE + }; + EGLenum api = static_cast(clientType); + auto createFlutterUtilityContext = [&]() -> EGLContext + { + if (!eglBindAPI(api)) + { + std::cerr << "[ThermionGL] Could not bind Flutter's EGL API: 0x" + << std::hex << eglGetError() << std::dec << std::endl; + self->opengl_initialization_error = + "Could not bind Flutter's EGL client API"; + return EGL_NO_CONTEXT; } + EGLContext context = + eglCreateContext(flutterDpy, flutterConfig, flutterCtx, ctxAttribs); + if (context == EGL_NO_CONTEXT) + { + std::cerr << "[ThermionGL] Could not create a context shared with " + "Flutter: 0x" + << std::hex << eglGetError() << std::dec << std::endl; + self->opengl_initialization_error = + "Could not create a utility context shared with Flutter"; + } + return context; + }; - if (flutterCtx != EGL_NO_CONTEXT && flutterDpy != EGL_NO_DISPLAY) + if (api == EGL_OPENGL_ES_API) + { + // Filament's Linux backend is compiled for desktop OpenGL. EGL object + // sharing cannot cross the GLES / desktop-GL API boundary. This is the + // case handled by PR #136's DMA-BUF bridge. Keep both APIs on Flutter's + // captured EGLDisplay; a second GBM EGLDisplay can corrupt NVIDIA's + // concurrently rendering Flutter context. + self->opengl_context = + new thermion::opengl::linux_platform::LinuxOpenGLContext( + reinterpret_cast(flutterDpy)); + if (!self->opengl_context->IsValid()) { - EGLint clientType = 0, glMajor = 0, glMinor = 0, configId = 0; - eglQueryContext(flutterDpy, flutterCtx, EGL_CONTEXT_CLIENT_TYPE, &clientType); - eglQueryContext(flutterDpy, flutterCtx, EGL_CONTEXT_MAJOR_VERSION_KHR, &glMajor); - eglQueryContext(flutterDpy, flutterCtx, EGL_CONTEXT_MINOR_VERSION_KHR, &glMinor); - eglQueryContext(flutterDpy, flutterCtx, EGL_CONFIG_ID, &configId); - - std::cerr << "[ThermionGL] Captured Flutter context=" << (void *)flutterCtx - << " display=" << (void *)flutterDpy - << " type=0x" << std::hex << clientType << std::dec - << " (" << (clientType == EGL_OPENGL_ES_API ? "GLES" : "GL") << ")" - << " version=" << glMajor << "." << glMinor - << " config_id=" << configId << std::endl; - - // Get the matching EGL config - EGLConfig config = nullptr; - EGLint numConfigs = 0; - EGLint configAttribs[] = { EGL_CONFIG_ID, configId, EGL_NONE }; - eglChooseConfig(flutterDpy, configAttribs, &config, 1, &numConfigs); - - if (clientType == EGL_OPENGL_API && - (glMajor > 4 || (glMajor == 4 && glMinor >= 1)) && - numConfigs > 0 && config != nullptr) - { - // Bind the appropriate API (GL or GLES) to match Flutter - eglBindAPI(clientType == EGL_OPENGL_ES_API ? EGL_OPENGL_ES_API : EGL_OPENGL_API); - - // Create utility context sharing with Flutter - EGLint ctxAttribs[] = { - EGL_CONTEXT_MAJOR_VERSION, glMajor > 0 ? glMajor : 3, - EGL_CONTEXT_MINOR_VERSION, glMinor > 0 ? glMinor : 0, - EGL_NONE - }; - EGLContext utilityCtx = eglCreateContext(flutterDpy, config, flutterCtx, ctxAttribs); - - if (utilityCtx != EGL_NO_CONTEXT) - { - self->flutter_egl_context = flutterCtx; - self->utility_egl_context = utilityCtx; - self->egl_display = flutterDpy; - self->egl_config = config; - self->use_direct_opengl = TRUE; - self->backend_type = BACKEND_OPENGL; - self->thermion_platform = - ThermionPlatformEGLHeadless_Create(flutterDpy); - - std::cerr << "[ThermionGL] Created utility context=" << (void *)utilityCtx - << " (shared with Flutter)" << std::endl; - return; - } - else - { - std::cerr << "[ThermionGL] eglCreateContext(shared) failed: 0x" - << std::hex << eglGetError() << std::dec - << ", falling back" << std::endl; - } - } - else - { - std::cerr << "[ThermionGL] Could not find EGL config for config_id=" - << configId << ", falling back" << std::endl; - } + self->opengl_initialization_error = + self->opengl_context->GetLastError(); + delete self->opengl_context; + self->opengl_context = nullptr; + self->backend_type = 0; + return false; } - else + EGLContext flutterUtilityCtx = createFlutterUtilityContext(); + if (flutterUtilityCtx == EGL_NO_CONTEXT) { - std::cerr << "[ThermionGL] No EGL context current on this thread," - << " falling back to LinuxOpenGLContext" << std::endl; + delete self->opengl_context; + self->opengl_context = nullptr; + self->backend_type = 0; + return false; } + self->flutter_egl_context = flutterCtx; + self->flutter_egl_api = api; + self->flutter_utility_egl_context = flutterUtilityCtx; + self->egl_display = flutterDpy; + self->egl_config = flutterConfig; + self->backend_type = BACKEND_OPENGL; + std::cerr + << "[ThermionGL] Flutter uses GLES; selected same-display " + "GBM/DMA-BUF OpenGL fallback" + << std::endl; + return true; } - // Flutter normally uses GLES on Linux, while Filament requires desktop GL. - // Keep the existing DMA-BUF transport, but create Filament's desktop context - // on Flutter's already-initialized display instead of racing a second one. - if (thermion_flutter_render_display != EGL_NO_DISPLAY) + if (glMajor < 4 || (glMajor == 4 && glMinor < 1)) { - std::cerr << "[ThermionGL] Using Flutter EGLDisplay with DMA-BUF transport" + std::cerr << "[ThermionGL] Flutter desktop OpenGL " << glMajor << "." + << glMinor << " is below Filament's 4.1 requirement; " + "selected same-display GBM/DMA-BUF fallback" << std::endl; self->opengl_context = new thermion::opengl::linux_platform::LinuxOpenGLContext( - reinterpret_cast(thermion_flutter_render_display)); + reinterpret_cast(flutterDpy)); + if (!self->opengl_context->IsValid()) + { + self->opengl_initialization_error = + self->opengl_context->GetLastError(); + delete self->opengl_context; + self->opengl_context = nullptr; + self->backend_type = 0; + return false; + } + EGLContext flutterUtilityCtx = createFlutterUtilityContext(); + if (flutterUtilityCtx == EGL_NO_CONTEXT) + { + delete self->opengl_context; + self->opengl_context = nullptr; + self->backend_type = 0; + return false; + } + self->flutter_egl_context = flutterCtx; + self->flutter_egl_api = api; + self->flutter_utility_egl_context = flutterUtilityCtx; + self->egl_display = flutterDpy; + self->egl_config = flutterConfig; self->backend_type = BACKEND_OPENGL; + return true; } + + EGLContext flutterUtilityCtx = createFlutterUtilityContext(); + if (flutterUtilityCtx == EGL_NO_CONTEXT) + { + return false; + } + self->flutter_egl_context = flutterCtx; + self->flutter_egl_api = api; + self->utility_egl_context = flutterUtilityCtx; + self->egl_display = flutterDpy; + self->egl_config = flutterConfig; + self->use_direct_opengl = TRUE; + self->backend_type = BACKEND_OPENGL; + self->thermion_platform = ThermionPlatformEGLHeadless_Create(flutterDpy); + if (!self->thermion_platform) + { + eglDestroyContext(flutterDpy, flutterUtilityCtx); + self->flutter_egl_context = EGL_NO_CONTEXT; + self->flutter_egl_api = EGL_NONE; + self->utility_egl_context = EGL_NO_CONTEXT; + self->egl_display = EGL_NO_DISPLAY; + self->egl_config = nullptr; + self->use_direct_opengl = FALSE; + self->backend_type = 0; + std::cerr << "[ThermionGL] Could not create the Filament EGL platform" + << std::endl; + self->opengl_initialization_error = + "Could not create Filament's EGL platform"; + return false; + } + + std::cerr << "[ThermionGL] Created desktop OpenGL utility context=" + << (void *)flutterUtilityCtx << " (shared with Flutter)" + << std::endl; + return true; } static FlMethodResponse *handle_get_driver_platform(ThermionFlutterPlugin *self, FlMethodCall *method_call) @@ -244,12 +415,18 @@ static FlMethodResponse *handle_get_driver_platform(ThermionFlutterPlugin *self, int64_t platform = 0; if (backend == BACKEND_OPENGL) { - ensure_opengl_context(self); - if (!self->use_direct_opengl && !self->opengl_context) + gboolean rasterContextReady = + thermion_flutter_render_context != EGL_NO_CONTEXT && + thermion_flutter_render_display != EGL_NO_DISPLAY; + if (!ensure_opengl_context(self)) { return FL_METHOD_RESPONSE(fl_method_error_response_new( - "CONTEXT_NOT_READY", - "Display the Linux OpenGL bootstrap texture before initialization", + rasterContextReady ? "OPENGL_INITIALIZATION_FAILED" + : "CONTEXT_NOT_READY", + rasterContextReady + ? self->opengl_initialization_error.c_str() + : "Flutter's raster EGL context must be imported before " + "Filament OpenGL initialization", nullptr)); } if (self->use_direct_opengl) @@ -281,17 +458,23 @@ static FlMethodResponse *handle_get_shared_context(ThermionFlutterPlugin *self, int64_t sharedCtx = 0; if (backend == BACKEND_OPENGL) { - ensure_opengl_context(self); - if (!self->use_direct_opengl && !self->opengl_context) + gboolean rasterContextReady = + thermion_flutter_render_context != EGL_NO_CONTEXT && + thermion_flutter_render_display != EGL_NO_DISPLAY; + if (!ensure_opengl_context(self)) { return FL_METHOD_RESPONSE(fl_method_error_response_new( - "CONTEXT_NOT_READY", - "Display the Linux OpenGL bootstrap texture before initialization", + rasterContextReady ? "OPENGL_INITIALIZATION_FAILED" + : "CONTEXT_NOT_READY", + rasterContextReady + ? self->opengl_initialization_error.c_str() + : "Flutter's raster EGL context must be imported before " + "Filament OpenGL initialization", nullptr)); } if (self->use_direct_opengl) { - // Direct sharing: return Flutter's context (Filament contexts share with it) + // Filament creates its own driver context in Flutter's object group. sharedCtx = reinterpret_cast(self->flutter_egl_context); } else @@ -364,10 +547,11 @@ static FlMethodResponse *handle_create_texture_vulkan(ThermionFlutterPlugin *sel static FlMethodResponse *handle_create_texture_opengl_direct(ThermionFlutterPlugin *self, int width, int height) { - // EGLImage bridge: create GL texture on utility context (Group B, same as - // Filament), then bridge to Flutter's render context (Group A) via EGLImage. + // EGLImage bridge: create the source texture on the desktop-GL utility + // context shared by Filament, then let populate() bind the image to a + // Flutter-owned texture name. // - Filament imports the GL texture directly (same share group) - // - Flutter imports the EGLImage in populate() (cross share group) + // - Flutter binds the EGLImage in populate() EGLDisplay display = self->egl_display; GLuint glTexId = 0; @@ -376,13 +560,28 @@ static FlMethodResponse *handle_create_texture_opengl_direct(ThermionFlutterPlug { EglContextGuard guard(display); - // Make utility context current (Group B) - if (!eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->utility_egl_context)) + // Make the desktop-GL utility context current. + if (!eglBindAPI(EGL_OPENGL_API)) { - std::cerr << "[ThermionGL] Failed to make utility context current: 0x" - << std::hex << eglGetError() << std::dec << std::endl; + EGLint error = eglGetError(); + std::cerr << "[ThermionGL] Failed to bind desktop OpenGL: 0x" + << std::hex << error << std::dec << std::endl; + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "EGL_BIND_ERROR", "Failed to bind desktop OpenGL", nullptr)); + } + if (!eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, + self->utility_egl_context)) + { + EGLint error = eglGetError(); + std::cerr << "[ThermionGL] Failed to make plugin texture context " + "current: 0x" + << std::hex << error << std::dec + << " context=" << (void*)self->utility_egl_context + << std::endl; + g_autofree gchar *message = g_strdup_printf( + "Failed to make plugin texture context current (EGL 0x%x)", error); return FL_METHOD_RESPONSE(fl_method_error_response_new( - "EGL_ERROR", "Failed to make utility context current", nullptr)); + "EGL_MAKE_CURRENT_ERROR", message, nullptr)); } // Create GL texture on utility context @@ -396,7 +595,7 @@ static FlMethodResponse *handle_create_texture_opengl_direct(ThermionFlutterPlug glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glBindTexture(GL_TEXTURE_2D, 0); - // Create EGLImage from the GL texture (bridges Group B → Group A) + // Create an EGLImage from the GL texture. // Must be called while the owning context is current EGLint imageAttribs[] = { EGL_GL_TEXTURE_LEVEL_KHR, 0, EGL_NONE }; eglImage = eglCreateImageKHR( @@ -413,6 +612,7 @@ static FlMethodResponse *handle_create_texture_opengl_direct(ThermionFlutterPlug << std::hex << err << std::dec << std::endl; { EglContextGuard guard(display); + eglBindAPI(EGL_OPENGL_API); eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->utility_egl_context); glDeleteTextures(1, &glTexId); } @@ -431,9 +631,10 @@ static FlMethodResponse *handle_create_texture_opengl_direct(ThermionFlutterPlug FlTexture *flTexture = FL_TEXTURE(textureGL); if (!fl_texture_registrar_register_texture(self->texture_registrar, flTexture)) { - eglDestroyImageKHR(display, eglImage); + destroy_egl_image(display, eglImage); { EglContextGuard guard(display); + eglBindAPI(EGL_OPENGL_API); eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->utility_egl_context); glDeleteTextures(1, &glTexId); } @@ -497,13 +698,18 @@ static FlMethodResponse *handle_create_texture_opengl_dmabuf(ThermionFlutterPlug static FlMethodResponse *handle_create_texture_opengl(ThermionFlutterPlugin *self, int width, int height) { - ensure_opengl_context(self); - - if (!self->use_direct_opengl && !self->opengl_context) + gboolean rasterContextReady = + thermion_flutter_render_context != EGL_NO_CONTEXT && + thermion_flutter_render_display != EGL_NO_DISPLAY; + if (!ensure_opengl_context(self)) { return FL_METHOD_RESPONSE(fl_method_error_response_new( - "CONTEXT_NOT_READY", - "Display the Linux OpenGL bootstrap texture before initialization", + rasterContextReady ? "OPENGL_INITIALIZATION_FAILED" + : "CONTEXT_NOT_READY", + rasterContextReady + ? self->opengl_initialization_error.c_str() + : "Cannot create an OpenGL texture before Flutter's raster " + "context is ready", nullptr)); } @@ -518,25 +724,47 @@ static FlMethodResponse *handle_create_context_bootstrap( ThermionFlutterPlugin *self, FlMethodCall *method_call) { FlValue *args = fl_method_call_get_args(method_call); + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_LIST || + fl_value_get_length(args) < 2) + { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGUMENTS", "Expected bootstrap texture width and height", + nullptr)); + } + int width = fl_value_get_int(fl_value_get_list_value(args, 0)); int height = fl_value_get_int(fl_value_get_list_value(args, 1)); + if (width <= 0 || height <= 0) + { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGUMENTS", "Bootstrap texture dimensions must be positive", + nullptr)); + } + ThermionTextureGL *textureGL = thermion_texture_gl_create_context_bootstrap( static_cast(width), static_cast(height), self->texture_registrar); FlTexture *flTexture = FL_TEXTURE(textureGL); - if (!fl_texture_registrar_register_texture(self->texture_registrar, flTexture)) + if (!fl_texture_registrar_register_texture( + self->texture_registrar, flTexture)) { g_object_unref(textureGL); return FL_METHOD_RESPONSE(fl_method_error_response_new( - "REGISTER_FAILED", "Failed to register context bootstrap", nullptr)); + "REGISTER_FAILED", + "Failed to register Flutter context bootstrap texture", nullptr)); } + self->textures->push_back(textureGL); self->backend_type = BACKEND_OPENGL; fl_texture_registrar_mark_texture_frame_available( self->texture_registrar, flTexture); - return FL_METHOD_RESPONSE(fl_method_success_response_new( - fl_value_new_int(fl_texture_get_id(flTexture)))); + + int64_t flutterTextureId = fl_texture_get_id(flTexture); + std::cerr << "[ThermionGL] Registered context bootstrap texture, flutterId=" + << flutterTextureId << std::endl; + g_autoptr(FlValue) result = fl_value_new_int(flutterTextureId); + return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); } @@ -567,25 +795,104 @@ static FlMethodResponse *handle_destroy_texture(ThermionFlutterPlugin *self, FlM if (fl_texture_get_id(FL_TEXTURE(tex)) == flutterTextureId) { int64_t surfaceId = tex->surface_id; + gboolean useDirectSharing = tex->use_direct_sharing; + gboolean useEglImage = tex->use_egl_image; + gboolean isContextBootstrap = tex->is_context_bootstrap; + GLuint glTextureId = tex->gl_texture_id; + GLuint flutterGlTextureId = tex->flutter_gl_texture_id; + EGLImage eglImage = tex->egl_image; + FlMethodCall *pendingReadyCall = tex->pending_ready_call; + tex->pending_ready_call = nullptr; + + if (pendingReadyCall) + { + fl_method_call_respond( + pendingReadyCall, + FL_METHOD_RESPONSE(fl_method_error_response_new( + "DESTROYED", + "Texture destroyed before Flutter populated it", nullptr)), + nullptr); + g_object_unref(pendingReadyCall); + } fl_texture_registrar_unregister_texture(self->texture_registrar, FL_TEXTURE(tex)); + gboolean initializedOnlyForBootstrapCleanup = FALSE; if (self->backend_type == BACKEND_OPENGL) { - if (tex->use_direct_sharing || tex->use_egl_image) + if (useDirectSharing || useEglImage) { - // Direct sharing / EGLImage bridge: delete the source GL texture on utility context - GLuint texId = tex->gl_texture_id; - if (texId != 0 && self->utility_egl_context != EGL_NO_CONTEXT) + // EGLImage bridge: source names belong to Filament's utility group. + // Bootstrap names belong to Flutter's group. + if (glTextureId != 0 && + self->utility_egl_context == EGL_NO_CONTEXT && + self->flutter_utility_egl_context == EGL_NO_CONTEXT && + useDirectSharing) + { + // A bootstrap may be cancelled after populate() but before Dart + // requests the driver platform. Import the captured context now so + // its GL object can still be deleted from the correct share group. + initializedOnlyForBootstrapCleanup = + isContextBootstrap && ensure_opengl_context(self); + } + EGLContext sourceContext = useDirectSharing && + self->flutter_utility_egl_context != EGL_NO_CONTEXT + ? self->flutter_utility_egl_context + : self->utility_egl_context; + if (glTextureId != 0 && sourceContext != EGL_NO_CONTEXT) { EglContextGuard guard(self->egl_display); + eglBindAPI(useDirectSharing + ? self->flutter_egl_api + : EGL_OPENGL_API); eglMakeCurrent(self->egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, - self->utility_egl_context); - glDeleteTextures(1, &texId); + sourceContext); + glDeleteTextures(1, &glTextureId); + } + + if (useEglImage) + { + EGLContext flutterContext = + self->flutter_utility_egl_context != EGL_NO_CONTEXT + ? self->flutter_utility_egl_context + : self->utility_egl_context; + if (flutterGlTextureId != 0 && + flutterContext != EGL_NO_CONTEXT) + { + EglContextGuard guard(self->egl_display); + eglBindAPI(self->flutter_egl_api); + eglMakeCurrent(self->egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, + flutterContext); + glDeleteTextures(1, &flutterGlTextureId); + } + if (eglImage != EGL_NO_IMAGE_KHR) + { + destroy_egl_image(self->egl_display, eglImage); + } } } else if (self->opengl_context) { + // DMA-BUF fallback: populate() created the consumer texture and + // EGLImage in Flutter's GLES share group. Release those before the + // producer destroys the backing GBM buffer. + if (glTextureId != 0 && + self->flutter_utility_egl_context != EGL_NO_CONTEXT) + { + EglContextGuard guard(self->egl_display); + eglBindAPI(self->flutter_egl_api); + if (eglMakeCurrent( + self->egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, + self->flutter_utility_egl_context)) + { + glDeleteTextures(1, &glTextureId); + } + } + if (eglImage != EGL_NO_IMAGE_KHR && + self->egl_display != EGL_NO_DISPLAY) + { + destroy_egl_image(self->egl_display, eglImage); + } self->opengl_context->DestroyRenderingSurface(surfaceId); } } @@ -607,6 +914,20 @@ static FlMethodResponse *handle_destroy_texture(ThermionFlutterPlugin *self, FlM } self->textures->erase(it); + if (self->backend_type == BACKEND_OPENGL) + { + // The plugin retains each factory's construction reference in + // addition to the registrar's reference. All OpenGL/EGL objects have + // now been deleted from their owning contexts. + tex->gl_texture_id = 0; + tex->flutter_gl_texture_id = 0; + tex->egl_image = EGL_NO_IMAGE_KHR; + g_object_unref(tex); + } + if (initializedOnlyForBootstrapCleanup) + { + destroy_all_contexts(self); + } break; } } @@ -786,7 +1107,9 @@ static void thermion_flutter_plugin_init(ThermionFlutterPlugin *self) self->view = nullptr; self->vulkan_context = nullptr; self->flutter_egl_context = EGL_NO_CONTEXT; + self->flutter_egl_api = EGL_NONE; self->utility_egl_context = EGL_NO_CONTEXT; + self->flutter_utility_egl_context = EGL_NO_CONTEXT; self->egl_display = EGL_NO_DISPLAY; self->egl_config = nullptr; self->use_direct_opengl = FALSE; From eb7551076082767adfc42ca3c75b7d336a75b558 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 02:56:34 +0000 Subject: [PATCH 03/16] chore: update generated artifacts + format (CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with GitHub Actions --- .../src/platform_texture_descriptor_registry_native.dart | 5 +++-- .../platform/src/thermion_flutter_plugin_initializer.dart | 5 +++-- .../thermion_flutter/lib/src/thermion_flutter_plugin.dart | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry_native.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry_native.dart index 71ba659be..ad63e682e 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry_native.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/platform_texture_descriptor_registry_native.dart @@ -11,8 +11,9 @@ import 'method_channel_platform_texture_descriptor.dart'; import 'platform_texture_descriptor.dart'; import 'platform_texture_descriptor_registry.dart'; -typedef TextureMutationRunner = - Future Function(Future Function() operation); +typedef TextureMutationRunner = Future Function( + Future Function() operation, +); class FilamentRenderingContext { const FilamentRenderingContext({ diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart index 146bad260..f843a7a5e 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart @@ -6,8 +6,9 @@ import 'platform_texture_descriptor.dart'; typedef ContextBootstrapAllocator = Future Function(); -typedef ContextBootstrapDestroyer = - Future Function(PlatformTextureDescriptor descriptor); +typedef ContextBootstrapDestroyer = Future Function( + PlatformTextureDescriptor descriptor, +); /// Hosts the Flutter-side prerequisites for initializing the native plugin, /// then runs [initialize]. 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 c79f9fdef..9e928c450 100644 --- a/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart +++ b/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart' hide View; import 'package:thermion_dart/thermion_dart.dart'; @@ -7,6 +8,7 @@ import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.d import 'package:thermion_flutter/src/options.dart'; import 'package:thermion_flutter/src/platform/src/platform_texture_descriptor.dart'; import 'package:thermion_flutter/src/platform/src/thermion_flutter_plugin_initializer.dart'; + import 'platform/platform.dart'; import 'package:logging/logging.dart'; From e81a6c6b604ff03efbf1e3fcd5964b84b6cebde1 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 12:43:58 +0800 Subject: [PATCH 04/16] fix!: caller-managed ColorGrading lifecycle, builder-only API (removes setToneMapper) (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix!: view-owned ColorGrading lifecycle, builder-only API Consolidates the color-grading work (previously a chain of commits on develop; rebased onto the FilamentApp engine-handle branch): - the view owns whichever ColorGrading is currently set: replacing, clearing, or destroying the view destroys the owned grading (fixes leaks on replace/clear/teardown) - ColorGrading and ColorGradingBuilder expose dispose() (scoped to unattached gradings / builder freeing); the builder is REUSABLE per Filament's pattern - build() does not consume it - ToneMapper dispose is idempotent, with the mapper-must-outlive-the- builder contract documented per Filament's header - removes View.setToneMapper, ThermionViewer.setToneMapper, the dead FilamentApp.createColorGrading, and the native ColorGrading_create / ColorGrading_createRenderThread C entry points (bindings regenerated) - docstring examples fixed (ToneMapper.ACES never existed); web_gallery example corrected (was double-freeing the replaced grading) - tests cover the full lifecycle: destroy-on-replace, clear-and-destroy, teardown, abandoned-builder dispose, double-dispose no-ops, use-after-dispose throwing, builder reuse with mutate-between-builds Co-Authored-By: Claude * docs: contract docstrings live on interfaces, not FFI impls FFIColorGrading.dispose, FFIColorGradingBuilder.build, and the FFIToneMapper factories duplicated docstrings that already exist on the abstract ColorGrading, ColorGradingBuilder, and ToneMapper interfaces. The duplicates had also started to drift from the interface wording. The interface layer is the public API surface (the FFI statics are reachable but secondary), so it owns the contract documentation; the implementations keep only their one-line class markers. Co-Authored-By: Claude * docs: setColorGrading - one view per ColorGrading Each view destroys the grading it owns, so sharing an instance across views dangling-pointers the second view. Document the restriction and the supported pattern (build one grading per view; the reusable builder makes that cheap). Co-Authored-By: Claude * feat: share one ColorGrading across multiple views Filament permits attaching a single ColorGrading to several views, so replace exclusive view ownership with collective ownership: - each attach registers a per-view reference; detaching (replace, clear, or view destruction - always after the dissociating native set/clear, per Filament's dissociate-before-destroy rule) releases it, and the native grading is destroyed when the last view detaches - ColorGrading.dispose() destroys an unattached grading immediately and defers destruction while any view is still attached (instead of being forbidden) - one view releasing a grading never affects other views still using it New tests: a grading shared by two views survives the first view detaching (captured on the second to prove it), and dispose-while- attached defers until the last detach. Co-Authored-By: Claude * chore: update generated artifacts + format (CI) 🤖 Generated with GitHub Actions * refactor!: caller-managed ColorGrading lifecycle, no refcounting Filament's contract: View::setColorGrading performs no ownership transfer and no reference counting; a grading must be dissociated from all views before it is destroyed. Thermion now requires the same manual lifecycle instead of tracking attachments internally: - FFIColorGrading: drop the view-count/deferred-dispose machinery; dispose() destroys the native grading immediately (idempotent). - FFIView: setColorGrading is a plain non-owning forward; the view no longer tracks or releases the grading on replace/clear/destroy. - Docs on ColorGrading/setColorGrading/getColorGrading state the caller-managed contract, including multi-view sharing and the non-owning getColorGrading wrapper (never dispose it). - Tests reworked for dissociate-then-dispose; the deferred-dispose test is replaced by a dispose-idempotency test. Test helpers track gradings they attach and dispose them after the viewer is destroyed. - web_gallery effects controls dispose the replaced/cleared grading. Co-Authored-By: Claude * Update CHANGELOG.md --------- Co-authored-by: Claude Co-authored-by: github-actions[bot] --- CHANGELOG.md | 26 ++- .../dart/cli_headless/bin/render_demo.dart | 7 +- .../examples_lib/lib/src/post_processing.dart | 6 + .../web_gallery/web/effects_controls.dart | 24 ++- .../src/bindings/src/thermion_dart_ffi.g.dart | 19 --- .../src/thermion_dart_js_interop.g.dart | 64 +++----- .../implementation/edge_detection_view.dart | 1 + .../src/implementation/ffi_color_grading.dart | 60 ++++--- .../src/implementation/ffi_filament_app.dart | 6 - .../src/implementation/ffi_tone_mapper.dart | 33 +--- .../filament/src/implementation/ffi_view.dart | 44 +----- .../filament/src/interface/filament_app.dart | 3 - .../filament/src/interface/tone_mapper.dart | 8 +- .../lib/src/filament/src/interface/view.dart | 110 +++++++++---- .../src/ffi/src/thermion_viewer_ffi.dart | 6 - .../src/viewer/src/thermion_viewer_base.dart | 3 - thermion_dart/native/include/c_api/TView.h | 3 - .../c_api/ThermionDartRenderThreadApi.h | 2 - thermion_dart/native/src/c_api/TView.cpp | 11 -- .../src/c_api/ThermionDartRenderThreadApi.cpp | 13 -- thermion_dart/test/color_grading_tests.dart | 149 ++++++++++++++++-- thermion_dart/test/destructor_tests.dart | 1 + thermion_dart/test/helpers.dart | 41 ++++- thermion_dart/test/unlit_material_tests.dart | 7 +- 24 files changed, 386 insertions(+), 261 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8499785d..8972aa97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,24 @@ ### Changes - `FilamentApp` exposes the native engine handle as a public - `Pointer get engine` (thermion_dart is FFI-backed on every - supported target, including web/WASM). `ToneMapper` factory methods take - the abstract `FilamentApp` instead of `FFIFilamentApp`, so callers no - longer need to downcast (`ToneMapper.aces(FilamentApp.instance!)` just - works). -- `TranslationAxisMaterial.createMaterialInstance` takes the abstract - `FilamentApp` too, and `FFIMaterial`/`FFIMaterialInstance` hold the - abstract type internally (they only ever needed the engine handle). +- `TranslationAxisMaterial.createMaterialInstance` and `ToneMapper` factory methods + now take the abstract `FilamentApp` instead of + `FFIFilamentApp` +- `ColorGrading` follows Filament's ownership model (caller + manages lifecycle). A `ColorGrading` must be dissociated from every view + (`setColorGrading` with a replacement or null) BEFORE calling + `ColorGrading.dispose()`. Attaching one grading to multiple views + remains supported, but its lifetime is entirely yours. +- `ColorGrading` and `ColorGradingBuilder` expose `dispose()` through the + public interface (previously FFI-only / missing). + +### Breaking changes +- remove the unused `FilamentApp.createColorGrading` — it returned a raw + pointer nobody could destroy; use `View.createColorGradingBuilder().build()` + instead. +- remove `View.setToneMapper` and `ThermionViewer.setToneMapper` - use + `view.createColorGradingBuilder().toneMapper(...).build()` followed by + `view.setColorGrading()` instead. ## 0.6.0 diff --git a/examples/dart/cli_headless/bin/render_demo.dart b/examples/dart/cli_headless/bin/render_demo.dart index fce02ea01..5fd36a659 100644 --- a/examples/dart/cli_headless/bin/render_demo.dart +++ b/examples/dart/cli_headless/bin/render_demo.dart @@ -180,7 +180,12 @@ Future main(List argv) async { await viewer.view.setFrustumCullingEnabled(false); await viewer.setViewport(width, height); // ACES tone mapping for filmic, non-blow-out highlights. - await viewer.setToneMapper(await ToneMapper.aces(app)); + final builder = await viewer.view.createColorGradingBuilder(); + final toneMapper = await ToneMapper.aces(app); + final colorGrading = await builder.toneMapper(toneMapper).build(); + await builder.dispose(); + await toneMapper.dispose(); // builder disposed; grading holds a copy + await viewer.view.setColorGrading(colorGrading); // Environment: HDRI skybox + image-based lighting. `--env=` selects one. // `--no-skybox` skips the visible skybox (background goes black) but keeps the diff --git a/examples/dart/examples_lib/lib/src/post_processing.dart b/examples/dart/examples_lib/lib/src/post_processing.dart index a31b3684c..316bc20a8 100644 --- a/examples/dart/examples_lib/lib/src/post_processing.dart +++ b/examples/dart/examples_lib/lib/src/post_processing.dart @@ -27,6 +27,11 @@ Future setupPostProcessing( // Warm colour grading (skips toneMapper -- the builder defaults to // ACESLegacy when toneMapper is not explicitly set). + // + // The grading is caller-owned (as in Filament): it stays attached for the + // viewer's lifetime here. A caller tearing the view down earlier must + // dissociate (`setColorGrading(null)`) and dispose it - see + // web_gallery/web/effects_controls.dart for the full pattern. final builder = await viewer.view.createColorGradingBuilder(); final grading = await builder .quality(QualityLevel.HIGH) @@ -36,5 +41,6 @@ Future setupPostProcessing( .saturation(1.1) .vibrance(1.2) .build(); + await builder.dispose(); await viewer.view.setColorGrading(grading); } diff --git a/examples/dart/web_gallery/web/effects_controls.dart b/examples/dart/web_gallery/web/effects_controls.dart index b183dec7e..b7fc9868e 100644 --- a/examples/dart/web_gallery/web/effects_controls.dart +++ b/examples/dart/web_gallery/web/effects_controls.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:js_interop'; -import 'package:thermion_dart/src/filament/src/implementation/ffi_color_grading.dart'; import 'package:thermion_dart/thermion_dart.dart'; import 'package:web/web.dart'; @@ -42,10 +41,14 @@ Future installEffectsControls(ThermionViewer viewer) async { document.getElementById('${input.id}-value')!.textContent = input.value; } - FFIColorGrading? currentGrading; Timer? gradingDebounce; Future gradingQueue = Future.value(); + // The grading currently attached to the view. The caller owns its + // lifecycle (as in Filament - the view holds only a non-owning + // reference), so it is disposed here whenever it is replaced or cleared. + ColorGrading? attachedGrading; + Future rebuildColorGrading() async { try { final builder = await viewer.view.createColorGradingBuilder(); @@ -56,13 +59,18 @@ Future installEffectsControls(ThermionViewer viewer) async { .contrast(valueOf(contrast)) .saturation(valueOf(saturation)) .vibrance(valueOf(vibrance)) - .build() as FFIColorGrading; + .build(); + await builder.dispose(); if (colorGrading.checked) { + // Attaching the new grading dissociates the old one, which can then + // be disposed. await viewer.view.setColorGrading(next); + await attachedGrading?.dispose(); + attachedGrading = next; + } else { + // Built but never attached: dispose it immediately. + await next.dispose(); } - final previous = currentGrading; - currentGrading = next; - await previous?.dispose(); } catch (error, stackTrace) { print('Failed to update color grading: $error\n$stackTrace'); } @@ -102,7 +110,11 @@ Future installEffectsControls(ThermionViewer viewer) async { if (colorGrading.checked) { await rebuildColorGrading(); } else { + // Dissociate from the view, then dispose the caller-owned + // grading. await viewer.view.setColorGrading(null); + await attachedGrading?.dispose(); + attachedGrading = null; } }); }).toJS); diff --git a/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart b/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart index 7565fa7a7..45d5b2028 100644 --- a/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart +++ b/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart @@ -60,12 +60,6 @@ external ffi.Pointer ToneMapper_createDisplayRange(ffi.Pointer)>(isLeaf: true) external void ToneMapper_destroy(ffi.Pointer toneMapper); -@ffi.Native Function(ffi.Pointer, ffi.Pointer)>(isLeaf: true) -external ffi.Pointer ColorGrading_create( - ffi.Pointer tEngine, - ffi.Pointer toneMapper, -); - @ffi.Native Function()>(isLeaf: true) external ffi.Pointer ColorGradingBuilder_create(); @@ -2334,19 +2328,6 @@ external void Material_createTranslationAxisMaterialRenderThread( ffi.Pointer)>> onComplete, ); -@ffi.Native< - ffi.Void Function( - ffi.Pointer, - ffi.Pointer, - ffi.Pointer)>>, - ) ->(isLeaf: true) -external void ColorGrading_createRenderThread( - ffi.Pointer tEngine, - ffi.Pointer toneMapper, - ffi.Pointer)>> callback, -); - @ffi.Native)>>)>( isLeaf: true, ) diff --git a/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart b/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart index 3d0bc948a..a91ff31ae 100644 --- a/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart +++ b/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart @@ -44,7 +44,6 @@ extension type GeneratedBindings(NativeLibrary _) implements JSObject { ); external Pointer _ToneMapper_createDisplayRange(Pointer tEngine); external void _ToneMapper_destroy(Pointer toneMapper); - external Pointer _ColorGrading_create(Pointer tEngine, Pointer toneMapper); external Pointer _ColorGradingBuilder_create(); external Pointer _ColorGradingBuilder_build( Pointer builder, @@ -1131,11 +1130,6 @@ extension type GeneratedBindings(NativeLibrary _) implements JSObject { Pointer tEngine, Pointer)>> onComplete, ); - external void _ColorGrading_createRenderThread( - Pointer tEngine, - Pointer toneMapper, - Pointer)>> callback, - ); external void _ColorGradingBuilder_createRenderThread( Pointer)>> onComplete, ); @@ -2487,11 +2481,6 @@ void ToneMapper_destroy(Pointer toneMapper) { return result; } -Pointer ColorGrading_create(Pointer tEngine, Pointer toneMapper) { - final result = GeneratedBindings.instance._ColorGrading_create(tEngine.cast(), toneMapper.cast()); - return Pointer(result); -} - Pointer ColorGradingBuilder_create() { final result = GeneratedBindings.instance._ColorGradingBuilder_create(); return Pointer(result); @@ -5579,19 +5568,6 @@ void Material_createTranslationAxisMaterialRenderThread( return result; } -void ColorGrading_createRenderThread( - Pointer tEngine, - Pointer toneMapper, - Pointer)>> callback, -) { - final result = GeneratedBindings.instance._ColorGrading_createRenderThread( - tEngine.cast(), - toneMapper.cast(), - callback.cast(), - ); - return result; -} - void ColorGradingBuilder_createRenderThread( Pointer)>> onComplete, ) { @@ -8882,21 +8858,6 @@ final class TEngine extends Struct { } } -extension TColorGradingExt on Pointer { - TColorGrading toDart() { - return TColorGrading(this); - } -} - -final class TColorGrading extends Struct { - Pointer get address => super.address.cast(); - TColorGrading(super.address); - - static Pointer stackAlloc() { - return Pointer(NativeLibrary.instance.stackAlloc(0)); - } -} - extension TColorGradingBuilderExt on Pointer { TColorGradingBuilder toDart() { return TColorGradingBuilder(this); @@ -8912,6 +8873,21 @@ final class TColorGradingBuilder extends Struct { } } +extension TColorGradingExt on Pointer { + TColorGrading toDart() { + return TColorGrading(this); + } +} + +final class TColorGrading extends Struct { + Pointer get address => super.address.cast(); + TColorGrading(super.address); + + static Pointer stackAlloc() { + return Pointer(NativeLibrary.instance.stackAlloc(0)); + } +} + sealed class TQualityLevel { static const LOW = 0; static const MEDIUM = 1; @@ -11193,12 +11169,12 @@ extension StructAllocator on Struct { case TEngine: final ptr = TEngine.stackAlloc(); return ptr.toDart() as T; - case TColorGrading: - final ptr = TColorGrading.stackAlloc(); - return ptr.toDart() as T; case TColorGradingBuilder: final ptr = TColorGradingBuilder.stackAlloc(); return ptr.toDart() as T; + case TColorGrading: + final ptr = TColorGrading.stackAlloc(); + return ptr.toDart() as T; case TRenderTarget: final ptr = TRenderTarget.stackAlloc(); return ptr.toDart() as T; @@ -11372,7 +11348,7 @@ extension NativeFunctionPointer17 on void Function(bool) { } } -extension NativeFunctionPointer48 on void Function(int) { +extension NativeFunctionPointer47 on void Function(int) { Pointer> addFunction() { return Pointer>( NativeLibrary.instance.addFunction(this.toJS, 'vi'), @@ -11380,7 +11356,7 @@ extension NativeFunctionPointer48 on void Function(int) { } } -extension NativeFunctionPointer62 on void Function(double) { +extension NativeFunctionPointer61 on void Function(double) { Pointer> addFunction() { return Pointer>( NativeLibrary.instance.addFunction(this.toJS, 'vf'), diff --git a/thermion_dart/lib/src/filament/src/implementation/edge_detection_view.dart b/thermion_dart/lib/src/filament/src/implementation/edge_detection_view.dart index 5c53708b2..2fcd93280 100644 --- a/thermion_dart/lib/src/filament/src/implementation/edge_detection_view.dart +++ b/thermion_dart/lib/src/filament/src/implementation/edge_detection_view.dart @@ -214,6 +214,7 @@ class EdgeDetectionView extends FFIView { ); colorGradingBuilder.toneMapper(linearToneMapper); final linearColorGrading = await colorGradingBuilder.build(); + await colorGradingBuilder.dispose(); // Create the EdgeDetectionView with all resources final edgeDetectionView = EdgeDetectionView._( diff --git a/thermion_dart/lib/src/filament/src/implementation/ffi_color_grading.dart b/thermion_dart/lib/src/filament/src/implementation/ffi_color_grading.dart index 57eaa840f..b86d32c3b 100644 --- a/thermion_dart/lib/src/filament/src/implementation/ffi_color_grading.dart +++ b/thermion_dart/lib/src/filament/src/implementation/ffi_color_grading.dart @@ -5,6 +5,7 @@ import 'ffi_filament_app.dart'; /// FFI implementation of ColorGrading class FFIColorGrading extends ColorGrading { final Pointer pointer; + bool _disposed = false; final FFIFilamentApp _app; @@ -13,7 +14,12 @@ class FFIColorGrading extends ColorGrading { @override Pointer getNativeHandle() => pointer; + @override Future dispose() async { + if (_disposed) { + return; + } + _disposed = true; await withVoidCallback( (requestId, cb) => Engine_destroyColorGradingRenderThread(_app.engine, pointer, requestId, cb), ); @@ -23,42 +29,41 @@ class FFIColorGrading extends ColorGrading { /// FFI implementation of ColorGradingBuilder class FFIColorGradingBuilder extends ColorGradingBuilder { final Pointer _builder; - bool _built = false; - final FFIFilamentApp _app; + bool _disposed = false; FFIColorGradingBuilder(this._builder, this._app); - void _checkNotBuilt() { - if (_built) { - throw StateError('Builder has already been built and cannot be reused'); + void _checkNotDisposed() { + if (_disposed) { + throw StateError('Builder has been disposed'); } } @override ColorGradingBuilder quality(QualityLevel level) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_quality(_builder, level.index); return this; } @override ColorGradingBuilder format(LutFormat format) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_format(_builder, format.index); return this; } @override ColorGradingBuilder dimensions(int dim) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_dimensions(_builder, dim); return this; } @override ColorGradingBuilder toneMapper(ToneMapper mapper) { - _checkNotBuilt(); + _checkNotDisposed(); // Extract the native pointer from the ToneMapper object final Pointer toneMapperPtr = mapper.getNativeHandle(); ColorGradingBuilder_toneMapper(_builder, toneMapperPtr); @@ -67,49 +72,49 @@ class FFIColorGradingBuilder extends ColorGradingBuilder { @override ColorGradingBuilder exposure(double exposure) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_exposure(_builder, exposure); return this; } @override ColorGradingBuilder nightAdaptation(double adaptation) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_nightAdaptation(_builder, adaptation); return this; } @override ColorGradingBuilder whiteBalance(double temperature, double tint) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_whiteBalance(_builder, temperature, tint); return this; } @override ColorGradingBuilder contrast(double contrast) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_contrast(_builder, contrast); return this; } @override ColorGradingBuilder vibrance(double vibrance) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_vibrance(_builder, vibrance); return this; } @override ColorGradingBuilder saturation(double saturation) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_saturation(_builder, saturation); return this; } @override ColorGradingBuilder channelMixer(Vector3 outRed, Vector3 outGreen, Vector3 outBlue) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_channelMixer( _builder, outRed.x, @@ -127,7 +132,7 @@ class FFIColorGradingBuilder extends ColorGradingBuilder { @override ColorGradingBuilder shadowsMidtonesHighlights(Vector4 shadows, Vector4 midtones, Vector4 highlights, Vector4 ranges) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_shadowsMidtonesHighlights( _builder, shadows.x, @@ -152,7 +157,7 @@ class FFIColorGradingBuilder extends ColorGradingBuilder { @override ColorGradingBuilder slopeOffsetPower(Vector3 slope, Vector3 offset, Vector3 power) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_slopeOffsetPower( _builder, slope.x, @@ -170,7 +175,7 @@ class FFIColorGradingBuilder extends ColorGradingBuilder { @override ColorGradingBuilder curves(Vector3 shadowGamma, Vector3 midPoint, Vector3 highlightScale) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_curves( _builder, shadowGamma.x, @@ -188,29 +193,36 @@ class FFIColorGradingBuilder extends ColorGradingBuilder { @override ColorGradingBuilder luminanceScaling(bool enabled) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_luminanceScaling(_builder, enabled); return this; } @override ColorGradingBuilder gamutMapping(bool enabled) { - _checkNotBuilt(); + _checkNotDisposed(); ColorGradingBuilder_gamutMapping(_builder, enabled); return this; } @override Future build() async { - _checkNotBuilt(); - _built = true; + _checkNotDisposed(); final ptr = await withPointerCallback( (cb) => ColorGradingBuilder_buildRenderThread(_builder, _app.engine, cb), ); - await withVoidCallback((requestId, cb) => ColorGradingBuilder_destroyRenderThread(_builder, requestId, cb)); if (ptr == nullptr) { throw Exception('Failed to build ColorGrading'); } return FFIColorGrading(ptr, _app); } + + @override + Future dispose() async { + if (_disposed) { + return; + } + _disposed = true; + await withVoidCallback((requestId, cb) => ColorGradingBuilder_destroyRenderThread(_builder, requestId, cb)); + } } 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 3b19b7bcf..bd95f6513 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 @@ -1266,12 +1266,6 @@ class FFIFilamentApp extends FilamentApp { await withVoidCallback((requestId, cb) => Engine_destroySceneRenderThread(engine, scene.scene, requestId, cb)); } - Future> createColorGrading(ToneMapper mapper) async { - return withPointerCallback( - (cb) => ColorGrading_createRenderThread(engine, mapper.getNativeHandle(), cb), - ); - } - // Future createGizmo(View view, GizmoType gizmoType) async { return FFIGizmo.create(this, view, gizmoType); diff --git a/thermion_dart/lib/src/filament/src/implementation/ffi_tone_mapper.dart b/thermion_dart/lib/src/filament/src/implementation/ffi_tone_mapper.dart index 5aa185d4e..5ba550d6c 100644 --- a/thermion_dart/lib/src/filament/src/implementation/ffi_tone_mapper.dart +++ b/thermion_dart/lib/src/filament/src/implementation/ffi_tone_mapper.dart @@ -3,28 +3,23 @@ import 'package:thermion_dart/thermion_dart.dart'; /// FFI implementation of ToneMapper class FFIToneMapper extends ToneMapper { final Pointer _pointer; + bool _disposed = false; FFIToneMapper._(this._pointer); @override Pointer getNativeHandle() => _pointer; - /// Create a LinearToneMapper - returns input color clamped to 0..1 range - /// Useful for debugging static Future linear(FilamentApp app) async { final pointer = await withPointerCallback((cb) => ToneMapper_createLinearRenderThread(app.engine, cb)); return FFIToneMapper._(pointer); } - /// Create an ACESToneMapper - ACES Reference Rendering Transform (RRT) - /// combined with the Output Device Transform (ODT) for sRGB monitors static Future aces(FilamentApp app) async { final pointer = await withPointerCallback((cb) => ToneMapper_createACESRenderThread(app.engine, cb)); return FFIToneMapper._(pointer); } - /// Create an ACESLegacyToneMapper - ACES tone mapper modified to match - /// the perceived brightness of FilmicToneMapper (applies ~1.6x brightness) static Future acesLegacy(FilamentApp app) async { final pointer = await withPointerCallback( (cb) => ToneMapper_createACESLegacyRenderThread(app.engine, cb), @@ -32,15 +27,11 @@ class FFIToneMapper extends ToneMapper { return FFIToneMapper._(pointer); } - /// Create a FilmicToneMapper - designed to approximate ACES RRT + ODT - /// for Rec.709. Exists for backward compatibility. static Future filmic(FilamentApp app) async { final pointer = await withPointerCallback((cb) => ToneMapper_createFilmicRenderThread(app.engine, cb)); return FFIToneMapper._(pointer); } - /// Create a PBRNeutralToneMapper - Khronos PBR Neutral tone mapper - /// designed to preserve material appearance across lighting conditions static Future pbrNeutral(FilamentApp app) async { final pointer = await withPointerCallback( (cb) => ToneMapper_createPBRNeutralRenderThread(app.engine, cb), @@ -48,12 +39,6 @@ class FFIToneMapper extends ToneMapper { return FFIToneMapper._(pointer); } - /// Create an AgxToneMapper with optional look - /// - /// [look] - Optional creative adjustment to contrast and saturation: - /// - AgxLook.none: Base contrast with no look applied - /// - AgxLook.punchy: More chroma laden look for sRGB displays - /// - AgxLook.golden: Golden tinted look for BT.1886 displays static Future agx(FilamentApp app, {AgxLook look = AgxLook.none}) async { final pointer = await withPointerCallback( (cb) => ToneMapper_createAGXWithLookRenderThread(app.engine, look.index, cb), @@ -61,16 +46,6 @@ class FFIToneMapper extends ToneMapper { return FFIToneMapper._(pointer); } - /// Create a GenericToneMapper with configurable parameters - /// - /// Provides control over the tone mapping curve aesthetics and dynamic range. - /// Default parameters approximate an ACES tone mapping curve. - /// - /// [contrast] - Controls the contrast of the curve (must be > 0.0) - /// Recommended range: 0.5..2.0 (default: 1.55) - /// [midGrayIn] - Input middle gray value (0.0..1.0, default: 0.18) - /// [midGrayOut] - Output middle gray value (0.0..1.0, default: 0.215) - /// [hdrMax] - Maximum input value mapped to output white (>= 1.0, default: 10.0) static Future generic( FilamentApp app, { double contrast = 1.55, @@ -84,8 +59,6 @@ class FFIToneMapper extends ToneMapper { return FFIToneMapper._(pointer); } - /// Create a DisplayRangeToneMapper - converts HDR RGB to 16 debug colors - /// representing pixel exposure levels. Useful for validating scene lighting. static Future displayRange(FilamentApp app) async { final pointer = await withPointerCallback( (cb) => ToneMapper_createDisplayRangeRenderThread(app.engine, cb), @@ -95,6 +68,10 @@ class FFIToneMapper extends ToneMapper { @override Future dispose() async { + if (_disposed) { + return; + } + _disposed = true; await withVoidCallback((requestId, cb) { ToneMapper_destroyRenderThread(_pointer, requestId, cb); }); diff --git a/thermion_dart/lib/src/filament/src/implementation/ffi_view.dart b/thermion_dart/lib/src/filament/src/implementation/ffi_view.dart index ad753694b..b406f6d61 100644 --- a/thermion_dart/lib/src/filament/src/implementation/ffi_view.dart +++ b/thermion_dart/lib/src/filament/src/implementation/ffi_view.dart @@ -37,13 +37,6 @@ class FFIView extends View> { Future destroy() async { _onPickResultHolder.dispose(); - - await withVoidCallback((requestId, cb) => View_setColorGradingRenderThread(view, nullptr, requestId, cb)); - if (_colorGrading != null && _colorGrading != nullptr) { - await withVoidCallback( - (requestId, cb) => Engine_destroyColorGradingRenderThread(_app.engine, _colorGrading!, requestId, cb), - ); - } await withVoidCallback((requestId, cb) => Engine_destroyViewRenderThread(_app.engine, view, requestId, cb)); } @@ -130,27 +123,6 @@ class FFIView extends View> { }); } - Pointer? _colorGrading; - - @override - Future setToneMapper(ToneMapper mapper) async { - final colorGrading = await withPointerCallback( - (cb) => ColorGrading_createRenderThread(_app.engine, mapper.getNativeHandle(), cb), - ); - if (colorGrading == nullptr) { - throw Exception("Failed to create color grading"); - } - - await withVoidCallback((requestId, cb) => View_setColorGradingRenderThread(view, colorGrading, requestId, cb)); - - if (_colorGrading != null) { - await withVoidCallback( - (requestId, cb) => Engine_destroyColorGradingRenderThread(_app.engine, _colorGrading!, requestId, cb), - ); - } - _colorGrading = colorGrading; - } - @override Future createColorGradingBuilder() async { final builderPtr = await withPointerCallback( @@ -164,15 +136,13 @@ class FFIView extends View> { @override Future setColorGrading(ColorGrading? colorGrading) async { - if (colorGrading == null) { - // Clear color grading by setting nullptr - await withVoidCallback((requestId, cb) => View_setColorGradingRenderThread(view, nullptr, requestId, cb)); - } else { - // Set color grading with provided object - await withVoidCallback( - (requestId, cb) => View_setColorGradingRenderThread(view, colorGrading.getNativeHandle(), requestId, cb), - ); - } + // Non-owning, like Filament's View::setColorGrading: the caller remains + // responsible for destroying the grading (after dissociating it from all + // views) - see [View.setColorGrading]. + await withVoidCallback( + (requestId, cb) => + View_setColorGradingRenderThread(view, colorGrading?.getNativeHandle() ?? nullptr, requestId, cb), + ); } @override diff --git a/thermion_dart/lib/src/filament/src/interface/filament_app.dart b/thermion_dart/lib/src/filament/src/interface/filament_app.dart index 3f767fc96..6f9cd0b07 100644 --- a/thermion_dart/lib/src/filament/src/interface/filament_app.dart +++ b/thermion_dart/lib/src/filament/src/interface/filament_app.dart @@ -353,9 +353,6 @@ abstract class FilamentApp { String? resourceUri, }); - // - Future createColorGrading(ToneMapper mapper); - // Future createGizmo(View view, GizmoType type); diff --git a/thermion_dart/lib/src/filament/src/interface/tone_mapper.dart b/thermion_dart/lib/src/filament/src/interface/tone_mapper.dart index 6b27e59d4..910c02bff 100644 --- a/thermion_dart/lib/src/filament/src/interface/tone_mapper.dart +++ b/thermion_dart/lib/src/filament/src/interface/tone_mapper.dart @@ -103,6 +103,12 @@ abstract class ToneMapper extends NativeHandle { return FFIToneMapper.displayRange(app); } - /// Destroy the tone mapper and free its resources + /// Destroys the tone mapper and frees its native resources. Idempotent. + /// + /// A ColorGradingBuilder that references this mapper re-reads it on every + /// build, so only dispose the mapper after disposing the builder (or after + /// its final build, if you are certain no more builds will run). Every + /// built ColorGrading holds a copy of this mapper's state, so disposing + /// never affects an applied grading. Future dispose(); } diff --git a/thermion_dart/lib/src/filament/src/interface/view.dart b/thermion_dart/lib/src/filament/src/interface/view.dart index 19921e7f5..e4dc21408 100644 --- a/thermion_dart/lib/src/filament/src/interface/view.dart +++ b/thermion_dart/lib/src/filament/src/interface/view.dart @@ -138,28 +138,56 @@ enum QualityLevel { LOW, MEDIUM, HIGH, ULTRA } enum LutFormat { INTEGER, FLOAT } -// ColorGrading object that holds color grading configuration. -// ColorGrading is treated as const -// Created via View.createColorGradingBuilder().build() and applied to a view. -// Will be disposed when View.setColorGrading is called. -abstract class ColorGrading extends NativeHandle {} +/// Immutable color grading configuration. +/// +/// Created via `View.createColorGradingBuilder().build()` and applied to one +/// or more views with `View.setColorGrading()`. +/// +/// Like Filament, ownership is entirely the CALLER's responsibility: +/// `View.setColorGrading` performs no ownership transfer and no reference +/// counting - the view merely holds a non-owning reference. A single +/// ColorGrading may be attached to multiple views simultaneously, but you +/// must dissociate it from every view (via `setColorGrading` with a +/// replacement or null) BEFORE calling [dispose]. Disposing a grading that +/// is still attached to a view leaves that view with a dangling pointer +/// (undefined behaviour on the next render). +abstract class ColorGrading extends NativeHandle { + /// Destroys the underlying native ColorGrading. Idempotent. + /// + /// The caller is responsible for the grading's lifetime: dissociate it + /// from every view first (see [ColorGrading]) - destroying a grading that + /// is still attached to a view is undefined behaviour. + Future dispose(); +} /// /// Builder for creating ColorGrading objects with the full Filament color pipeline. /// /// Usage: /// ```dart -/// final colorGrading = await view.createColorGradingBuilder() -/// .toneMapper(ToneMapper.ACES) +/// final builder = await view.createColorGradingBuilder(); +/// final toneMapper = await ToneMapper.aces(FilamentApp.instance!); +/// final colorGrading = await builder +/// .toneMapper(toneMapper) /// .exposure(1.0) /// .contrast(1.1) /// .saturation(1.05) /// .build(); +/// await builder.dispose(); +/// // safe once the builder is disposed (no further builds will read it): +/// await toneMapper.dispose(); /// await view.setColorGrading(colorGrading); +/// // ...later, once no view uses it: +/// await view.setColorGrading(null); +/// await colorGrading.dispose(); /// ``` /// /// All methods return this builder for method chaining. -/// The builder is consumed after build() and cannot be reused. +/// +/// Like Filament's ColorGrading::Builder, this builder is REUSABLE: [build] +/// may be called any number of times (settings may also be changed between +/// builds), and each call creates an independent ColorGrading. Free the +/// builder itself with [dispose] when you are done building. /// abstract class ColorGradingBuilder { // ============================================================================ @@ -186,8 +214,13 @@ abstract class ColorGradingBuilder { /// Sets the tone mapping operator. /// - /// Default is ACESLegacy. The tone mapper must have a lifecycle that - /// exceeds this method call. + /// Default is ACESLegacy. The builder stores a reference to [mapper] and + /// copies its state each time [build] executes on the render thread, so the + /// mapper must NOT be disposed while this builder is still usable - dispose + /// it only after [dispose]ding the builder (or after your final build if + /// you are certain no more builds will run). Each built ColorGrading holds + /// a copy, never a reference, so an applied grading is never affected by + /// disposing the mapper. ColorGradingBuilder toneMapper(ToneMapper mapper); // ============================================================================ @@ -305,9 +338,23 @@ abstract class ColorGradingBuilder { /// Builds the ColorGrading object. /// - /// The builder is consumed after this call and cannot be reused. - /// The returned ColorGrading must be disposed when no longer needed. + /// Like Filament's ColorGrading::Builder, this does NOT consume the + /// builder: it may be called any number of times, and each call creates an + /// independent ColorGrading owned by the caller (see [ColorGrading] for + /// the caller-managed lifetime). Each build reads the builder's current + /// settings and the current state of its tone mapper. + /// + /// Throws if the build fails on the render thread or the builder has been + /// disposed. Future build(); + + /// Destroys the native builder. + /// + /// Required once you are done building - a builder that is never disposed + /// leaks its native resources. Idempotent; using a disposed builder + /// (building or setting values) throws. Dispose the builder before + /// disposing any tone mapper it references (each build reads the mapper). + Future dispose(); } abstract class View extends NativeHandle { @@ -366,37 +413,46 @@ abstract class View extends NativeHandle { /// /// Example: /// ```dart - /// final colorGrading = await view.createColorGradingBuilder() - /// .toneMapper(ToneMapper.ACES) + /// final builder = await view.createColorGradingBuilder(); + /// final toneMapper = await ToneMapper.aces(FilamentApp.instance!); + /// final colorGrading = await builder + /// .toneMapper(toneMapper) /// .exposure(1.0) /// .contrast(1.1) /// .build(); + /// await builder.dispose(); + /// await toneMapper.dispose(); // safe: builder disposed, grading holds a copy /// await view.setColorGrading(colorGrading); + /// // ...later, once no view uses it: + /// await view.setColorGrading(null); + /// await colorGrading.dispose(); /// ``` Future createColorGradingBuilder(); /// Sets the color grading for this view. /// /// The ColorGrading object must be created via createColorGradingBuilder(). - /// The view does not take ownership - you must dispose the ColorGrading - /// when no longer needed. - /// - /// Pass null to clear any existing color grading from this view. + /// Like Filament's `View::setColorGrading`, this performs NO ownership + /// transfer and NO reference counting - the view holds a non-owning + /// reference and the caller remains responsible for the grading's lifetime + /// (see [ColorGrading]). A grading may be attached to several views at + /// once, but it must be dissociated from every view before being disposed. + /// + /// Pass null to clear any existing color grading from this view (this does + /// NOT destroy the grading - dispose it yourself once no view uses it). Future setColorGrading(ColorGrading? colorGrading); /// Gets the current color grading from this view. /// - /// Returns null if no color grading is currently set. + /// Returns null if no color grading is currently set. The returned object + /// is a non-owning wrapper; the grading's lifetime is the caller's + /// responsibility (see [setColorGrading]). Do not dispose the returned + /// wrapper - dispose the ColorGrading instance you created instead (both + /// wrap the same native object; disposing both would destroy it twice). + /// Note that a view with no grading set still reports Filament's internal + /// default grading here - never dispose that either. Future getColorGrading(); - /// Sets the tone mapper for this view (deprecated). - /// - /// @deprecated Use createColorGradingBuilder().toneMapper(...).build() - /// followed by setColorGrading() instead. This provides access to the - /// full color grading pipeline. - @Deprecated('Use createColorGradingBuilder() instead') - Future setToneMapper(ToneMapper mapper); - Future setTransparentPickingEnabled(bool enabled); Future isTransparentPickingEnabled(); diff --git a/thermion_dart/lib/src/viewer/src/ffi/src/thermion_viewer_ffi.dart b/thermion_dart/lib/src/viewer/src/ffi/src/thermion_viewer_ffi.dart index 7423c6662..44b0cd66d 100644 --- a/thermion_dart/lib/src/viewer/src/ffi/src/thermion_viewer_ffi.dart +++ b/thermion_dart/lib/src/viewer/src/ffi/src/thermion_viewer_ffi.dart @@ -585,12 +585,6 @@ class ThermionViewerFFI extends ThermionViewer { _assets.clear(); } - // - @override - Future setToneMapper(ToneMapper mapper) async { - await view.setToneMapper(mapper); - } - // @override Future setPostProcessing(bool enabled) async { diff --git a/thermion_dart/lib/src/viewer/src/thermion_viewer_base.dart b/thermion_dart/lib/src/viewer/src/thermion_viewer_base.dart index bec96129a..283e23e44 100644 --- a/thermion_dart/lib/src/viewer/src/thermion_viewer_base.dart +++ b/thermion_dart/lib/src/viewer/src/thermion_viewer_base.dart @@ -199,9 +199,6 @@ abstract class ThermionViewer { // this method is complete. Future destroyAssets(); - // Sets the tone mapping (requires postprocessing). - Future setToneMapper(ToneMapper mapper); - // Enable/disable bloom. Future setBloom(bool enabled, double strength); diff --git a/thermion_dart/native/include/c_api/TView.h b/thermion_dart/native/include/c_api/TView.h index e49e952ad..38cebf9a7 100644 --- a/thermion_dart/native/include/c_api/TView.h +++ b/thermion_dart/native/include/c_api/TView.h @@ -74,9 +74,6 @@ EMSCRIPTEN_KEEPALIVE TToneMapper *ToneMapper_createGeneric(TEngine* tEngine, flo EMSCRIPTEN_KEEPALIVE TToneMapper *ToneMapper_createDisplayRange(TEngine* tEngine); EMSCRIPTEN_KEEPALIVE void ToneMapper_destroy(TToneMapper *toneMapper); -// Legacy ColorGrading API (deprecated - use ColorGradingBuilder instead) -EMSCRIPTEN_KEEPALIVE TColorGrading *ColorGrading_create(TEngine* tEngine, TToneMapper *toneMapper); - // ColorGrading Builder API typedef struct TColorGradingBuilder TColorGradingBuilder; diff --git a/thermion_dart/native/include/c_api/ThermionDartRenderThreadApi.h b/thermion_dart/native/include/c_api/ThermionDartRenderThreadApi.h index 413118f22..f1013ccee 100644 --- a/thermion_dart/native/include/c_api/ThermionDartRenderThreadApi.h +++ b/thermion_dart/native/include/c_api/ThermionDartRenderThreadApi.h @@ -114,8 +114,6 @@ namespace thermion EMSCRIPTEN_KEEPALIVE void Material_createWireframeMaterialRenderThread(TEngine *tEngine, void (*onComplete)(TMaterial *)); EMSCRIPTEN_KEEPALIVE void Material_createTranslationAxisMaterialRenderThread(TEngine *tEngine, void (*onComplete)(TMaterial *)); - EMSCRIPTEN_KEEPALIVE void ColorGrading_createRenderThread(TEngine *tEngine, TToneMapper *toneMapper, void (*callback)(TColorGrading *)); - EMSCRIPTEN_KEEPALIVE void ColorGradingBuilder_createRenderThread(void (*onComplete)(TColorGradingBuilder *)); EMSCRIPTEN_KEEPALIVE void ColorGradingBuilder_buildRenderThread(TColorGradingBuilder *tBuilder, TEngine *tEngine, void (*onComplete)(TColorGrading *)); EMSCRIPTEN_KEEPALIVE void ColorGradingBuilder_destroyRenderThread(TColorGradingBuilder *tBuilder, uint32_t requestId, VoidCallback onComplete); diff --git a/thermion_dart/native/src/c_api/TView.cpp b/thermion_dart/native/src/c_api/TView.cpp index b61b86549..4af8b806a 100644 --- a/thermion_dart/native/src/c_api/TView.cpp +++ b/thermion_dart/native/src/c_api/TView.cpp @@ -235,17 +235,6 @@ namespace thermion TRACE("Destroyed ToneMapper"); } - EMSCRIPTEN_KEEPALIVE TColorGrading *ColorGrading_create(TEngine *tEngine, TToneMapper *toneMapper) - { - auto engine = reinterpret_cast(tEngine); - auto tm = reinterpret_cast(toneMapper); - - TRACE("Creating ColorGrading with ToneMapper"); - auto colorGrading = ColorGrading::Builder().toneMapper(tm).build(*engine); - - return reinterpret_cast(colorGrading); - } - // ============================================================================ // ColorGrading Builder API // ============================================================================ diff --git a/thermion_dart/native/src/c_api/ThermionDartRenderThreadApi.cpp b/thermion_dart/native/src/c_api/ThermionDartRenderThreadApi.cpp index ba73e5e09..206b2c452 100644 --- a/thermion_dart/native/src/c_api/ThermionDartRenderThreadApi.cpp +++ b/thermion_dart/native/src/c_api/ThermionDartRenderThreadApi.cpp @@ -1153,19 +1153,6 @@ extern "C" auto fut = rt->addTask(lambda); } - EMSCRIPTEN_KEEPALIVE void ColorGrading_createRenderThread(TEngine *tEngine, TToneMapper *toneMapper, void (*callback)(TColorGrading *)) - { - auto *rt = RT(tEngine); - std::packaged_task lambda( - [=] - { - auto cg = ColorGrading_create(tEngine, toneMapper); - - setOwner(cg, rt); PROXY(callback(cg)); - }); - auto fut = rt->addTask(lambda); - } - EMSCRIPTEN_KEEPALIVE void ColorGradingBuilder_createRenderThread(void (*onComplete)(TColorGradingBuilder *)) { auto *rt = RT(nullptr); diff --git a/thermion_dart/test/color_grading_tests.dart b/thermion_dart/test/color_grading_tests.dart index 6a8dd9f4b..104faacb7 100644 --- a/thermion_dart/test/color_grading_tests.dart +++ b/thermion_dart/test/color_grading_tests.dart @@ -2,7 +2,6 @@ import 'package:logging/logging.dart'; import 'package:test/test.dart'; import 'package:thermion_dart/thermion_dart.dart'; -import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; import 'helpers.dart'; void main() async { @@ -63,11 +62,20 @@ void main() async { final name = toneMappers[i + 1] as String; final toneMapper = await toneMapperFactory(); - // Set the tone mapper directly to the view - await result.viewer.view.setToneMapper(toneMapper); + // Apply the tone mapper via the color grading builder + final builder = await result.viewer.view.createColorGradingBuilder(); + final colorGrading = await builder.toneMapper(toneMapper).build(); + // done building: dispose the builder, then the mapper it referenced + await builder.dispose(); + await toneMapper.dispose(); + await result.viewer.view.setColorGrading(colorGrading); // Capture the viewport after changing tone mapper await testHelper.capture(result.viewer.view, "tone_mapper_$name"); + + // The grading is caller-owned: dissociate, then dispose + await result.viewer.view.setColorGrading(null); + await colorGrading.dispose(); } }); }); @@ -87,6 +95,7 @@ void main() async { .execute((result) async { // Create a complex color grading using the builder final builder = await result.viewer.view.createColorGradingBuilder(); + final toneMapper = await ToneMapper.aces(FilamentApp.instance!); final colorGrading = await builder .quality(QualityLevel.HIGH) .exposure(0.7) // Slight exposure adjustment @@ -96,7 +105,7 @@ void main() async { .saturation(0.95) // Slight desaturation .luminanceScaling(true) // Better HDR handling .gamutMapping(true) // Prevent hue shifts - .toneMapper(await ToneMapper.aces(FilamentApp.instance!)) // Add a tone mapper + .toneMapper(toneMapper) // Add a tone mapper .shadowsMidtonesHighlights( Vector4(0.8, 0.9, 1.0, 0.5), // Slightly cool shadows Vector4(1.0, 1.0, 1.0, 1.0), // Neutral midtones @@ -107,11 +116,29 @@ void main() async { expect(colorGrading, isNotNull); + // The builder is reusable (Filament's pattern): change a setting + // and build again to get an independent grading. + final brighterColorGrading = await builder.exposure(2.0).build(); + + // Done building: dispose the builder, then the mapper it referenced + await builder.dispose(); + await toneMapper.dispose(); + // Apply the color grading to the view await result.viewer.view.setColorGrading(colorGrading); // Capture the viewport after applying color grading await testHelper.capture(result.viewer.view, "color_grading_builder_applied"); + + // Attaching the second grading dissociates the first, so the first + // can now be disposed (caller-owned lifecycle) + await result.viewer.view.setColorGrading(brighterColorGrading); + await colorGrading.dispose(); + await testHelper.capture(result.viewer.view, "color_grading_builder_rebuilt_brighter"); + + // Dissociate, then dispose the second grading + await result.viewer.view.setColorGrading(null); + await brighterColorGrading.dispose(); }); }); @@ -139,17 +166,25 @@ void main() async { for (final (format, dim, name) in configs) { final builder = await result.viewer.view.createColorGradingBuilder(); + final toneMapper = await ToneMapper.aces(FilamentApp.instance!); final colorGrading = await builder .format(format) .dimensions(dim) - .toneMapper(await ToneMapper.aces(FilamentApp.instance!)) + .toneMapper(toneMapper) .saturation(1.3) .contrast(1.2) .build(); + // done building: dispose the builder, then the mapper it referenced + await builder.dispose(); + await toneMapper.dispose(); expect(colorGrading, isNotNull); await result.viewer.view.setColorGrading(colorGrading); await testHelper.capture(result.viewer.view, "color_grading_lut_$name"); + + // The grading is caller-owned: dissociate, then dispose + await result.viewer.view.setColorGrading(null); + await colorGrading.dispose(); } }); }); @@ -177,18 +212,98 @@ void main() async { for (final (outRed, outGreen, outBlue, name) in configs) { final builder = await result.viewer.view.createColorGradingBuilder(); - final colorGrading = await builder - .toneMapper(await ToneMapper.linear(FilamentApp.instance!)) - .channelMixer(outRed, outGreen, outBlue) - .build(); + final toneMapper = await ToneMapper.linear(FilamentApp.instance!); + final colorGrading = await builder.toneMapper(toneMapper).channelMixer(outRed, outGreen, outBlue).build(); + // done building: dispose the builder, then the mapper it referenced + await builder.dispose(); + await toneMapper.dispose(); expect(colorGrading, isNotNull); await result.viewer.view.setColorGrading(colorGrading); await testHelper.capture(result.viewer.view, "color_grading_channel_mixer_$name"); + + // The grading is caller-owned: dissociate, then dispose + await result.viewer.view.setColorGrading(null); + await colorGrading.dispose(); } }); }); + test('ColorGrading shared between views', () async { + await ViewerBuilder(testHelper).setPostProcessing(true).addCube(color: kWhite, createUbershader: true).execute(( + result, + ) async { + final app = FilamentApp.instance!; + final view1 = result.viewer.view; + final view2 = await app.createView(); + await view2.setViewport(512, 512); + try { + final builder = await view1.createColorGradingBuilder(); + final toneMapper = await ToneMapper.aces(FilamentApp.instance!); + final shared = await builder.toneMapper(toneMapper).saturation(2.0).build(); + await builder.dispose(); + await toneMapper.dispose(); + + // One grading, two views - Filament allows this. Lifetime is the + // caller's responsibility: neither view destroys or releases it. + await view1.setColorGrading(shared); + await view2.setColorGrading(shared); + await testHelper.capture(view2, "color_grading_shared_view2"); + + // Detaching view1 leaves the grading untouched (view2 still uses + // it) - the caller, not the view, decides when it is destroyed + await view1.setColorGrading(null); + await testHelper.capture(view2, "color_grading_shared_view2_after_detach"); + + // Dissociate from the last view, then dispose + await view2.setColorGrading(null); + await shared.dispose(); + } finally { + await app.destroyView(view2); + } + }); + }); + + test('ColorGrading dispose is idempotent', () async { + await ViewerBuilder(testHelper).setPostProcessing(true).addCube(color: kWhite, createUbershader: true).execute(( + result, + ) async { + final view = result.viewer.view; + final builder = await view.createColorGradingBuilder(); + final toneMapper = await ToneMapper.aces(FilamentApp.instance!); + final colorGrading = await builder.toneMapper(toneMapper).build(); + await builder.dispose(); + await toneMapper.dispose(); + + // Dissociate, then dispose - twice; the second is a no-op + await view.setColorGrading(null); + await colorGrading.dispose(); + await colorGrading.dispose(); + }); + }); + + test('ColorGrading builder can be disposed without building', () async { + await ViewerBuilder(testHelper).setPostProcessing(true).addCube(color: kWhite, createUbershader: true).execute(( + result, + ) async { + final builder = await result.viewer.view.createColorGradingBuilder(); + await builder.exposure(1.5); + + // Abandoning a builder without building must not leak: dispose it. + await builder.dispose(); + // A second dispose is a no-op + await builder.dispose(); + // Using a disposed builder throws + expect(() => builder.exposure(1.0), throwsStateError); + await expectLater(builder.build(), throwsStateError); + + // Tone mapper dispose is also idempotent + final toneMapper = await ToneMapper.linear(FilamentApp.instance!); + await toneMapper.dispose(); + await toneMapper.dispose(); + }); + }); + test('getColorGrading - retrieve and verify color grading', () async { await ViewerBuilder(testHelper) .setCameraLookAt(Vector3(0, 2, 8), focus: Vector3.zero()) @@ -205,24 +320,30 @@ void main() async { // Setting the color grading to null will clear the color grading await result.viewer.view.setColorGrading(null); // but internally, View resets this to a "default" color grading, - // so this will be non-null + // so this will be non-null. This is a non-owning wrapper around + // Filament's internal default - never dispose it. var initialColorGrading = await result.viewer.view.getColorGrading(); expect(initialColorGrading, isNotNull); // Create and apply a color grading final builder = await result.viewer.view.createColorGradingBuilder(); + final toneMapper = await ToneMapper.aces(FilamentApp.instance!); final colorGrading = await builder .quality(QualityLevel.HIGH) .exposure(0.5) .contrast(1.2) .saturation(1.1) - .toneMapper(await ToneMapper.aces(FilamentApp.instance!)) + .toneMapper(toneMapper) .build(); + await builder.dispose(); + await toneMapper.dispose(); // Apply the color grading to the view await result.viewer.view.setColorGrading(colorGrading); - // Retrieve the color grading from the view + // Retrieve the color grading from the view. This wraps the same + // native grading as [colorGrading] - a non-owning view of it, not a + // separate object to dispose. var retrievedColorGrading = await result.viewer.view.getColorGrading(); expect(retrievedColorGrading, isNotNull); expect(retrievedColorGrading, isA()); @@ -230,8 +351,10 @@ void main() async { // Capture the viewport with color grading applied await testHelper.capture(result.viewer.view, "color_grading_get_test"); - // Clear the color grading by passing null + // Clear the color grading by passing null. This only dissociates - + // it does NOT destroy the grading, so dispose it explicitly. await result.viewer.view.setColorGrading(null); + await colorGrading.dispose(); // Capture the viewport with color grading cleared await testHelper.capture(result.viewer.view, "color_grading_cleared"); diff --git a/thermion_dart/test/destructor_tests.dart b/thermion_dart/test/destructor_tests.dart index ce2203607..826f19c56 100644 --- a/thermion_dart/test/destructor_tests.dart +++ b/thermion_dart/test/destructor_tests.dart @@ -10,6 +10,7 @@ void main() async { await testHelper.setup(); final viewer = (await testHelper.createViewer()).$1; await viewer.dispose(); + await testHelper.disposeColorGradings(); await FilamentApp.instance!.destroy(); await testHelper.setup(); }); diff --git a/thermion_dart/test/helpers.dart b/thermion_dart/test/helpers.dart index a4c5c0725..9e67510f2 100644 --- a/thermion_dart/test/helpers.dart +++ b/thermion_dart/test/helpers.dart @@ -65,6 +65,10 @@ class TestHelper { late String testDir; late String assetsDir; + /// ColorGradings attached by [createViewer]; caller-owned (like Filament), + /// so [disposeColorGradings] must run once the viewers are destroyed. + final List _colorGradings = []; + TestHelper(String? subDir) { final packageUri = findPackageRoot('thermion_dart').toFilePath(); assetsDir = p.normalize(p.join(packageUri, '..', 'examples', 'assets')); @@ -325,10 +329,20 @@ class TestHelper { await viewer.setPostProcessing(postProcessing); - await viewer.setToneMapper(await ToneMapper.aces(FilamentApp.instance!)); + _colorGradings.add(await applyToneMapper(viewer.view, await ToneMapper.aces(FilamentApp.instance!))); return (viewer, swapChain); } + /// Disposes the ColorGradings attached by [createViewer]. Call after the + /// viewer is destroyed - destroying the view dissociates them, and a + /// grading must not be disposed while still attached to a view. + Future disposeColorGradings() async { + for (final grading in _colorGradings) { + await grading.dispose(); + } + _colorGradings.clear(); + } + Future withViewer( Future Function(ThermionViewer viewer) fn, { img.Color? bg, @@ -351,6 +365,7 @@ class TestHelper { await fn.call(viewer.$1); await viewer.$1.dispose(); + await disposeColorGradings(); await FilamentApp.instance!.destroySwapChain(viewer.$2); } } @@ -399,6 +414,20 @@ class _PlaneConfig { }); } +/// Applies [mapper] as the tone mapper on [view] via the color grading +/// builder API and returns the attached ColorGrading. Disposes the builder +/// and [mapper] (each build reads the mapper, so the builder goes first). +/// The grading is caller-owned (like Filament): dissociate it from the view +/// (`setColorGrading(null)`) and dispose it when done. +Future applyToneMapper(View view, ToneMapper mapper) async { + final builder = await view.createColorGradingBuilder(); + final colorGrading = await builder.toneMapper(mapper).build(); + await builder.dispose(); + await mapper.dispose(); + await view.setColorGrading(colorGrading); + return colorGrading; +} + /// Result class containing all components created by ViewerBuilder class ViewerBuildResult { final ThermionViewer viewer; @@ -423,6 +452,10 @@ class ViewerBuilder { ShadowType? _shadowType; final List _directLights = []; ToneMapper? _toneMapper; + + /// ColorGradings attached during [buildWithAssets]; caller-owned (like + /// Filament), disposed in [execute] after the viewer is destroyed. + final List _colorGradings = []; late TestHelper _testHelper; // Store cube and plane configurations @@ -629,7 +662,7 @@ class ViewerBuilder { // Apply tone mapping if specified if (_toneMapper != null) { - viewer.setToneMapper(_toneMapper!); + _colorGradings.add(await applyToneMapper(viewer.view, _toneMapper!)); } // Add direct lights and store their entities @@ -758,6 +791,10 @@ class ViewerBuilder { await fn.call(viewerBuildResult); } finally { await buildResult.viewer.dispose(); + for (final grading in _colorGradings) { + await grading.dispose(); + } + _colorGradings.clear(); await FilamentApp.instance!.destroySwapChain(buildResult.swapChain); } } diff --git a/thermion_dart/test/unlit_material_tests.dart b/thermion_dart/test/unlit_material_tests.dart index 0f2269e11..e328f28f9 100644 --- a/thermion_dart/test/unlit_material_tests.dart +++ b/thermion_dart/test/unlit_material_tests.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'package:thermion_dart/thermion_dart.dart'; -import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; import 'package:test/test.dart'; import 'helpers.dart'; @@ -40,7 +39,7 @@ void main() async { test('unlit + baseColorFactor', () async { await testHelper.withViewer((viewer) async { await viewer.setPostProcessing(true); - await viewer.setToneMapper(await ToneMapper.linear(FilamentApp.instance!)); + await applyToneMapper(viewer.view, await ToneMapper.linear(FilamentApp.instance!)); var materialInstance = await FilamentApp.instance!.createUnlitMaterialInstance(); var cube = await viewer.createGeometry( @@ -192,7 +191,7 @@ void main() async { test('unlit material with color + alpha', () async { await testHelper.withViewer((viewer) async { await viewer.setPostProcessing(true); - await viewer.setToneMapper(await ToneMapper.linear(FilamentApp.instance!)); + await applyToneMapper(viewer.view, await ToneMapper.linear(FilamentApp.instance!)); var materialInstance = await FilamentApp.instance!.createUnlitMaterialInstance(); var cube = await viewer.createGeometry( @@ -217,7 +216,7 @@ void main() async { await viewer.setCameraPosition(0, 0, 6); await viewer.setBackgroundColor(1.0, 0.0, 0.0, 1.0); await viewer.setPostProcessing(true); - await viewer.setToneMapper(await ToneMapper.linear(FilamentApp.instance!)); + await applyToneMapper(viewer.view, await ToneMapper.linear(FilamentApp.instance!)); var materialInstance = await viewer.createUnlitFixedSizeMaterialInstance(); var cube = await viewer.createGeometry( From e87873cbbb9cae90a98303c617784bb41d5be119 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 04:46:58 +0000 Subject: [PATCH 05/16] chore: update web.version --- thermion_dart/native/web/web.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thermion_dart/native/web/web.version b/thermion_dart/native/web/web.version index d5f9e7ba8..2b1b876fa 100644 --- a/thermion_dart/native/web/web.version +++ b/thermion_dart/native/web/web.version @@ -1 +1 @@ -7d72355 +e81a6c6 From db9f8b471395709e1650636ddb499b24f727cdb9 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 12:52:44 +0800 Subject: [PATCH 06/16] CI: update golden image baseline (#270) From 7975fe21dbb8b53fec178f82758b43d9e548610c Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 13:16:39 +0800 Subject: [PATCH 07/16] ci(linux): smoke test quickstart on X11 and Wayland --- .github/workflows/run-flutter-builds.yml | 60 ++++++++++++++++++- .../display_server_smoke_test.dart | 60 +++++++++++++++++++ examples/flutter/quickstart/lib/main.dart | 7 +++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 examples/flutter/quickstart/integration_test/display_server_smoke_test.dart diff --git a/.github/workflows/run-flutter-builds.yml b/.github/workflows/run-flutter-builds.yml index bcd64586a..4bf362d9e 100644 --- a/.github/workflows/run-flutter-builds.yml +++ b/.github/workflows/run-flutter-builds.yml @@ -110,7 +110,7 @@ jobs: shell: bash run: | sudo apt-get update -y - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libdrm-dev libegl1 libegl1-mesa-dev libc++-dev libc++abi-dev + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libdrm-dev libegl1 libegl1-mesa-dev libgl1-mesa-dri libc++-dev libc++abi-dev xvfb weston # Same priming as the Windows/macOS unit test steps above: fires the # build hooks so the Filament headers are extracted and @@ -128,6 +128,61 @@ jobs: if: matrix.name == 'Linux' run: cd quickstart && flutter pub get && flutter build linux + - name: Run quickstart viewer smoke test (X11) + if: matrix.name == 'Linux' + shell: bash + run: | + set -o pipefail + GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \ + timeout 8m xvfb-run -a \ + -e "${RUNNER_TEMP}/xvfb.log" \ + -s '-screen 0 1280x720x24' \ + flutter test integration_test/display_server_smoke_test.dart \ + -d linux 2>&1 | tee "${RUNNER_TEMP}/display-smoke-x11.log" + working-directory: examples/flutter/quickstart + + - name: Run quickstart viewer smoke test (Wayland) + if: matrix.name == 'Linux' + shell: bash + run: | + set -o pipefail + export XDG_RUNTIME_DIR="${RUNNER_TEMP}/wayland-runtime" + export WAYLAND_DISPLAY=wayland-ci + export LIBGL_ALWAYS_SOFTWARE=1 + mkdir -p "${XDG_RUNTIME_DIR}" + chmod 700 "${XDG_RUNTIME_DIR}" + + weston \ + --backend=headless-backend.so \ + --use-gl \ + --no-config \ + --socket="${WAYLAND_DISPLAY}" \ + --width=1280 \ + --height=720 \ + --idle-time=0 \ + --log="${RUNNER_TEMP}/weston.log" & + weston_pid=$! + trap 'kill "${weston_pid}" 2>/dev/null || true' EXIT + + for _ in {1..50}; do + if [[ -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ]]; then + break + fi + if ! kill -0 "${weston_pid}" 2>/dev/null; then + cat "${RUNNER_TEMP}/weston.log" + exit 1 + fi + sleep 0.1 + done + test -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" + + unset DISPLAY + GDK_BACKEND=wayland \ + timeout 8m flutter test \ + integration_test/display_server_smoke_test.dart \ + -d linux 2>&1 | tee "${RUNNER_TEMP}/display-smoke-wayland.log" + working-directory: examples/flutter/quickstart + - name: Build picking (Linux) if: matrix.name == 'Linux' run: cd picking && flutter pub get && flutter build linux @@ -139,6 +194,9 @@ jobs: name: build-logs-flutter-${{ matrix.name }} path: | ${{ github.workspace }}/thermion_dart/.dart_tool/thermion_dart/log/build.log + ${{ runner.temp }}/display-smoke-*.log + ${{ runner.temp }}/weston.log + ${{ runner.temp }}/xvfb.log retention-days: 5 android-examples: diff --git a/examples/flutter/quickstart/integration_test/display_server_smoke_test.dart b/examples/flutter/quickstart/integration_test/display_server_smoke_test.dart new file mode 100644 index 000000000..4be271ea9 --- /dev/null +++ b/examples/flutter/quickstart/integration_test/display_server_smoke_test.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:quickstart/main.dart'; +import 'package:thermion_flutter/thermion_flutter.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'adds, initializes, renders, and removes a viewer', + (tester) async { + await tester.pumpWidget(const MyApp()); + expect(find.text('No viewers mounted'), findsOneWidget); + + // Exercise the real quickstart interaction instead of mounting a + // ViewerWidget directly in the test. + await tester.tap(find.text('Add')); + await tester.pump(); + expect(find.byType(ViewerWidget), findsOneWidget); + + await _pumpUntil(tester, find.byKey(const ValueKey('viewer-ready-1'))); + + // Keep the native render loop alive for several frames after the viewer + // callback. Startup-only success is not enough: the EGL transport must + // remain usable once Flutter begins consuming frames. + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(tester.takeException(), isNull); + + await tester.tap(find.text('Remove')); + await _pumpUntil(tester, find.byType(ViewerWidget), present: false); + expect(tester.takeException(), isNull); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} + +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + bool present = true, + Duration timeout = const Duration(seconds: 90), +}) async { + final stopwatch = Stopwatch()..start(); + while ((finder.evaluate().isNotEmpty != present) && + stopwatch.elapsed < timeout) { + await tester.pump(const Duration(milliseconds: 16)); + } + if (finder.evaluate().isNotEmpty != present) { + throw TimeoutException( + 'Timed out waiting for ${finder.describeMatch(Plurality.one)} to be ' + '${present ? 'present' : 'absent'}', + timeout, + ); + } +} diff --git a/examples/flutter/quickstart/lib/main.dart b/examples/flutter/quickstart/lib/main.dart index bc276c842..01d6065bb 100644 --- a/examples/flutter/quickstart/lib/main.dart +++ b/examples/flutter/quickstart/lib/main.dart @@ -706,6 +706,13 @@ class _ViewerTileState extends State<_ViewerTile> { ), ), ), + // Exposes completion of the real ViewerWidget initialization to + // native integration tests without changing the visible UI. + if (_viewer != null) + KeyedSubtree( + key: ValueKey('viewer-ready-${widget.index}'), + child: const SizedBox.shrink(), + ), // Top scrim so overlay controls stay legible on bright skyboxes. const Positioned( left: 0, From 4d0fba2bac06f3af99732ec424a80ee0454b4990 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 13:30:45 +0800 Subject: [PATCH 08/16] fix(linux): capture Flutter GL version during bootstrap --- .../thermion_flutter/linux/egl_texture.cc | 24 +++++++++++++++++-- .../thermion_flutter/linux/egl_texture.h | 3 +++ .../linux/thermion_flutter_plugin.cc | 18 +++++++------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/thermion_flutter/thermion_flutter/linux/egl_texture.cc b/thermion_flutter/thermion_flutter/linux/egl_texture.cc index c0c5db7a6..5cb0465b2 100644 --- a/thermion_flutter/thermion_flutter/linux/egl_texture.cc +++ b/thermion_flutter/thermion_flutter/linux/egl_texture.cc @@ -2,6 +2,7 @@ #include "Log.hpp" #include +#include #include #include #include @@ -13,6 +14,9 @@ // Flutter's render context, captured during the first deferred populate(). EGLContext thermion_flutter_render_context = EGL_NO_CONTEXT; EGLDisplay thermion_flutter_render_display = EGL_NO_DISPLAY; +EGLenum thermion_flutter_render_api = EGL_NONE; +EGLint thermion_flutter_render_gl_major = 0; +EGLint thermion_flutter_render_gl_minor = 0; // EGL function pointers (resolved at runtime) static PFNEGLCREATEIMAGEKHRPROC s_eglCreateImageKHR = nullptr; @@ -131,9 +135,25 @@ thermion_texture_populate(FlTextureGL *texture, // This is the ONLY place where Flutter's render context is current. thermion_flutter_render_context = flutterContext; thermion_flutter_render_display = flutterDisplay; - TRACE( "[DirectPop] Captured Flutter render context=%p display=%p\n", + thermion_flutter_render_api = eglQueryAPI(); + + const char* version = reinterpret_cast( + glGetString(GL_VERSION)); + if (version) { + if (std::sscanf(version, "OpenGL ES %d.%d", + &thermion_flutter_render_gl_major, + &thermion_flutter_render_gl_minor) != 2) { + std::sscanf(version, "%d.%d", + &thermion_flutter_render_gl_major, + &thermion_flutter_render_gl_minor); + } + } + TRACE( "[DirectPop] Captured Flutter render context=%p display=%p API=0x%x version=%d.%d\n", (void*)thermion_flutter_render_context, - (void*)thermion_flutter_render_display); + (void*)thermion_flutter_render_display, + thermion_flutter_render_api, + thermion_flutter_render_gl_major, + thermion_flutter_render_gl_minor); // Do not resolve awaitTextureReady from inside populate(). Dart // may immediately initialize another EGL client API when the diff --git a/thermion_flutter/thermion_flutter/linux/egl_texture.h b/thermion_flutter/thermion_flutter/linux/egl_texture.h index b9a282121..27dcf45d4 100644 --- a/thermion_flutter/thermion_flutter/linux/egl_texture.h +++ b/thermion_flutter/thermion_flutter/linux/egl_texture.h @@ -94,5 +94,8 @@ FLUTTER_PLUGIN_EXPORT void thermion_texture_gl_destroy(ThermionTextureGL* textur // pathway on Flutter's actual EGLDisplay. extern EGLContext thermion_flutter_render_context; extern EGLDisplay thermion_flutter_render_display; +extern EGLenum thermion_flutter_render_api; +extern EGLint thermion_flutter_render_gl_major; +extern EGLint thermion_flutter_render_gl_minor; #endif diff --git a/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc b/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc index 88fee72c4..6fb29511b 100644 --- a/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc +++ b/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc @@ -176,6 +176,9 @@ static void destroy_all_contexts(ThermionFlutterPlugin *self) self->use_direct_opengl = FALSE; thermion_flutter_render_context = EGL_NO_CONTEXT; thermion_flutter_render_display = EGL_NO_DISPLAY; + thermion_flutter_render_api = EGL_NONE; + thermion_flutter_render_gl_major = 0; + thermion_flutter_render_gl_minor = 0; self->backend_type = 0; } @@ -213,16 +216,13 @@ static bool ensure_opengl_context(ThermionFlutterPlugin *self) return false; } - EGLint clientType = 0; - EGLint glMajor = 0; - EGLint glMinor = 0; + EGLenum clientType = thermion_flutter_render_api; + EGLint glMajor = thermion_flutter_render_gl_major; + EGLint glMinor = thermion_flutter_render_gl_minor; EGLint configId = 0; - if (!eglQueryContext( - flutterDpy, flutterCtx, EGL_CONTEXT_CLIENT_TYPE, &clientType) || - !eglQueryContext( - flutterDpy, flutterCtx, EGL_CONTEXT_MAJOR_VERSION_KHR, &glMajor) || - !eglQueryContext( - flutterDpy, flutterCtx, EGL_CONTEXT_MINOR_VERSION_KHR, &glMinor) || + if ((clientType != EGL_OPENGL_API && + clientType != EGL_OPENGL_ES_API) || + glMajor <= 0 || !eglQueryContext(flutterDpy, flutterCtx, EGL_CONFIG_ID, &configId)) { std::cerr << "[ThermionGL] Could not query Flutter's EGL context: 0x" From 46737d13aff10061d3e6fbc413584c560528438c Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 13:42:16 +0800 Subject: [PATCH 09/16] ci(linux): expose llvmpipe OpenGL 4.5 --- .github/workflows/run-flutter-builds.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/run-flutter-builds.yml b/.github/workflows/run-flutter-builds.yml index 4bf362d9e..8874e77f2 100644 --- a/.github/workflows/run-flutter-builds.yml +++ b/.github/workflows/run-flutter-builds.yml @@ -134,6 +134,7 @@ jobs: run: | set -o pipefail GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \ + MESA_GL_VERSION_OVERRIDE=4.5 \ timeout 8m xvfb-run -a \ -e "${RUNNER_TEMP}/xvfb.log" \ -s '-screen 0 1280x720x24' \ @@ -149,6 +150,7 @@ jobs: export XDG_RUNTIME_DIR="${RUNNER_TEMP}/wayland-runtime" export WAYLAND_DISPLAY=wayland-ci export LIBGL_ALWAYS_SOFTWARE=1 + export MESA_GL_VERSION_OVERRIDE=4.5 mkdir -p "${XDG_RUNTIME_DIR}" chmod 700 "${XDG_RUNTIME_DIR}" From 97040e73d5b85d97c3461ab97b445483629674e9 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 14:57:32 +0800 Subject: [PATCH 10/16] ci(linux): run display smoke on GPU runner --- .github/workflows/linux-display-smoke.yml | 126 ++++++++++++++++++++++ .github/workflows/run-flutter-builds.yml | 62 +---------- 2 files changed, 127 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/linux-display-smoke.yml diff --git a/.github/workflows/linux-display-smoke.yml b/.github/workflows/linux-display-smoke.yml new file mode 100644 index 000000000..1026d2803 --- /dev/null +++ b/.github/workflows/linux-display-smoke.yml @@ -0,0 +1,126 @@ +name: Linux Display Smoke + +on: + workflow_dispatch: + inputs: + ref: + description: Git ref to test + required: false + type: string + x11-display: + description: X11 display exposed to the runner + required: false + default: ':0' + type: string + +jobs: + quickstart: + name: Quickstart viewer (X11 + Wayland) + runs-on: [self-hosted, linux, x64] + timeout-minutes: 30 + env: + DISPLAY: ${{ inputs.x11-display }} + defaults: + run: + working-directory: examples/flutter/quickstart + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.8' + channel: stable + architecture: X64 + cache: true + pub-cache: true + + - name: Install Linux dependencies + working-directory: . + run: | + sudo apt-get update -y + sudo apt-get install -y \ + clang cmake ninja-build pkg-config \ + libgtk-3-dev liblzma-dev libdrm-dev \ + libegl1 libegl1-mesa-dev libc++-dev libc++abi-dev \ + weston x11-utils + + # Prime generated headers before the example's CMake configure step. + - name: Run thermion_flutter unit tests + working-directory: thermion_flutter/thermion_flutter + run: | + flutter pub get + flutter test + + - name: Build quickstart + run: | + flutter pub get + flutter build linux + + - name: Verify GPU and X11 session + run: | + if [[ ! -r /dev/dri/renderD128 || ! -w /dev/dri/renderD128 ]]; then + echo '::error::The runner user needs read/write access to /dev/dri/renderD128' + exit 1 + fi + if ! xdpyinfo >/dev/null; then + echo "::error::Start the runner from the logged-in X11 session or provide a usable x11-display input (currently ${DISPLAY})" + exit 1 + fi + + - name: Run viewer smoke test (X11) + run: | + set -o pipefail + GDK_BACKEND=x11 timeout 8m flutter test \ + integration_test/display_server_smoke_test.dart \ + -d linux 2>&1 | tee "${RUNNER_TEMP}/display-smoke-x11.log" + + - name: Run viewer smoke test (Wayland) + run: | + set -o pipefail + export XDG_RUNTIME_DIR="${RUNNER_TEMP}/wayland-runtime" + export WAYLAND_DISPLAY=wayland-ci + mkdir -p "${XDG_RUNTIME_DIR}" + chmod 700 "${XDG_RUNTIME_DIR}" + + weston \ + --backend=headless-backend.so \ + --use-gl \ + --no-config \ + --socket="${WAYLAND_DISPLAY}" \ + --width=1280 \ + --height=720 \ + --idle-time=0 \ + --log="${RUNNER_TEMP}/weston.log" & + weston_pid=$! + trap 'kill "${weston_pid}" 2>/dev/null || true' EXIT + + for _ in {1..50}; do + if [[ -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ]]; then + break + fi + if ! kill -0 "${weston_pid}" 2>/dev/null; then + cat "${RUNNER_TEMP}/weston.log" + exit 1 + fi + sleep 0.1 + done + test -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" + + unset DISPLAY + GDK_BACKEND=wayland timeout 8m flutter test \ + integration_test/display_server_smoke_test.dart \ + -d linux 2>&1 | tee "${RUNNER_TEMP}/display-smoke-wayland.log" + + - name: Upload display-server logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: linux-display-smoke-logs + path: | + ${{ runner.temp }}/display-smoke-*.log + ${{ runner.temp }}/weston.log + retention-days: 5 diff --git a/.github/workflows/run-flutter-builds.yml b/.github/workflows/run-flutter-builds.yml index 8874e77f2..bcd64586a 100644 --- a/.github/workflows/run-flutter-builds.yml +++ b/.github/workflows/run-flutter-builds.yml @@ -110,7 +110,7 @@ jobs: shell: bash run: | sudo apt-get update -y - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libdrm-dev libegl1 libegl1-mesa-dev libgl1-mesa-dri libc++-dev libc++abi-dev xvfb weston + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libdrm-dev libegl1 libegl1-mesa-dev libc++-dev libc++abi-dev # Same priming as the Windows/macOS unit test steps above: fires the # build hooks so the Filament headers are extracted and @@ -128,63 +128,6 @@ jobs: if: matrix.name == 'Linux' run: cd quickstart && flutter pub get && flutter build linux - - name: Run quickstart viewer smoke test (X11) - if: matrix.name == 'Linux' - shell: bash - run: | - set -o pipefail - GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \ - MESA_GL_VERSION_OVERRIDE=4.5 \ - timeout 8m xvfb-run -a \ - -e "${RUNNER_TEMP}/xvfb.log" \ - -s '-screen 0 1280x720x24' \ - flutter test integration_test/display_server_smoke_test.dart \ - -d linux 2>&1 | tee "${RUNNER_TEMP}/display-smoke-x11.log" - working-directory: examples/flutter/quickstart - - - name: Run quickstart viewer smoke test (Wayland) - if: matrix.name == 'Linux' - shell: bash - run: | - set -o pipefail - export XDG_RUNTIME_DIR="${RUNNER_TEMP}/wayland-runtime" - export WAYLAND_DISPLAY=wayland-ci - export LIBGL_ALWAYS_SOFTWARE=1 - export MESA_GL_VERSION_OVERRIDE=4.5 - mkdir -p "${XDG_RUNTIME_DIR}" - chmod 700 "${XDG_RUNTIME_DIR}" - - weston \ - --backend=headless-backend.so \ - --use-gl \ - --no-config \ - --socket="${WAYLAND_DISPLAY}" \ - --width=1280 \ - --height=720 \ - --idle-time=0 \ - --log="${RUNNER_TEMP}/weston.log" & - weston_pid=$! - trap 'kill "${weston_pid}" 2>/dev/null || true' EXIT - - for _ in {1..50}; do - if [[ -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ]]; then - break - fi - if ! kill -0 "${weston_pid}" 2>/dev/null; then - cat "${RUNNER_TEMP}/weston.log" - exit 1 - fi - sleep 0.1 - done - test -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" - - unset DISPLAY - GDK_BACKEND=wayland \ - timeout 8m flutter test \ - integration_test/display_server_smoke_test.dart \ - -d linux 2>&1 | tee "${RUNNER_TEMP}/display-smoke-wayland.log" - working-directory: examples/flutter/quickstart - - name: Build picking (Linux) if: matrix.name == 'Linux' run: cd picking && flutter pub get && flutter build linux @@ -196,9 +139,6 @@ jobs: name: build-logs-flutter-${{ matrix.name }} path: | ${{ github.workspace }}/thermion_dart/.dart_tool/thermion_dart/log/build.log - ${{ runner.temp }}/display-smoke-*.log - ${{ runner.temp }}/weston.log - ${{ runner.temp }}/xvfb.log retention-days: 5 android-examples: From 4d850a06b3f1580a79ca088a35e59c8e7c72b559 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 15:24:10 +0800 Subject: [PATCH 11/16] ci(linux): make display smoke runnable on the Fedora GPU runner - TEMPORARY push trigger on this branch while under test on the self-hosted runner; remove before merging. - Fall back to :0 when the x11-display input is absent (push events have no inputs context, which would otherwise blank DISPLAY). - Distro-aware dependency step: keep apt-get for Ubuntu, add a dnf path for Fedora that no-ops when deps are preinstalled and sudo needs a password. Co-Authored-By: Claude --- .github/workflows/linux-display-smoke.yml | 36 ++++++++++++++++++----- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/linux-display-smoke.yml b/.github/workflows/linux-display-smoke.yml index 1026d2803..f72cae524 100644 --- a/.github/workflows/linux-display-smoke.yml +++ b/.github/workflows/linux-display-smoke.yml @@ -1,6 +1,10 @@ name: Linux Display Smoke on: + # TEMPORARY: auto-run on pushes to this branch while it is under test on the + # self-hosted GPU runner. Remove before merging. + push: + branches: [fix/linux-egl-context-bootstrap] workflow_dispatch: inputs: ref: @@ -19,7 +23,9 @@ jobs: runs-on: [self-hosted, linux, x64] timeout-minutes: 30 env: - DISPLAY: ${{ inputs.x11-display }} + # Push events have no inputs context; fall back to :0 so the runner's + # desktop session display is used. + DISPLAY: ${{ inputs.x11-display || ':0' }} defaults: run: working-directory: examples/flutter/quickstart @@ -41,12 +47,28 @@ jobs: - name: Install Linux dependencies working-directory: . run: | - sudo apt-get update -y - sudo apt-get install -y \ - clang cmake ninja-build pkg-config \ - libgtk-3-dev liblzma-dev libdrm-dev \ - libegl1 libegl1-mesa-dev libc++-dev libc++abi-dev \ - weston x11-utils + if command -v apt-get >/dev/null; then + sudo apt-get update -y + sudo apt-get install -y \ + clang cmake ninja-build pkg-config \ + libgtk-3-dev liblzma-dev libdrm-dev \ + libegl1 libegl1-mesa-dev libc++-dev libc++abi-dev \ + weston x11-utils + elif command -v dnf >/dev/null; then + # Fedora self-hosted runner. Dependencies are preinstalled on the + # image; only fetch missing ones, and only with passwordless sudo. + if ! sudo -n true 2>/dev/null; then + echo '::notice::No passwordless sudo; assuming dependencies are preinstalled' + exit 0 + fi + sudo dnf install -y \ + clang cmake ninja-build pkgconf-pkg-config \ + gtk3-devel libdrm-devel mesa-libEGL-devel xz-devel \ + libcxx-devel libcxxabi-devel \ + weston xdpyinfo + else + echo '::warning::No apt-get or dnf found; assuming dependencies are preinstalled' + fi # Prime generated headers before the example's CMake configure step. - name: Run thermion_flutter unit tests From ee0909f08cfdfd4d15dd7753e58f9d5431e0425f Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 21:14:06 +0800 Subject: [PATCH 12/16] feat: align skybox API with Filament headers, return Skybox from creation (#271) Expose Skybox on the public API and add related methodssetLayerMask/getLayerMask, getIntensity, getTexture. Also adds showSun/intensity args during creation. ThermionViewer.loadSkybox now returns Future. --- .github/workflows/run-dart-tests.yml | 104 +++++++--- CHANGELOG.md | 9 + examples/dart/cli_headless/bin/example.dart | 4 +- .../dart/cli_windows/bin/cli_windows.dart | 5 +- .../lib/src/materials_and_lighting.dart | 5 +- .../examples_lib/lib/src/scene_effects.dart | 5 +- examples/flutter/viewer/lib/main.dart | 12 +- .../src/bindings/src/thermion_dart_ffi.g.dart | 59 +++++- .../src/thermion_dart_js_interop.g.dart | 109 +++++++++- thermion_dart/lib/src/filament/filament.dart | 1 + .../implementation/edge_detection_view.dart | 1 - .../src/implementation/ffi_filament_app.dart | 22 +- .../src/implementation/ffi_scene.dart | 12 +- .../src/implementation/ffi_skybox.dart | 26 ++- .../src/implementation/silhouette_view.dart | 1 - .../filament/src/interface/filament_app.dart | 25 ++- .../lib/src/filament/src/interface/scene.dart | 8 +- .../src/filament/src/interface/skybox.dart | 24 +++ .../src/ffi/src/thermion_viewer_ffi.dart | 62 +++--- .../src/viewer/src/thermion_viewer_base.dart | 28 ++- thermion_dart/native/include/c_api/TEngine.h | 8 +- thermion_dart/native/include/c_api/TScene.h | 1 + thermion_dart/native/include/c_api/TSkybox.h | 20 ++ .../c_api/ThermionDartRenderThreadApi.h | 4 +- thermion_dart/native/src/c_api/TEngine.cpp | 40 +++- thermion_dart/native/src/c_api/TScene.cpp | 5 + thermion_dart/native/src/c_api/TSkybox.cpp | 25 ++- .../src/c_api/ThermionDartRenderThreadApi.cpp | 8 +- thermion_dart/test/asset_tests.dart | 10 +- thermion_dart/test/compare_goldens.py | 69 +++++-- thermion_dart/test/helpers.dart | 8 +- thermion_dart/test/image_tests.dart | 6 +- thermion_dart/test/light_tests.dart | 6 +- thermion_dart/test/postprocessing_tests.dart | 3 +- thermion_dart/test/skybox_tests.dart | 189 +++++++++++++++++- .../lib/src/widgets/src/viewer_widget.dart | 36 +++- 36 files changed, 809 insertions(+), 151 deletions(-) diff --git a/.github/workflows/run-dart-tests.yml b/.github/workflows/run-dart-tests.yml index 84ce2267b..d0b7176d0 100644 --- a/.github/workflows/run-dart-tests.yml +++ b/.github/workflows/run-dart-tests.yml @@ -7,6 +7,7 @@ on: description: Git ref (SHA or branch) to check out required: false type: string + workflow_dispatch: inputs: ref: @@ -108,11 +109,13 @@ jobs: test/all_axes_test.dart \ test/bone_picking_tests.dart \ test/all_materials_smoke_test.dart \ + test/skybox_tests.dart \ --concurrency=1 - name: Run tests (Windows) if: matrix.name == 'Windows' working-directory: thermion_dart + shell: pwsh env: VK_ICD_FILENAMES: C:\swiftshader\vk_swiftshader_icd.json run: | @@ -134,41 +137,14 @@ jobs: test/gizmo_tests_new.dart ` test/all_axes_test.dart ` test/all_materials_smoke_test.dart ` + test/skybox_tests.dart ` --concurrency=1 - - name: Zip output - if: matrix.name == 'Linux' - run: zip -r output.zip ./thermion_dart/test/output - - - name: Upload test output - if: matrix.name == 'Linux' + - name: Upload Dart test output candidate uses: actions/upload-artifact@v4 with: - name: golden-images-${{ github.sha }} - path: output.zip - - - name: Download golden images from previous run - if: matrix.name == 'Linux' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh run download 32373595042 \ - --name golden-images-7d723559f07771f8bb83c85824fcb2516af22940 \ - --dir ./thermion_dart/test/golden-downloads - - name: Unzip golden images - if: matrix.name == 'Linux' - run: | - cd thermion_dart/test/golden-downloads && unzip output.zip - - - name: Install Python dependencies - if: matrix.name == 'Linux' - run: | - python -m pip install --upgrade pip - pip install Pillow numpy - - - name: Compare golden images - if: matrix.name == 'Linux' - run: cd thermion_dart/test && python compare_goldens.py + name: dart-test-output-${{ matrix.name }} + path: thermion_dart/test/output - name: Upload logs if: failure() @@ -243,8 +219,15 @@ jobs: test/overlay_tests.dart \ test/wireframe_renderable_test.dart \ test/gizmo_tests_new.dart \ + test/skybox_tests.dart \ --concurrency=1 + - name: Upload Dart test output candidate + uses: actions/upload-artifact@v4 + with: + name: dart-test-output-macOS + path: thermion_dart/test/output + - name: Upload logs if: failure() uses: actions/upload-artifact@v4 @@ -352,6 +335,12 @@ jobs: VK_ICD_FILENAMES: C:\swiftshader\vk_swiftshader_icd.json run: dart pub get; dart test -j1 test/image_tests.dart test/overlay_tests.dart test/wireframe_renderable_test.dart test/gizmo_tests_new.dart test/all_axes_test.dart test/all_materials_smoke_test.dart --concurrency=1 + - name: Upload material-variant test output candidate + uses: actions/upload-artifact@v4 + with: + name: dart-material-output-${{ matrix.name }} + path: thermion_dart/test/output + - name: Upload logs if: failure() uses: actions/upload-artifact@v4 @@ -360,3 +349,56 @@ jobs: path: | ${{ github.workspace }}/thermion_dart/.dart_tool/thermion_dart/log/build.log retention-days: 5 + + compare-dart-test-output: + name: Compare Dart Test Output (${{ matrix.name }}) + needs: + - dart-tests + - dart-tests-macos + - dart-tests-material-variants + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + include: + - name: Linux + artifact: dart-test-output-Linux + - name: Windows + artifact: dart-test-output-Windows + - name: macOS + artifact: dart-test-output-macOS + - name: Linux opengl-only + artifact: dart-material-output-Linux opengl-only + - name: Windows vulkan-only + artifact: dart-material-output-Windows vulkan-only + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.head_ref || github.ref }} + + - name: Download current test output + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: thermion_dart/test/golden-candidate + + - name: Download reviewed platform baseline + uses: actions/download-artifact@v4 + with: + github-token: ${{ github.token }} + # To refresh every baseline, first push a capture-only commit, + # inspect its dart-*-output-* artifacts, then update this run ID. + run-id: 32477413084 + name: ${{ matrix.artifact }} + path: thermion_dart/test/golden-baseline + + - name: Install golden comparison dependencies + run: | + python -m pip install --upgrade pip + python -m pip install Pillow numpy + + - name: Compare all captured test output + run: >- + python thermion_dart/test/compare_goldens.py + --golden-dir thermion_dart/test/golden-baseline + --output-dir thermion_dart/test/golden-candidate diff --git a/CHANGELOG.md b/CHANGELOG.md index 8972aa97a..9eaed6e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ remains supported, but its lifetime is entirely yours. - `ColorGrading` and `ColorGradingBuilder` expose `dispose()` through the public interface (previously FFI-only / missing). +- `Skybox` is now exported from the public API with methods for layer mask, intensity, etc. +- `ThermionViewer.setBackgroundColor` and `ThermionViewer.loadSkybox` now + return the created `Skybox`, and + `ThermionViewer.getSkybox` returns whatever skybox is currently attached + to the viewer's scene. The viewer no longer caches the skybox internally: + `removeSkybox` derives it from the scene, detaches it, and returns it without + destroying caller-owned resources. + Native library rebuild required (C API signatures for + the skybox builders changed). ### Breaking changes - remove the unused `FilamentApp.createColorGrading` — it returned a raw diff --git a/examples/dart/cli_headless/bin/example.dart b/examples/dart/cli_headless/bin/example.dart index 317378460..771c43411 100644 --- a/examples/dart/cli_headless/bin/example.dart +++ b/examples/dart/cli_headless/bin/example.dart @@ -14,7 +14,9 @@ void main() async { await FilamentApp.instance!.register(sc, viewer.view); await viewer.view.setFrustumCullingEnabled(false); - await viewer.setBackgroundColor(1, 0, 1, 1); + await (await viewer.view.getScene()).setSkybox( + await FilamentApp.instance!.createColoredSkybox(r: 1, g: 0, b: 1, a: 1), + ); await viewer.setViewport(width, height); final result = await FilamentApp.instance!.capture( sc, diff --git a/examples/dart/cli_windows/bin/cli_windows.dart b/examples/dart/cli_windows/bin/cli_windows.dart index 513e1f11e..27fa7f88d 100644 --- a/examples/dart/cli_windows/bin/cli_windows.dart +++ b/examples/dart/cli_windows/bin/cli_windows.dart @@ -25,7 +25,10 @@ void main(List arguments) async { await camera.setLensProjection(); await FilamentApp.instance!.renderManager.attach(view, swapChain); - await viewer.setBackgroundColor(1.0, 0.0, 0.0, 1.0); + await (await viewer.view.getScene()).setSkybox( + await FilamentApp.instance! + .createColoredSkybox(r: 1.0, g: 0.0, b: 0.0, a: 1.0), + ); var skyboxPath = File("../../assets/default_env_skybox.ktx").absolute; await viewer.loadSkybox( diff --git a/examples/dart/examples_lib/lib/src/materials_and_lighting.dart b/examples/dart/examples_lib/lib/src/materials_and_lighting.dart index 146b5d5dd..5b3aaf82e 100644 --- a/examples/dart/examples_lib/lib/src/materials_and_lighting.dart +++ b/examples/dart/examples_lib/lib/src/materials_and_lighting.dart @@ -15,7 +15,10 @@ Future setupMaterialsAndLighting( final camera = await viewer.getActiveCamera(); await camera.lookAt(Vector3(0, 4.0, 11), focus: Vector3(0, 0, 0)); - await viewer.setBackgroundColor(0.18, 0.18, 0.18, 1.0); + await (await viewer.view.getScene()).setSkybox( + await FilamentApp.instance! + .createColoredSkybox(r: 0.18, g: 0.18, b: 0.18, a: 1.0), + ); // Dimmed IBL so the three coloured point lights read clearly against the // ambient fill — at higher intensities the image-based lighting washes out // their orbiting contribution. diff --git a/examples/dart/examples_lib/lib/src/scene_effects.dart b/examples/dart/examples_lib/lib/src/scene_effects.dart index 67d45bae7..f34f37cb6 100644 --- a/examples/dart/examples_lib/lib/src/scene_effects.dart +++ b/examples/dart/examples_lib/lib/src/scene_effects.dart @@ -14,7 +14,10 @@ Future setupEffects( final camera = await viewer.getActiveCamera(); await camera.lookAt(Vector3(0, 2.5, 7), focus: Vector3(0, 0.5, 0)); - await viewer.setBackgroundColor(0.025, 0.03, 0.04, 1.0); + await (await viewer.view.getScene()).setSkybox( + await FilamentApp.instance! + .createColoredSkybox(r: 0.025, g: 0.03, b: 0.04, a: 1.0), + ); await viewer.loadIbl("$assetsDir/default_env_ibl.ktx"); await viewer.addDirectLight(DirectLight.sun(direction: Vector3(0, -1, -0.5))); await viewer.addDirectLight( diff --git a/examples/flutter/viewer/lib/main.dart b/examples/flutter/viewer/lib/main.dart index bfc305aa2..d2382d805 100644 --- a/examples/flutter/viewer/lib/main.dart +++ b/examples/flutter/viewer/lib/main.dart @@ -63,12 +63,13 @@ class _MyHomePageState extends State { // instead, use file:// URIs. // Setting preserveGeometry: true rebuilds vertex buffers with a superset // of attributes, enabling free material swapping (wireframe, solid, etc). - var asset = await _thermionViewer!.loadGltf( - "assets/FlightHelmet/FlightHelmet.gltf"); + var asset = await _thermionViewer! + .loadGltf("assets/FlightHelmet/FlightHelmet.gltf"); await _thermionViewer!.addToScene(asset); - var wireframe = await FilamentApp.instance!.createWireframeMaterialInstance(); + var wireframe = + await FilamentApp.instance!.createWireframeMaterialInstance(); await wireframe.setEdgeColor(0.3, 0.3, 0.3, 1.0); await wireframe.setFaceColor(0.1, 0.1, 0.1, 1.0); await wireframe.setEdgeWidth(0.5); @@ -125,8 +126,9 @@ class _MyHomePageState extends State { child: ElevatedButton( onPressed: () async { final rnd = Random(); - await _thermionViewer!.removeSkybox(); - await _thermionViewer!.setBackgroundColor( + // Mutate the existing skybox in place - no teardown needed. + final skybox = await _thermionViewer!.getSkybox(); + await skybox?.setColor( rnd.nextDouble(), rnd.nextDouble(), rnd.nextDouble(), 1.0); }, child: const Text("Randomize background color"))); diff --git a/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart b/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart index 45d5b2028..1a77152c7 100644 --- a/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart +++ b/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart @@ -1185,18 +1185,38 @@ external void Engine_destroyMaterialInstance( @ffi.Native Function(ffi.Pointer)>(isLeaf: true) external ffi.Pointer Engine_createScene(ffi.Pointer tEngine); -@ffi.Native Function(ffi.Pointer, ffi.Pointer)>(isLeaf: true) -external ffi.Pointer Engine_buildSkybox(ffi.Pointer tEngine, ffi.Pointer tTexture); - -@ffi.Native Function(ffi.Pointer, ffi.Float, ffi.Float, ffi.Float, ffi.Float)>( +@ffi.Native Function(ffi.Pointer, ffi.Pointer, ffi.Bool, ffi.Float, ffi.Uint8)>( isLeaf: true, ) +external ffi.Pointer Engine_buildSkybox( + ffi.Pointer tEngine, + ffi.Pointer tTexture, + bool showSun, + double intensity, + int priority, +); + +@ffi.Native< + ffi.Pointer Function( + ffi.Pointer, + ffi.Float, + ffi.Float, + ffi.Float, + ffi.Float, + ffi.Bool, + ffi.Float, + ffi.Uint8, + ) +>(isLeaf: true) external ffi.Pointer Engine_buildColoredSkybox( ffi.Pointer tEngine, double r, double g, double b, double a, + bool showSun, + double intensity, + int priority, ); @ffi.Native< @@ -2088,12 +2108,18 @@ external void Engine_executeRenderThread(ffi.Pointer tEngine, int reque ffi.Void Function( ffi.Pointer, ffi.Pointer, + ffi.Bool, + ffi.Float, + ffi.Uint8, ffi.Pointer)>>, ) >(isLeaf: true) external void Engine_buildSkyboxRenderThread( ffi.Pointer tEngine, ffi.Pointer tTexture, + bool showSun, + double intensity, + int priority, ffi.Pointer)>> onComplete, ); @@ -2104,6 +2130,9 @@ external void Engine_buildSkyboxRenderThread( ffi.Float, ffi.Float, ffi.Float, + ffi.Bool, + ffi.Float, + ffi.Uint8, ffi.Pointer)>>, ) >(isLeaf: true) @@ -2113,6 +2142,9 @@ external void Engine_buildColoredSkyboxRenderThread( double g, double b, double a, + bool showSun, + double intensity, + int priority, ffi.Pointer)>> onComplete, ); @@ -3691,6 +3723,22 @@ external void RenderTarget_destroy(ffi.Pointer tEngine, ffi.Pointer, ffi.Double, ffi.Double, ffi.Double, ffi.Double)>(isLeaf: true) external void Skybox_setColor(ffi.Pointer tSkybox, double r, double g, double b, double a); +/// Sets bits in a visibility mask (see filament::Skybox::setLayerMask). +@ffi.Native, ffi.Uint8, ffi.Uint8)>(isLeaf: true) +external void Skybox_setLayerMask(ffi.Pointer tSkybox, int select, int values); + +/// Returns the visibility mask bits. +@ffi.Native)>(isLeaf: true) +external int Skybox_getLayerMask(ffi.Pointer tSkybox); + +/// Returns the skybox intensity in lux. +@ffi.Native)>(isLeaf: true) +external double Skybox_getIntensity(ffi.Pointer tSkybox); + +/// Returns the environment texture, or nullptr for a color-only skybox. +@ffi.Native Function(ffi.Pointer)>(isLeaf: true) +external ffi.Pointer Skybox_getTexture(ffi.Pointer tSkybox); + @ffi.Native, EntityId)>(isLeaf: true) external void RenderableManager_destroyEntity(ffi.Pointer tRenderableManager, int entityId); @@ -4485,6 +4533,9 @@ external void Scene_removeEntity(ffi.Pointer tScene, int entityId); @ffi.Native, ffi.Pointer)>(isLeaf: true) external void Scene_setSkybox(ffi.Pointer tScene, ffi.Pointer skybox); +@ffi.Native Function(ffi.Pointer)>(isLeaf: true) +external ffi.Pointer Scene_getSkybox(ffi.Pointer tScene); + @ffi.Native, ffi.Pointer)>(isLeaf: true) external void Scene_setIndirectLight(ffi.Pointer tScene, ffi.Pointer tIndirectLight); diff --git a/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart b/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart index a91ff31ae..4da89636c 100644 --- a/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart +++ b/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart @@ -521,13 +521,22 @@ extension type GeneratedBindings(NativeLibrary _) implements JSObject { external void _Engine_destroyMaterial(Pointer tEngine, Pointer tMaterial); external void _Engine_destroyMaterialInstance(Pointer tEngine, Pointer tMaterialInstance); external Pointer _Engine_createScene(Pointer tEngine); - external Pointer _Engine_buildSkybox(Pointer tEngine, Pointer tTexture); + external Pointer _Engine_buildSkybox( + Pointer tEngine, + Pointer tTexture, + bool showSun, + double intensity, + int priority, + ); external Pointer _Engine_buildColoredSkybox( Pointer tEngine, double r, double g, double b, double a, + bool showSun, + double intensity, + int priority, ); external Pointer _Engine_buildIndirectLightFromIrradianceTexture( Pointer tEngine, @@ -1021,6 +1030,9 @@ extension type GeneratedBindings(NativeLibrary _) implements JSObject { external void _Engine_buildSkyboxRenderThread( Pointer tEngine, Pointer tTexture, + bool showSun, + double intensity, + int priority, Pointer)>> onComplete, ); external void _Engine_buildColoredSkyboxRenderThread( @@ -1029,6 +1041,9 @@ extension type GeneratedBindings(NativeLibrary _) implements JSObject { double g, double b, double a, + bool showSun, + double intensity, + int priority, Pointer)>> onComplete, ); external void _Engine_buildIndirectLightFromIrradianceTextureRenderThread( @@ -1874,6 +1889,18 @@ extension type GeneratedBindings(NativeLibrary _) implements JSObject { ); external void _RenderTarget_destroy(Pointer tEngine, Pointer tRenderTarget); external void _Skybox_setColor(Pointer tSkybox, double r, double g, double b, double a); + + /// Sets bits in a visibility mask (see filament::Skybox::setLayerMask). + external void _Skybox_setLayerMask(Pointer tSkybox, int select, int values); + + /// Returns the visibility mask bits. + external int _Skybox_getLayerMask(Pointer tSkybox); + + /// Returns the skybox intensity in lux. + external double _Skybox_getIntensity(Pointer tSkybox); + + /// Returns the environment texture, or nullptr for a color-only skybox. + external Pointer _Skybox_getTexture(Pointer tSkybox); external void _RenderableManager_destroyEntity(Pointer tRenderableManager, EntityId entityId); external int _RenderableManager_hasComponent(Pointer tRenderableManager, EntityId entityId); external int _RenderableManager_empty(Pointer tRenderableManager); @@ -2319,6 +2346,7 @@ extension type GeneratedBindings(NativeLibrary _) implements JSObject { external void _Scene_addEntity(Pointer tScene, EntityId entityId); external void _Scene_removeEntity(Pointer tScene, EntityId entityId); external void _Scene_setSkybox(Pointer tScene, Pointer skybox); + external Pointer _Scene_getSkybox(Pointer tScene); external void _Scene_setIndirectLight(Pointer tScene, Pointer tIndirectLight); external void _Scene_addFilamentAsset(Pointer tScene, Pointer asset); external void _FrameScheduler_start(FrameCallback callback, int targetFps); @@ -4040,13 +4068,43 @@ Pointer Engine_createScene(Pointer tEngine) { return Pointer(result); } -Pointer Engine_buildSkybox(Pointer tEngine, Pointer tTexture) { - final result = GeneratedBindings.instance._Engine_buildSkybox(tEngine.cast(), tTexture.cast()); +Pointer Engine_buildSkybox( + Pointer tEngine, + Pointer tTexture, + bool showSun, + double intensity, + int priority, +) { + final result = GeneratedBindings.instance._Engine_buildSkybox( + tEngine.cast(), + tTexture.cast(), + showSun, + intensity, + priority, + ); return Pointer(result); } -Pointer Engine_buildColoredSkybox(Pointer tEngine, double r, double g, double b, double a) { - final result = GeneratedBindings.instance._Engine_buildColoredSkybox(tEngine.cast(), r, g, b, a); +Pointer Engine_buildColoredSkybox( + Pointer tEngine, + double r, + double g, + double b, + double a, + bool showSun, + double intensity, + int priority, +) { + final result = GeneratedBindings.instance._Engine_buildColoredSkybox( + tEngine.cast(), + r, + g, + b, + a, + showSun, + intensity, + priority, + ); return Pointer(result); } @@ -5289,11 +5347,17 @@ void Engine_executeRenderThread(Pointer tEngine, int requestId, DartVoi void Engine_buildSkyboxRenderThread( Pointer tEngine, Pointer tTexture, + bool showSun, + double intensity, + int priority, Pointer)>> onComplete, ) { final result = GeneratedBindings.instance._Engine_buildSkyboxRenderThread( tEngine.cast(), tTexture.cast(), + showSun, + intensity, + priority, onComplete.cast(), ); return result; @@ -5305,6 +5369,9 @@ void Engine_buildColoredSkyboxRenderThread( double g, double b, double a, + bool showSun, + double intensity, + int priority, Pointer)>> onComplete, ) { final result = GeneratedBindings.instance._Engine_buildColoredSkyboxRenderThread( @@ -5313,6 +5380,9 @@ void Engine_buildColoredSkyboxRenderThread( g, b, a, + showSun, + intensity, + priority, onComplete.cast(), ); return result; @@ -7344,6 +7414,30 @@ void Skybox_setColor(Pointer tSkybox, double r, double g, double b, dou return result; } +/// Sets bits in a visibility mask (see filament::Skybox::setLayerMask). +void Skybox_setLayerMask(Pointer tSkybox, int select, int values) { + final result = GeneratedBindings.instance._Skybox_setLayerMask(tSkybox.cast(), select, values); + return result; +} + +/// Returns the visibility mask bits. +int Skybox_getLayerMask(Pointer tSkybox) { + final result = GeneratedBindings.instance._Skybox_getLayerMask(tSkybox.cast()); + return result; +} + +/// Returns the skybox intensity in lux. +double Skybox_getIntensity(Pointer tSkybox) { + final result = GeneratedBindings.instance._Skybox_getIntensity(tSkybox.cast()); + return result; +} + +/// Returns the environment texture, or nullptr for a color-only skybox. +Pointer Skybox_getTexture(Pointer tSkybox) { + final result = GeneratedBindings.instance._Skybox_getTexture(tSkybox.cast()); + return Pointer(result); +} + void RenderableManager_destroyEntity(Pointer tRenderableManager, DartEntityId entityId) { final result = GeneratedBindings.instance._RenderableManager_destroyEntity(tRenderableManager.cast(), entityId); return result; @@ -8565,6 +8659,11 @@ void Scene_setSkybox(Pointer tScene, Pointer skybox) { return result; } +Pointer Scene_getSkybox(Pointer tScene) { + final result = GeneratedBindings.instance._Scene_getSkybox(tScene.cast()); + return Pointer(result); +} + void Scene_setIndirectLight(Pointer tScene, Pointer tIndirectLight) { final result = GeneratedBindings.instance._Scene_setIndirectLight(tScene.cast(), tIndirectLight.cast()); return result; diff --git a/thermion_dart/lib/src/filament/filament.dart b/thermion_dart/lib/src/filament/filament.dart index 3d28565d3..01c1178f4 100644 --- a/thermion_dart/lib/src/filament/filament.dart +++ b/thermion_dart/lib/src/filament/filament.dart @@ -1,4 +1,5 @@ export 'src/interface/filament_app.dart'; +export 'src/interface/skybox.dart'; export 'src/interface/engine.dart'; export 'src/interface/layers.dart'; export 'src/interface/light_manager.dart'; diff --git a/thermion_dart/lib/src/filament/src/implementation/edge_detection_view.dart b/thermion_dart/lib/src/filament/src/implementation/edge_detection_view.dart index 2fcd93280..f5f6e89ed 100644 --- a/thermion_dart/lib/src/filament/src/implementation/edge_detection_view.dart +++ b/thermion_dart/lib/src/filament/src/implementation/edge_detection_view.dart @@ -7,7 +7,6 @@ import 'package:thermion_dart/src/filament/src/implementation/ffi_texture.dart'; import 'package:thermion_dart/src/filament/src/implementation/ffi_vertex_buffer.dart'; import 'package:thermion_dart/src/filament/src/implementation/ffi_view.dart'; import 'package:thermion_dart/src/filament/src/interface/scene.dart'; -import 'package:thermion_dart/src/filament/src/interface/skybox.dart'; import 'package:thermion_dart/thermion_dart.dart'; import 'ffi_filament_app.dart'; 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 bd95f6513..81ca9a762 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 @@ -26,7 +26,6 @@ import 'package:thermion_dart/src/filament/src/implementation/ffi_scene.dart'; import 'package:thermion_dart/src/filament/src/implementation/ffi_swapchain.dart'; import 'package:thermion_dart/src/filament/src/implementation/ffi_texture.dart'; import 'package:thermion_dart/src/filament/src/implementation/ffi_view.dart'; -import 'package:thermion_dart/src/filament/src/interface/skybox.dart'; import 'package:thermion_dart/src/filament/src/interface/surface_orientation.dart'; import 'package:thermion_dart/thermion_dart.dart'; import 'package:logging/logging.dart'; @@ -1577,9 +1576,21 @@ class FFIFilamentApp extends FilamentApp { // Builds an (empty) [Skybox] instance. This will not be attached to any scene until // [setSkybox] is called. // - Future buildSkybox({Texture? texture = null}) async { + Future buildSkybox({ + Texture? texture = null, + bool showSun = false, + double? intensity, + int priority = 7, + }) async { final ptr = await withPointerCallback((cb) { - Engine_buildSkyboxRenderThread(engine, (texture as FFITexture?)?.pointer ?? nullptr, cb); + Engine_buildSkyboxRenderThread( + engine, + (texture as FFITexture?)?.pointer ?? nullptr, + showSun, + intensity ?? -1.0, + priority, + cb, + ); }); return FFISkybox(ptr, this); } @@ -1594,9 +1605,12 @@ class FFIFilamentApp extends FilamentApp { required double g, required double b, required double a, + bool showSun = false, + double? intensity, + int priority = 7, }) async { final ptr = await withPointerCallback((cb) { - Engine_buildColoredSkyboxRenderThread(engine, r, g, b, a, cb); + Engine_buildColoredSkyboxRenderThread(engine, r, g, b, a, showSun, intensity ?? -1.0, priority, cb); }); return FFISkybox(ptr, this); } diff --git a/thermion_dart/lib/src/filament/src/implementation/ffi_scene.dart b/thermion_dart/lib/src/filament/src/implementation/ffi_scene.dart index 172f2ec79..77395098e 100644 --- a/thermion_dart/lib/src/filament/src/implementation/ffi_scene.dart +++ b/thermion_dart/lib/src/filament/src/implementation/ffi_scene.dart @@ -1,7 +1,6 @@ import 'package:thermion_dart/src/filament/src/implementation/ffi_indirect_light.dart'; import 'package:thermion_dart/src/filament/src/implementation/ffi_skybox.dart'; import 'package:thermion_dart/src/filament/src/interface/scene.dart'; -import 'package:thermion_dart/src/filament/src/interface/skybox.dart'; import 'package:thermion_dart/thermion_dart.dart'; import 'ffi_filament_app.dart'; @@ -75,6 +74,17 @@ class FFIScene extends Scene> { } } + /// + /// + /// + Future getSkybox() async { + final ptr = Scene_getSkybox(scene); + if (ptr == nullptr) { + return null; + } + return FFISkybox(ptr, _app); + } + /// /// Destroys this scene and releases its resources. /// diff --git a/thermion_dart/lib/src/filament/src/implementation/ffi_skybox.dart b/thermion_dart/lib/src/filament/src/implementation/ffi_skybox.dart index 8785a5296..cd9d385f2 100644 --- a/thermion_dart/lib/src/filament/src/implementation/ffi_skybox.dart +++ b/thermion_dart/lib/src/filament/src/implementation/ffi_skybox.dart @@ -1,5 +1,5 @@ import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; -import 'package:thermion_dart/src/filament/src/interface/skybox.dart'; +import 'package:thermion_dart/src/filament/src/implementation/ffi_texture.dart'; import 'package:thermion_dart/thermion_dart.dart'; class FFISkybox extends Skybox { @@ -14,6 +14,30 @@ class FFISkybox extends Skybox { Skybox_setColor(pointer, r, g, b, a); } + @override + Future setLayerMask(int select, int values) async { + Skybox_setLayerMask(pointer, select, values); + } + + @override + int getLayerMask() { + return Skybox_getLayerMask(pointer); + } + + @override + double getIntensity() { + return Skybox_getIntensity(pointer); + } + + @override + Texture? getTexture() { + final ptr = Skybox_getTexture(pointer); + if (ptr == nullptr) { + return null; + } + return FFITexture(_app.engine, ptr, _app); + } + @override Future destroy() async { await withVoidCallback((requestId, cb) => Engine_destroySkyboxRenderThread(_app.engine, pointer, requestId, cb)); diff --git a/thermion_dart/lib/src/filament/src/implementation/silhouette_view.dart b/thermion_dart/lib/src/filament/src/implementation/silhouette_view.dart index 21900ad74..2f6135c5c 100644 --- a/thermion_dart/lib/src/filament/src/implementation/silhouette_view.dart +++ b/thermion_dart/lib/src/filament/src/implementation/silhouette_view.dart @@ -4,7 +4,6 @@ import 'package:thermion_dart/src/filament/src/implementation/ffi_render_target. import 'package:thermion_dart/src/filament/src/implementation/ffi_scene.dart'; import 'package:thermion_dart/src/filament/src/implementation/ffi_texture.dart'; import 'package:thermion_dart/src/filament/src/implementation/ffi_view.dart'; -import 'package:thermion_dart/src/filament/src/interface/skybox.dart'; import 'package:thermion_dart/thermion_dart.dart'; import 'ffi_filament_app.dart'; diff --git a/thermion_dart/lib/src/filament/src/interface/filament_app.dart b/thermion_dart/lib/src/filament/src/interface/filament_app.dart index 6f9cd0b07..e1ae9066d 100644 --- a/thermion_dart/lib/src/filament/src/interface/filament_app.dart +++ b/thermion_dart/lib/src/filament/src/interface/filament_app.dart @@ -1,7 +1,6 @@ import 'package:thermion_dart/src/filament/src/interface/animation_manager.dart'; import 'package:thermion_dart/src/filament/src/interface/render_manager.dart'; import 'package:thermion_dart/src/filament/src/interface/scene.dart'; -import 'package:thermion_dart/src/filament/src/interface/skybox.dart'; import 'package:thermion_dart/thermion_dart.dart'; class FilamentConfig { @@ -403,14 +402,34 @@ abstract class FilamentApp { // Builds a [Skybox] instance. This will not be attached to any scene until // [setSkybox] is called. // - Future buildSkybox({Texture? texture = null}); + // [showSun] renders the sun (requires a SUN light in the scene; off by + // default). [intensity] scales the skybox texel values to lux/lumen-m^2 + // (Filament's default of 30000 is used when null). [priority] is the + // rendering priority, clamped by Filament to [0..7] (7 = lowest priority, + // rendered last; the default). + // + Future buildSkybox({Texture? texture = null, bool showSun = false, double? intensity, int priority = 7}); // Creates a [Skybox] with a solid color. This will not be attached to any // scene until [setSkybox] is called. // // This is useful for clearing render targets with a specific color // (including fully transparent for overlay passes). - Future createColoredSkybox({required double r, required double g, required double b, required double a}); + // + // [showSun] renders the sun (requires a SUN light in the scene; off by + // default). [intensity] scales the skybox color to lux/lumen-m^2 + // (Filament's default of 30000 is used when null). [priority] is the + // rendering priority, clamped by Filament to [0..7] (7 = lowest priority, + // rendered last; the default). + Future createColoredSkybox({ + required double r, + required double g, + required double b, + required double a, + bool showSun = false, + double? intensity, + int priority = 7, + }); // Future isRenderable(ThermionEntity entity); diff --git a/thermion_dart/lib/src/filament/src/interface/scene.dart b/thermion_dart/lib/src/filament/src/interface/scene.dart index 5f6d7b572..120c41847 100644 --- a/thermion_dart/lib/src/filament/src/interface/scene.dart +++ b/thermion_dart/lib/src/filament/src/interface/scene.dart @@ -1,5 +1,4 @@ import 'package:thermion_dart/src/filament/src/interface/native_handle.dart'; -import 'package:thermion_dart/src/filament/src/interface/skybox.dart'; import 'package:thermion_dart/thermion_dart.dart'; abstract class Scene extends NativeHandle { @@ -43,4 +42,11 @@ abstract class Scene extends NativeHandle { Future setSkybox(Skybox? skybox) { throw UnimplementedError(); } + + /// + /// Returns the skybox currently attached to this scene, or null. + /// + Future getSkybox() { + throw UnimplementedError(); + } } diff --git a/thermion_dart/lib/src/filament/src/interface/skybox.dart b/thermion_dart/lib/src/filament/src/interface/skybox.dart index a59ed9d5c..3b90b942d 100644 --- a/thermion_dart/lib/src/filament/src/interface/skybox.dart +++ b/thermion_dart/lib/src/filament/src/interface/skybox.dart @@ -1,9 +1,33 @@ +import 'package:thermion_dart/thermion_dart.dart'; + abstract class Skybox { /// /// /// Future setColor(double r, double g, double b, double a); + /// + /// Sets bits in a visibility mask (see filament's Skybox::setLayerMask). + /// Use [select] to pick the bits to affect and [values] for their + /// replacement values. + /// + Future setLayerMask(int select, int values); + + /// + /// Returns the visibility mask bits. + /// + int getLayerMask(); + + /// + /// Returns the skybox intensity in lux (lumen/m^2). + /// + double getIntensity(); + + /// + /// Returns the environment texture, or null for a color-only skybox. + /// + Texture? getTexture(); + /// /// /// diff --git a/thermion_dart/lib/src/viewer/src/ffi/src/thermion_viewer_ffi.dart b/thermion_dart/lib/src/viewer/src/ffi/src/thermion_viewer_ffi.dart index 44b0cd66d..c2aee4f7c 100644 --- a/thermion_dart/lib/src/viewer/src/ffi/src/thermion_viewer_ffi.dart +++ b/thermion_dart/lib/src/viewer/src/ffi/src/thermion_viewer_ffi.dart @@ -162,7 +162,7 @@ class ThermionViewerFFI extends ThermionViewer { // Finish any load/remove operation that was accepted before dispose, then // detach and destroy every scene-level resource while the scene is valid. await _sceneResourceOperations; - await _removeSkybox(); + await _removeSkybox(destroy: true); await _removeIbl(destroy: true); await clearBackgroundImage(destroy: true); @@ -247,25 +247,34 @@ class ThermionViewerFFI extends ThermionViewer { return (_backgroundImage!.width!, _backgroundImage!.height!); } - // + /// + /// Returns the skybox currently attached to this viewer's scene, or null. + /// The viewer does not cache the skybox; this always reflects the scene. + /// + @override + Future getSkybox() { + return scene.getSkybox(); + } + @override - Future setBackgroundColor(double r, double g, double b, double a) { + Future setBackgroundColor(double r, double g, double b, double alpha) { _throwIfDisposed(); return _serializeSceneResourceOperation(() async { - await _removeSkybox(); - _skybox = await _app.buildSkybox() as FFISkybox; - await scene.setSkybox(_skybox!); - await _skybox!.setColor(r, g, b, a); + await _removeSkybox(destroy: true); + final skybox = await _app.createColoredSkybox(r: r, g: g, b: b, a: alpha); + await scene.setSkybox(skybox); + return skybox; }); } - Future _loadSkybox(String skyboxPath) async { - await _removeSkybox(); + Future _loadSkybox(String skyboxPath) async { + await _removeSkybox(destroy: true); var data = await _app.loadResource(skyboxPath); final completer = Completer(); FFIKtx1Bundle? bundle; + late FFISkybox skybox; final uploadFuture = withVoidCallback((requestId, onTextureUploadComplete) async { bundle = await FFIKtx1Bundle.create(_app, data) as FFIKtx1Bundle; @@ -277,9 +286,9 @@ class ThermionViewerFFI extends ThermionViewer { ) as FFITexture; - _skybox = await _app.buildSkybox(texture: _skyboxTexture) as FFISkybox; + skybox = await _app.buildSkybox(texture: _skyboxTexture) as FFISkybox; - await scene.setSkybox(_skybox!); + await scene.setSkybox(skybox); completer.complete(); }); @@ -293,11 +302,12 @@ class ThermionViewerFFI extends ThermionViewer { }); _skyboxTextureUploadComplete = trackedUploadFuture; await completer.future; + return skybox; } // @override - Future loadSkybox(String skyboxPath) { + Future loadSkybox(String skyboxPath) { _throwIfDisposed(); return _serializeSceneResourceOperation(() => _loadSkybox(skyboxPath)); } @@ -365,23 +375,30 @@ class ThermionViewerFFI extends ThermionViewer { await scene.setIndirectLight(ibl); } - Future _removeSkybox() async { + Future _removeSkybox({bool destroy = false}) async { final upload = _skyboxTextureUploadComplete; if (upload != null) { await _app.flush(); await upload; } + final skybox = await scene.getSkybox(); await scene.setSkybox(null); - await _skybox?.destroy(); - if (_skybox != null && _skyboxTexture != null) { - // Engine::destroy queues the skybox destruction. Ensure the skybox has - // released its environment texture before destroying that texture. - await _app.flush(); - } - await _skyboxTexture?.destroy(); - _skybox = null; + + final texture = _skyboxTexture; _skyboxTexture = null; + + if (destroy) { + await skybox?.destroy(); + if (skybox != null && texture != null) { + // Engine::destroy queues the skybox destruction. Ensure the skybox has + // released its environment texture before destroying that texture. + await _app.flush(); + } + await texture?.destroy(); + } + + return skybox; } // @@ -392,7 +409,6 @@ class ThermionViewerFFI extends ThermionViewer { Future? _skyboxTextureUploadComplete; FFITexture? _skyboxTexture; - FFISkybox? _skybox; Future? _iblTextureUploadComplete; @@ -434,7 +450,7 @@ class ThermionViewerFFI extends ThermionViewer { // @override - Future removeSkybox() { + Future removeSkybox() { _throwIfDisposed(); return _serializeSceneResourceOperation(_removeSkybox); } diff --git a/thermion_dart/lib/src/viewer/src/thermion_viewer_base.dart b/thermion_dart/lib/src/viewer/src/thermion_viewer_base.dart index 283e23e44..24fa0534f 100644 --- a/thermion_dart/lib/src/viewer/src/thermion_viewer_base.dart +++ b/thermion_dart/lib/src/viewer/src/thermion_viewer_base.dart @@ -67,15 +67,29 @@ abstract class ThermionViewer { // Removes the background image. Future clearBackgroundImage({bool destroy = false}); - // Sets the color for the background plane (positioned at the maximum depth, - // i.e. behind all other objects including the skybox). - Future setBackgroundColor(double r, double g, double b, double alpha); + // Returns the skybox currently attached to this viewer's scene, or null. + // The viewer does not cache the skybox; this always reflects the scene, so + // it also returns skyboxes attached directly via [Scene.setSkybox]. + Future getSkybox(); - // Load a skybox from [skyboxPath] (which must be a .ktx file) - Future loadSkybox(String skyboxPath); + /// Creates a solid-color [Skybox], attaches it to this viewer's scene, and + /// returns it. + /// + /// The viewer does not cache the returned skybox. It remains attached to the + /// scene until it is replaced or detached with [removeSkybox]. + Future setBackgroundColor(double r, double g, double b, double alpha); + + // Load a skybox from [skyboxPath] (which must be a .ktx file). Returns the + // created [Skybox], which may be mutated (e.g. [Skybox.setColor], + // [Skybox.setLayerMask]) or detached via [removeSkybox]. + Future loadSkybox(String skyboxPath); - // Removes the skybox from the scene and destroys all associated resources. - Future removeSkybox(); + /// Detaches and returns the skybox currently attached to the scene. + /// + /// This does not destroy the returned [Skybox]. The caller is responsible + /// for destroying it and, for a texture-backed skybox, its [Skybox.getTexture] + /// after the skybox is no longer needed. + Future removeSkybox(); // Creates an indirect light by loading the reflections/irradiance from the // KTX file. Only one indirect light can be active at any given time; if an diff --git a/thermion_dart/native/include/c_api/TEngine.h b/thermion_dart/native/include/c_api/TEngine.h index 7d4fdfaba..f381668c3 100644 --- a/thermion_dart/native/include/c_api/TEngine.h +++ b/thermion_dart/native/include/c_api/TEngine.h @@ -61,8 +61,12 @@ EMSCRIPTEN_KEEPALIVE TMaterial *Engine_buildMaterial(TEngine *tEngine, const uin EMSCRIPTEN_KEEPALIVE void Engine_destroyMaterial(TEngine *tEngine, TMaterial *tMaterial); EMSCRIPTEN_KEEPALIVE void Engine_destroyMaterialInstance(TEngine *tEngine, TMaterialInstance *tMaterialInstance); EMSCRIPTEN_KEEPALIVE TScene *Engine_createScene(TEngine *tEngine); -EMSCRIPTEN_KEEPALIVE TSkybox *Engine_buildSkybox(TEngine *tEngine, TTexture* tTexture); -EMSCRIPTEN_KEEPALIVE TSkybox *Engine_buildColoredSkybox(TEngine *tEngine, float r, float g, float b, float a); +// Builds a skybox from an environment cubemap. A negative [intensity] leaves +// the filament default (30000) in place. [showSun] requires a SUN light in the +// scene to have any effect. [priority] is clamped by filament to [0..7]; +// 7 (lowest priority, rendered last) is the default. +EMSCRIPTEN_KEEPALIVE TSkybox *Engine_buildSkybox(TEngine *tEngine, TTexture* tTexture, bool showSun, float intensity, uint8_t priority); +EMSCRIPTEN_KEEPALIVE TSkybox *Engine_buildColoredSkybox(TEngine *tEngine, float r, float g, float b, float a, bool showSun, float intensity, uint8_t priority); EMSCRIPTEN_KEEPALIVE TIndirectLight *Engine_buildIndirectLightFromIrradianceTexture(TEngine *tEngine, TTexture *tReflectionsTexture, TTexture* tIrradianceTexture, float intensity); EMSCRIPTEN_KEEPALIVE TIndirectLight *Engine_buildIndirectLightFromIrradianceHarmonics(TEngine *tEngine, TTexture *tReflectionsTexture, float *irradianceHarmonics, float intensity); EMSCRIPTEN_KEEPALIVE void Engine_destroySkybox(TEngine *tEngine, TSkybox *tSkybox); diff --git a/thermion_dart/native/include/c_api/TScene.h b/thermion_dart/native/include/c_api/TScene.h index 04c9ab18c..df0182aa0 100644 --- a/thermion_dart/native/include/c_api/TScene.h +++ b/thermion_dart/native/include/c_api/TScene.h @@ -13,6 +13,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void Scene_addEntity(TScene* tScene, EntityId entityId); EMSCRIPTEN_KEEPALIVE void Scene_removeEntity(TScene* tScene, EntityId entityId); EMSCRIPTEN_KEEPALIVE void Scene_setSkybox(TScene* tScene, TSkybox *skybox); +EMSCRIPTEN_KEEPALIVE TSkybox* Scene_getSkybox(TScene* tScene); EMSCRIPTEN_KEEPALIVE void Scene_setIndirectLight(TScene* tScene, TIndirectLight *tIndirectLight); EMSCRIPTEN_KEEPALIVE void Scene_addFilamentAsset(TScene* tScene, TFilamentAsset *asset); diff --git a/thermion_dart/native/include/c_api/TSkybox.h b/thermion_dart/native/include/c_api/TSkybox.h index cf68c2c82..a5d8ba0a0 100644 --- a/thermion_dart/native/include/c_api/TSkybox.h +++ b/thermion_dart/native/include/c_api/TSkybox.h @@ -10,6 +10,26 @@ extern "C" EMSCRIPTEN_KEEPALIVE void Skybox_setColor(TSkybox* tSkybox, double r, double g, double b, double a); +/// +/// Sets bits in a visibility mask (see filament::Skybox::setLayerMask). +/// +EMSCRIPTEN_KEEPALIVE void Skybox_setLayerMask(TSkybox* tSkybox, uint8_t select, uint8_t values); + +/// +/// Returns the visibility mask bits. +/// +EMSCRIPTEN_KEEPALIVE uint8_t Skybox_getLayerMask(TSkybox* tSkybox); + +/// +/// Returns the skybox intensity in lux. +/// +EMSCRIPTEN_KEEPALIVE float Skybox_getIntensity(TSkybox* tSkybox); + +/// +/// Returns the environment texture, or nullptr for a color-only skybox. +/// +EMSCRIPTEN_KEEPALIVE TTexture* Skybox_getTexture(TSkybox* tSkybox); + #ifdef __cplusplus } #endif diff --git a/thermion_dart/native/include/c_api/ThermionDartRenderThreadApi.h b/thermion_dart/native/include/c_api/ThermionDartRenderThreadApi.h index f1013ccee..72841af7d 100644 --- a/thermion_dart/native/include/c_api/ThermionDartRenderThreadApi.h +++ b/thermion_dart/native/include/c_api/ThermionDartRenderThreadApi.h @@ -84,8 +84,8 @@ namespace thermion EMSCRIPTEN_KEEPALIVE void Engine_destroyFenceRenderThread(TEngine *tEngine, TFence *tFence, uint32_t requestId, VoidCallback onComplete); EMSCRIPTEN_KEEPALIVE void Engine_flushAndWaitRenderThread(TEngine *tEngine, uint32_t requestId, VoidCallback onComplete); EMSCRIPTEN_KEEPALIVE void Engine_executeRenderThread(TEngine *tEngine, uint32_t requestId, VoidCallback onComplete); - EMSCRIPTEN_KEEPALIVE void Engine_buildSkyboxRenderThread(TEngine *tEngine, TTexture *tTexture, void (*onComplete)(TSkybox *)); - EMSCRIPTEN_KEEPALIVE void Engine_buildColoredSkyboxRenderThread(TEngine *tEngine, float r, float g, float b, float a, void (*onComplete)(TSkybox *)); + EMSCRIPTEN_KEEPALIVE void Engine_buildSkyboxRenderThread(TEngine *tEngine, TTexture *tTexture, bool showSun, float intensity, uint8_t priority, void (*onComplete)(TSkybox *)); + EMSCRIPTEN_KEEPALIVE void Engine_buildColoredSkyboxRenderThread(TEngine *tEngine, float r, float g, float b, float a, bool showSun, float intensity, uint8_t priority, void (*onComplete)(TSkybox *)); EMSCRIPTEN_KEEPALIVE void Engine_buildIndirectLightFromIrradianceTextureRenderThread(TEngine *tEngine, TTexture *tReflectionsTexture, TTexture* tIrradianceTexture, float intensity, void (*onComplete)(TIndirectLight *)); EMSCRIPTEN_KEEPALIVE void Engine_buildIndirectLightFromIrradianceHarmonicsRenderThread(TEngine *tEngine, TTexture *tReflectionsTexture, float *harmonics, float intensity, void (*onComplete)(TIndirectLight *)); diff --git a/thermion_dart/native/src/c_api/TEngine.cpp b/thermion_dart/native/src/c_api/TEngine.cpp index 1c17b2a0c..7643845d6 100644 --- a/thermion_dart/native/src/c_api/TEngine.cpp +++ b/thermion_dart/native/src/c_api/TEngine.cpp @@ -360,25 +360,41 @@ namespace thermion return reinterpret_cast(scene); } - EMSCRIPTEN_KEEPALIVE TSkybox *Engine_buildSkybox(TEngine *tEngine, TTexture *tTexture) + EMSCRIPTEN_KEEPALIVE TSkybox *Engine_buildSkybox(TEngine *tEngine, TTexture *tTexture, bool showSun, float intensity, uint8_t priority) { auto *engine = reinterpret_cast(tEngine); auto *texture = reinterpret_cast(tTexture); - auto *skybox = - filament::Skybox::Builder() - .environment(texture) - .build(*engine); + auto skyboxBuilder = filament::Skybox::Builder(); + + if (texture) + { + skyboxBuilder.environment(texture); + } + skyboxBuilder.showSun(showSun); + if (intensity >= 0.0f) + { + skyboxBuilder.intensity(intensity); + } + skyboxBuilder.priority(priority); + + auto *skybox = skyboxBuilder.build(*engine); return reinterpret_cast(skybox); } - EMSCRIPTEN_KEEPALIVE TSkybox *Engine_buildColoredSkybox(TEngine *tEngine, float r, float g, float b, float a) + EMSCRIPTEN_KEEPALIVE TSkybox *Engine_buildColoredSkybox(TEngine *tEngine, float r, float g, float b, float a, bool showSun, float intensity, uint8_t priority) { auto *engine = reinterpret_cast(tEngine); - auto *skybox = filament::Skybox::Builder() + auto skyboxBuilder = filament::Skybox::Builder() .color({r, g, b, a}) - .build(*engine); + .showSun(showSun) + .priority(priority); + if (intensity >= 0.0f) + { + skyboxBuilder.intensity(intensity); + } + auto *skybox = skyboxBuilder.build(*engine); return reinterpret_cast(skybox); } @@ -428,6 +444,14 @@ namespace thermion EMSCRIPTEN_KEEPALIVE void Engine_destroySkybox(TEngine *tEngine, TSkybox *tSkybox) { auto *engine = reinterpret_cast(tEngine); auto *skybox = reinterpret_cast(tSkybox); + // Callers can destroy a caller-attached skybox themselves before + // the scene (or a viewer teardown that derives from the scene) + // releases it. Treat a later explicit release as idempotent, as + // Engine_destroyTexture does. + if (!engine->isValid(skybox)) + { + return; + } if(skybox->getTexture()) { engine->destroy(skybox->getTexture()); } diff --git a/thermion_dart/native/src/c_api/TScene.cpp b/thermion_dart/native/src/c_api/TScene.cpp index 0821a0617..abf9db334 100644 --- a/thermion_dart/native/src/c_api/TScene.cpp +++ b/thermion_dart/native/src/c_api/TScene.cpp @@ -44,6 +44,11 @@ namespace thermion TRACE("Set skybox"); } + EMSCRIPTEN_KEEPALIVE TSkybox* Scene_getSkybox(TScene* tScene) { + auto *scene = reinterpret_cast(tScene); + return reinterpret_cast(scene->getSkybox()); + } + EMSCRIPTEN_KEEPALIVE void Scene_setIndirectLight(TScene* tScene, TIndirectLight *tIndirectLight) { auto *scene = reinterpret_cast(tScene); auto *light = reinterpret_cast(tIndirectLight); diff --git a/thermion_dart/native/src/c_api/TSkybox.cpp b/thermion_dart/native/src/c_api/TSkybox.cpp index a4e7a8416..75658868c 100644 --- a/thermion_dart/native/src/c_api/TSkybox.cpp +++ b/thermion_dart/native/src/c_api/TSkybox.cpp @@ -20,7 +20,30 @@ namespace thermion auto *skybox = reinterpret_cast(tSkybox); skybox->setColor(filament::math::float4 { static_cast(r), static_cast(g), static_cast(b), static_cast(a) } ); } - + + EMSCRIPTEN_KEEPALIVE void Skybox_setLayerMask(TSkybox *tSkybox, uint8_t select, uint8_t values) + { + auto *skybox = reinterpret_cast(tSkybox); + skybox->setLayerMask(select, values); + } + + EMSCRIPTEN_KEEPALIVE uint8_t Skybox_getLayerMask(TSkybox *tSkybox) + { + auto *skybox = reinterpret_cast(tSkybox); + return skybox->getLayerMask(); + } + + EMSCRIPTEN_KEEPALIVE float Skybox_getIntensity(TSkybox *tSkybox) + { + auto *skybox = reinterpret_cast(tSkybox); + return skybox->getIntensity(); + } + + EMSCRIPTEN_KEEPALIVE TTexture *Skybox_getTexture(TSkybox *tSkybox) + { + auto *skybox = reinterpret_cast(tSkybox); + return reinterpret_cast(const_cast(skybox->getTexture())); + } #ifdef __cplusplus } diff --git a/thermion_dart/native/src/c_api/ThermionDartRenderThreadApi.cpp b/thermion_dart/native/src/c_api/ThermionDartRenderThreadApi.cpp index 206b2c452..30ef1fc4c 100644 --- a/thermion_dart/native/src/c_api/ThermionDartRenderThreadApi.cpp +++ b/thermion_dart/native/src/c_api/ThermionDartRenderThreadApi.cpp @@ -701,25 +701,25 @@ extern "C" #endif } - EMSCRIPTEN_KEEPALIVE void Engine_buildSkyboxRenderThread(TEngine *tEngine, TTexture *tTexture, void (*onComplete)(TSkybox *)) + EMSCRIPTEN_KEEPALIVE void Engine_buildSkyboxRenderThread(TEngine *tEngine, TTexture *tTexture, bool showSun, float intensity, uint8_t priority, void (*onComplete)(TSkybox *)) { auto *rt = RT(tEngine); std::packaged_task lambda( [=]() mutable { - auto *skybox = Engine_buildSkybox(tEngine, tTexture); + auto *skybox = Engine_buildSkybox(tEngine, tTexture, showSun, intensity, priority); PROXY(onComplete(skybox)); }); auto fut = rt->addTask(lambda); } - EMSCRIPTEN_KEEPALIVE void Engine_buildColoredSkyboxRenderThread(TEngine *tEngine, float r, float g, float b, float a, void (*onComplete)(TSkybox *)) + EMSCRIPTEN_KEEPALIVE void Engine_buildColoredSkyboxRenderThread(TEngine *tEngine, float r, float g, float b, float a, bool showSun, float intensity, uint8_t priority, void (*onComplete)(TSkybox *)) { auto *rt = RT(tEngine); std::packaged_task lambda( [=]() mutable { - auto *skybox = Engine_buildColoredSkybox(tEngine, r, g, b, a); + auto *skybox = Engine_buildColoredSkybox(tEngine, r, g, b, a, showSun, intensity, priority); PROXY(onComplete(skybox)); }); auto fut = rt->addTask(lambda); diff --git a/thermion_dart/test/asset_tests.dart b/thermion_dart/test/asset_tests.dart index 590df5651..213eacd4b 100644 --- a/thermion_dart/test/asset_tests.dart +++ b/thermion_dart/test/asset_tests.dart @@ -9,11 +9,11 @@ import 'helpers.dart'; // Rendered frames captured by this suite are diffed against golden reference // images by test/compare_goldens.py (run in CI, see // .github/workflows/run-dart-tests.yml). The goldens are NOT in this repo: -// the workflow downloads a pinned golden-images artifact from a previous -// known-good run into test/golden-downloads/ (a transient dir, gitignored) -// and diffs it against test/output/. A capture must be pixel-identical to -// its golden or the comparison fails. Captures with no golden yet are -// reported as EXTRA and do not fail the comparison. +// the workflow downloads the platform's pinned dart-test-output artifact from +// a previous known-good run into test/golden-downloads/ (a transient dir, +// gitignored) and diffs it against test/output/. Every capture must match its +// golden within a narrow pixel tolerance; missing, changed, and new captures +// all fail. void main() async { final testHelper = TestHelper("assets"); diff --git a/thermion_dart/test/compare_goldens.py b/thermion_dart/test/compare_goldens.py index 8808678c8..42713350c 100644 --- a/thermion_dart/test/compare_goldens.py +++ b/thermion_dart/test/compare_goldens.py @@ -3,6 +3,7 @@ Compare PNG files between golden reference images and test output images. """ +import argparse import os import sys from pathlib import Path @@ -30,7 +31,9 @@ def calculate_image_difference(img1_path, img2_path): diff = ImageChops.difference(img1, img2) # Convert to numpy for calculations - diff_array = np.array(diff) + # Promote before squaring; uint8 arithmetic wraps at 255 and can + # otherwise report non-identical pixels as an MSE of zero. + diff_array = np.asarray(diff, dtype=np.float32) # Calculate metrics mse = np.mean(diff_array ** 2) @@ -53,9 +56,33 @@ def find_png_files(directory): return sorted(png_files) def main(): - # Define paths - golden_dir = Path("golden-downloads/thermion_dart/test/output") - output_dir = Path("output") + parser = argparse.ArgumentParser(description="Compare rendered PNGs with golden images") + parser.add_argument( + "--golden-dir", + default="golden-downloads/thermion_dart/test/output", + help="directory containing golden PNG files", + ) + parser.add_argument( + "--output-dir", + default="output", + help="directory containing rendered PNG files", + ) + parser.add_argument( + "--max-mse", + type=float, + default=0.01, + help="maximum accepted mean squared channel error (default: 0.01)", + ) + parser.add_argument( + "--max-difference", + type=float, + default=2.0, + help="maximum accepted per-channel difference (default: 2)", + ) + args = parser.parse_args() + + golden_dir = Path(args.golden_dir) + output_dir = Path(args.output_dir) # Check if directories exist if not golden_dir.exists(): @@ -84,45 +111,49 @@ def main(): output_path = output_dir / golden_file if not output_path.exists(): - print(f"❌ MISSING: {golden_file} (exists in golden but not in output)") + print(f"MISSING: {golden_file} (exists in golden but not in output)") missing_count += 1 continue mse, max_diff, are_identical = calculate_image_difference(golden_path, output_path) if mse is None: - print(f"❌ ERROR: {golden_file} (failed to compare)") + print(f"ERROR: {golden_file} (failed to compare)") error_count += 1 continue - if are_identical: - print(f"✅ IDENTICAL: {golden_file}") + within_tolerance = mse <= args.max_mse and max_diff <= args.max_difference + if within_tolerance: + if are_identical: + print(f"IDENTICAL: {golden_file}") + else: + print(f"WITHIN TOLERANCE: {golden_file} (MSE: {mse:.4f}, Max diff: {max_diff})") identical_count += 1 else: - print(f"⚠️ DIFFERENT: {golden_file} (MSE: {mse:.2f}, Max diff: {max_diff})") + print(f"DIFFERENT: {golden_file} (MSE: {mse:.2f}, Max diff: {max_diff})") different_count += 1 # Check for files that exist in output but not in golden extra_files = set(output_files) - set(golden_files) for extra_file in extra_files: - print(f"ℹ️ EXTRA: {extra_file} (exists in output but not in golden)") + print(f"EXTRA: {extra_file} (exists in output but not in golden)") # Print summary print("\n" + "="*50) print("COMPARISON SUMMARY:") - print(f"✅ Identical files: {identical_count}") - print(f"⚠️ Different files: {different_count}") - print(f"❌ Missing files: {missing_count}") - print(f"❌ Error files: {error_count}") - print(f"ℹ️ Extra files: {len(extra_files)}") - print(f"📊 Total golden files: {len(golden_files)}") + print(f"Identical files: {identical_count}") + print(f"Different files: {different_count}") + print(f"Missing files: {missing_count}") + print(f"Error files: {error_count}") + print(f"Extra files: {len(extra_files)}") + print(f"Total golden files: {len(golden_files)}") # Exit with appropriate code - if different_count > 0 or missing_count > 0 or error_count > 0: - print("\n❌ COMPARISON FAILED") + if different_count > 0 or missing_count > 0 or error_count > 0 or extra_files: + print("\nCOMPARISON FAILED") sys.exit(1) else: - print("\n✅ ALL COMPARISONS PASSED") + print("\nALL COMPARISONS PASSED") sys.exit(0) if __name__ == "__main__": diff --git a/thermion_dart/test/helpers.dart b/thermion_dart/test/helpers.dart index 9e67510f2..a4560963d 100644 --- a/thermion_dart/test/helpers.dart +++ b/thermion_dart/test/helpers.dart @@ -313,7 +313,13 @@ class TestHelper { } if (bg != null) { - await viewer.setBackgroundColor(bg.r.toDouble(), bg.g.toDouble(), bg.b.toDouble(), bg.a.toDouble()); + final skybox = await FilamentApp.instance!.createColoredSkybox( + r: bg.r.toDouble(), + g: bg.g.toDouble(), + b: bg.b.toDouble(), + a: bg.a.toDouble(), + ); + await viewer.scene.setSkybox(skybox); } final camera = await viewer.getActiveCamera(); diff --git a/thermion_dart/test/image_tests.dart b/thermion_dart/test/image_tests.dart index f19d510b7..add67b915 100644 --- a/thermion_dart/test/image_tests.dart +++ b/thermion_dart/test/image_tests.dart @@ -22,9 +22,11 @@ void main() async { test('set background color', () async { await ViewerBuilder(testHelper).execute((result) async { - await result.viewer.setBackgroundColor(0, 1, 0, 1); + final scene = await result.viewer.view.getScene(); + final skybox = await FilamentApp.instance!.createColoredSkybox(r: 0, g: 1, b: 0, a: 1); + await scene.setSkybox(skybox); await testHelper.capture(result.viewer.view, "background_green"); - await result.viewer.setBackgroundColor(1, 0, 0, 1); + await skybox.setColor(1, 0, 0, 1); await testHelper.capture(result.viewer.view, "background_red"); }); }); diff --git a/thermion_dart/test/light_tests.dart b/thermion_dart/test/light_tests.dart index 7ac134f25..db7e1d76c 100644 --- a/thermion_dart/test/light_tests.dart +++ b/thermion_dart/test/light_tests.dart @@ -41,8 +41,10 @@ void main() async { textureSamplerType: TextureSamplerType.SAMPLER_CUBEMAP, flags: {TextureUsage.TEXTURE_USAGE_COLOR_ATTACHMENT, TextureUsage.TEXTURE_USAGE_UPLOADABLE}, ); - var data = Float32List.fromList(List.filled(1 * 1 * 4, 1.0)).asUint8List(); - await texture.setImage(0, data, 1, 1, PixelDataFormat.RGBA, PixelDataType.FLOAT); + // A cubemap has six layers. Leaving five faces uninitialized made this + // capture alternate between the expected red reflection and black. + var data = Float32List.fromList(List.filled(6 * 1 * 1 * 4, 1.0)).asUint8List(); + await texture.setImage(0, data, 1, 1, PixelDataFormat.RGBA, PixelDataType.FLOAT, depth: 6); var indirectLight = await FFIIndirectLight.fromIrradianceTexture( FilamentApp.instance! as FFIFilamentApp, diff --git a/thermion_dart/test/postprocessing_tests.dart b/thermion_dart/test/postprocessing_tests.dart index e170f0428..fc6d37ce8 100644 --- a/thermion_dart/test/postprocessing_tests.dart +++ b/thermion_dart/test/postprocessing_tests.dart @@ -12,7 +12,8 @@ void main() async { test('enable/disable postprocessing', () async { await testHelper.withViewer( (viewer) async { - await viewer.setBackgroundColor(1.0, 0.0, 0.0, 1.0); + final scene = await viewer.view.getScene(); + await scene.setSkybox(await FilamentApp.instance!.createColoredSkybox(r: 1.0, g: 0.0, b: 0.0, a: 1.0)); await testHelper.capture(viewer.view, "empty_scene_no_postprocessing"); await viewer.setPostProcessing(true); await testHelper.capture(viewer.view, "empty_scene_postprocessing"); diff --git a/thermion_dart/test/skybox_tests.dart b/thermion_dart/test/skybox_tests.dart index 021e58065..f4a7df7b8 100644 --- a/thermion_dart/test/skybox_tests.dart +++ b/thermion_dart/test/skybox_tests.dart @@ -1,7 +1,5 @@ @Timeout(const Duration(seconds: 600)) import 'package:test/test.dart'; -import 'package:thermion_dart/src/filament/src/implementation/ffi_scene.dart'; -import 'package:thermion_dart/src/viewer/src/ffi/src/thermion_viewer_ffi.dart'; import 'package:thermion_dart/thermion_dart.dart'; import 'helpers.dart'; @@ -19,6 +17,7 @@ void main() async { await scene.setSkybox(skybox); await testHelper.capture(result.viewer.view, "colored_skybox_black"); + await scene.setSkybox(null); await skybox.destroy(); }); }); @@ -33,6 +32,7 @@ void main() async { await scene.setSkybox(skybox); await testHelper.capture(result.viewer.view, "colored_skybox_transparent"); + await scene.setSkybox(null); await skybox.destroy(); }); }); @@ -47,7 +47,192 @@ void main() async { await scene.setSkybox(skybox); await testHelper.capture(result.viewer.view, "colored_skybox_red"); + await scene.setSkybox(null); await skybox.destroy(); }); }); + + test('colored skybox has no environment texture', () async { + await ViewerBuilder(testHelper).setRenderTargetEnabled(true).execute((result) async { + final skybox = await FilamentApp.instance!.createColoredSkybox(r: 0.0, g: 0.0, b: 0.0, a: 1.0); + expect(skybox.getTexture(), isNull); + await skybox.destroy(); + }); + }); + + test('skybox intensity defaults to Filament value and can be overridden', () async { + await ViewerBuilder(testHelper).setRenderTargetEnabled(true).execute((result) async { + final defaultSkybox = await FilamentApp.instance!.createColoredSkybox(r: 0.0, g: 0.0, b: 0.0, a: 1.0); + expect(defaultSkybox.getIntensity(), 30000.0); + + final dimSkybox = await FilamentApp.instance!.createColoredSkybox( + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + intensity: 100.0, + ); + expect(dimSkybox.getIntensity(), 100.0); + + await defaultSkybox.destroy(); + await dimSkybox.destroy(); + }); + }); + + test('skybox layer mask can be set and read back', () async { + await ViewerBuilder(testHelper).setRenderTargetEnabled(true).execute((result) async { + final skybox = await FilamentApp.instance!.createColoredSkybox(r: 0.0, g: 0.0, b: 0.0, a: 1.0); + // Default visibility mask is bit 0 only. + expect(skybox.getLayerMask(), 0x1); + + // Set bit 1, clear bit 0 (see filament Skybox::setLayerMask docs). + await skybox.setLayerMask(7, 2); + expect(skybox.getLayerMask(), 2); + + await skybox.destroy(); + }); + }); + + test('loadSkybox returns the created skybox and its environment texture', () async { + await ViewerBuilder(testHelper).setRenderTargetEnabled(true).execute((result) async { + final skybox = await result.viewer.loadSkybox("file://${testHelper.assetsDir}/default_env_skybox.ktx"); + expect(skybox, isNotNull); + final texture = skybox.getTexture(); + expect(texture, isNotNull); + expect(skybox.getLayerMask(), 0x1); + expect(await result.viewer.removeSkybox(), isNotNull); + await skybox.destroy(); + await result.viewer.app.flush(); + await texture!.destroy(); + }); + }); + + test('setBackgroundColor returns the attached skybox without caching it', () async { + await ViewerBuilder(testHelper).setRenderTargetEnabled(true).execute((result) async { + final skybox = await result.viewer.setBackgroundColor(1.0, 0.0, 0.0, 1.0); + expect(skybox.getTexture(), isNull); + + final attached = await result.viewer.getSkybox(); + expect(attached, isNotNull); + expect(attached!.getTexture(), isNull); + await testHelper.capture(result.viewer.view, "set_background_color"); + + final removed = await result.viewer.removeSkybox(); + expect(removed, isNotNull); + expect(await result.viewer.getSkybox(), isNull); + await testHelper.capture(result.viewer.view, "remove_background_color"); + await skybox.destroy(); + }); + }); + + test('viewer does not cache skybox; getSkybox reflects the scene', () async { + await ViewerBuilder(testHelper).setRenderTargetEnabled(true).execute((result) async { + expect(await result.viewer.getSkybox(), isNull); + + final skybox = await FilamentApp.instance!.createColoredSkybox(r: 0.0, g: 0.0, b: 0.0, a: 1.0); + await (result.viewer as ThermionViewerFFI).scene.setSkybox(skybox); + + // Attached directly via the scene, still visible through the viewer. + final attached = await result.viewer.getSkybox(); + expect(attached, isNotNull); + expect(attached!.getTexture(), isNull); + + // removeSkybox detaches caller-owned skyboxes without destroying them. + expect(await result.viewer.removeSkybox(), isNotNull); + expect(await result.viewer.getSkybox(), isNull); + await skybox.destroy(); + }); + }); + + test('scene skybox can be set and read back', () async { + await ViewerBuilder(testHelper).setRenderTargetEnabled(true).execute((result) async { + final scene = (result.viewer as ThermionViewerFFI).scene; + expect(await scene.getSkybox(), isNull); + + final skybox = await FilamentApp.instance!.createColoredSkybox(r: 0.0, g: 0.0, b: 0.0, a: 1.0); + await scene.setSkybox(skybox); + final attached = await scene.getSkybox(); + expect(attached, isNotNull); + // Same underlying skybox (no environment texture on a color skybox). + expect(attached!.getTexture(), isNull); + + await scene.setSkybox(null); + expect(await scene.getSkybox(), isNull); + + await skybox.destroy(); + }); + }); + + test('builder options do not break skybox creation', () async { + await ViewerBuilder(testHelper).setRenderTargetEnabled(true).execute((result) async { + final scene = (result.viewer as ThermionViewerFFI).scene; + final skybox = await FilamentApp.instance!.createColoredSkybox( + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + showSun: false, + intensity: 2500.0, + priority: 3, + ); + expect(skybox.getIntensity(), 2500.0); + await scene.setSkybox(skybox); + await scene.setSkybox(null); + await skybox.destroy(); + }); + }); + + test('showSun renders the sun disc when a SUN light is in the scene', () async { + await ViewerBuilder( + testHelper, + ).setRenderTargetEnabled(true).setCameraLookAt(Vector3(0, 0, 1), focus: Vector3.zero()).execute((result) async { + final scene = (result.viewer as ThermionViewerFFI).scene; + final lightManager = FilamentApp.instance!.lightManager; + + // Aim the light along +z so the sun disc sits at -z, dead ahead of the + // camera (Filament's default SUN direction points straight down, which + // puts the disc at the zenith, out of frame). + final sunLight = lightManager.createLight(LightType.SUN); + lightManager.setDirection(sunLight, 0.0, 0.0, 1.0); + await scene.addEntity(sunLight); + + // Baseline: black color skybox with showSun disabled - the capture + // should be uniformly black. + final plain = await FilamentApp.instance!.createColoredSkybox(r: 0.0, g: 0.0, b: 0.0, a: 1.0); + await scene.setSkybox(plain); + final withoutSun = await testHelper.capture(result.viewer.view, "skybox_no_sun"); + final pixelsWithout = withoutSun[result.viewer.view]!; + + // showSun: the sun disc must appear as non-black pixels. + final withSunSkybox = await FilamentApp.instance!.createColoredSkybox( + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + showSun: true, + ); + await scene.setSkybox(withSunSkybox); + final withSun = await testHelper.capture(result.viewer.view, "skybox_show_sun"); + final pixelsWith = withSun[result.viewer.view]!; + + double maxLuminance(Uint8List pixels) { + var maxLum = 0.0; + final floats = Float32List.view(pixels.buffer, pixels.offsetInBytes); + for (var i = 0; i < floats.length; i += 4) { + final lum = floats[i] + floats[i + 1] + floats[i + 2]; + if (lum > maxLum) maxLum = lum; + } + return maxLum; + } + + expect(maxLuminance(pixelsWithout), 0.0); + expect(maxLuminance(pixelsWith), greaterThan(0.0)); + + await scene.removeEntity(sunLight); + lightManager.destroyLight(sunLight); + await scene.setSkybox(null); + await withSunSkybox.destroy(); + await plain.destroy(); + }); + }); } diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart index 44f169778..a1681801d 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart @@ -171,12 +171,23 @@ class _ViewerWidgetState extends State { viewer!.loadIbl(widget.iblPath!); } } else if (oldWidget.background != widget.background) { - viewer!.setBackgroundColor( - widget.background?.r ?? 0, - widget.background?.g ?? 0, - widget.background?.b ?? 0, - widget.background?.a ?? 0, - ); + final background = widget.background; + if (background == null) { + viewer!.removeSkybox(); + } else { + () async { + await viewer!.removeSkybox(); + final scene = await viewer!.view.getScene(); + await scene.setSkybox( + await FilamentApp.instance!.createColoredSkybox( + r: background.r, + g: background.g, + b: background.b, + a: background.a, + ), + ); + }(); + } } else if (oldWidget.initialCameraPosition != widget.initialCameraPosition) { throw UnsupportedError( @@ -286,11 +297,14 @@ class _ViewerWidgetState extends State { if (widget.skyboxPath != null) { _logger.severe("Specify skyboxPath or background, not both"); } else { - await viewer!.setBackgroundColor( - widget.background!.r, - widget.background!.g, - widget.background!.b, - widget.background!.a, + final scene = await viewer!.view.getScene(); + await scene.setSkybox( + await FilamentApp.instance!.createColoredSkybox( + r: widget.background!.r, + g: widget.background!.g, + b: widget.background!.b, + a: widget.background!.a, + ), ); } } From 4cda42ea0059121232a482ad6c94c530481da18b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 13:16:59 +0000 Subject: [PATCH 13/16] chore: update web.version --- thermion_dart/native/web/web.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thermion_dart/native/web/web.version b/thermion_dart/native/web/web.version index 2b1b876fa..212ec6734 100644 --- a/thermion_dart/native/web/web.version +++ b/thermion_dart/native/web/web.version @@ -1 +1 @@ -e81a6c6 +ee0909f From 3899baf77a8ea64626bb5398a84716efa8f32232 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 21:49:39 +0800 Subject: [PATCH 14/16] refactor(flutter): keep widget-building out of the plugin API - ThermionFlutterPluginInitializer -> ThermionTextureBootstrap, moved into the widgets layer next to ViewerWidget; the name now says what it does (deferred texture handshake) instead of implying a scope. - The plugin no longer constructs widgets: buildInitializationScope is replaced by createContextBootstrap/destroyContextBootstrap, which default to null/no-op for platforms without the Linux OpenGL prerequisite. - The injected allocator pair stays a unit: with an injected create hook, destruction falls back to the descriptor, never the plugin. No behavior change; sequencing and dispose-races are covered by texture_bootstrap_test and lifecycle_test. Co-Authored-By: Claude --- .../src/thermion_flutter_plugin_native.dart | 22 ++---- .../lib/src/thermion_flutter_plugin.dart | 24 ++++--- .../src/texture_bootstrap.dart} | 68 ++++++++++++++----- .../lib/src/widgets/src/viewer_widget.dart | 4 +- ..._test.dart => texture_bootstrap_test.dart} | 6 +- 5 files changed, 74 insertions(+), 50 deletions(-) rename thermion_flutter/thermion_flutter/lib/src/{platform/src/thermion_flutter_plugin_initializer.dart => widgets/src/texture_bootstrap.dart} (60%) rename thermion_flutter/thermion_flutter/test/{thermion_flutter_plugin_initializer_test.dart => texture_bootstrap_test.dart} (94%) 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 ec140262d..5586c916d 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 @@ -2,14 +2,12 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart' hide View; // ignore: implementation_imports import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; import 'native_rendering_lifecycle_controller.dart'; import 'native_texture_surface_manager.dart'; import 'platform_texture_descriptor.dart'; -import 'thermion_flutter_plugin_initializer.dart'; import '../../../thermion_flutter.dart'; /// Initializes the native Filament application and delegates frame lifecycle @@ -162,7 +160,9 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { _lifecycle.resumeExplicitly(); } - Future _createContextBootstrap() async { + @internal + @override + Future createContextBootstrap() async { if (!Platform.isLinux || _resolveBackend() != Backend.OPENGL || FilamentApp.instance != null) { @@ -171,22 +171,10 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { return _textureSurfaces.createContextBootstrap(); } - Future _destroyContextBootstrap(PlatformTextureDescriptor descriptor) { - return _textureSurfaces.destroyContextBootstrap(descriptor); - } - @internal @override - Widget buildInitializationScope({ - required Future Function() initialize, - required Widget child, - }) { - return ThermionFlutterPluginInitializer( - initialize: initialize, - child: child, - createContextBootstrap: _createContextBootstrap, - destroyContextBootstrap: _destroyContextBootstrap, - ); + Future destroyContextBootstrap(PlatformTextureDescriptor descriptor) { + return _textureSurfaces.destroyContextBootstrap(descriptor); } @override 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 9e928c450..4c35fbace 100644 --- a/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart +++ b/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart @@ -1,13 +1,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart' hide View; import 'package:thermion_dart/thermion_dart.dart'; // ignore: implementation_imports import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; import 'package:thermion_flutter/src/options.dart'; import 'package:thermion_flutter/src/platform/src/platform_texture_descriptor.dart'; -import 'package:thermion_flutter/src/platform/src/thermion_flutter_plugin_initializer.dart'; import 'platform/platform.dart'; @@ -86,16 +84,20 @@ abstract class ThermionFlutterPlugin { FilamentApp.instance?.setTargetFramerate(fps); } - /// Hosts any Flutter-side prerequisites while [initialize] creates a viewer. + /// Allocates the throwaway external texture that must be composited by + /// Flutter before the native viewer can be created. Returns null when the + /// running platform has no such prerequisite and the caller may initialize + /// immediately. + /// + /// Consumed by ThermionTextureBootstrap; not part of the public API. @internal - Widget buildInitializationScope({ - required Future Function() initialize, - required Widget child, - }) { - return ThermionFlutterPluginInitializer( - initialize: initialize, - child: child, - ); + Future createContextBootstrap() async => null; + + /// Releases a descriptor returned by [createContextBootstrap]; only ever + /// called with a non-null descriptor. Not part of the public API. + @internal + Future destroyContextBootstrap(PlatformTextureDescriptor descriptor) { + return descriptor.destroy(); } /// Creates a rendering surface and binds to the given [View]. diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart similarity index 60% rename from thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart rename to thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart index f843a7a5e..8a6b5c352 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_initializer.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; - -import 'platform_texture_descriptor.dart'; +// ignore: implementation_imports +import 'package:thermion_flutter/src/platform/src/platform_texture_descriptor.dart'; +// ignore: implementation_imports +import 'package:thermion_flutter/src/thermion_flutter_plugin.dart'; typedef ContextBootstrapAllocator = Future Function(); @@ -10,15 +12,27 @@ typedef ContextBootstrapDestroyer = Future Function( PlatformTextureDescriptor descriptor, ); -/// Hosts the Flutter-side prerequisites for initializing the native plugin, -/// then runs [initialize]. +/// Sequences [initialize] after the texture handshake some platforms require +/// before the native viewer can be created. +/// +/// Linux OpenGL cannot create Filament's shared GL context until Flutter has +/// composited an external texture at least once. Until then there is nothing +/// on the Flutter side for Filament's context to share with. So this widget +/// renders a 1x1 [Texture] backed by a bootstrap descriptor, waits for the +/// engine to populate it ([PlatformTextureDescriptor.awaitTextureReady]), +/// runs [initialize], and only then removes the layer and destroys the +/// descriptor. +/// +/// When the allocator returns null (every platform without the prerequisite, +/// and any viewer created after the first one), the handshake is skipped and +/// [initialize] runs immediately. /// -/// Linux OpenGL needs a real [Texture] layer before Filament can initialize. -/// Other platforms skip that handshake and invoke [initialize] immediately. -/// Keeping the layer and descriptor lifecycle here prevents viewer widgets -/// from depending on EGL or deferred texture details. -class ThermionFlutterPluginInitializer extends StatefulWidget { - const ThermionFlutterPluginInitializer({ +/// The allocator pair comes from [ThermionFlutterPlugin] by default; tests +/// may inject their own. If a create hook is injected, destruction stays +/// within the injected pair (falling back to the descriptor itself) and the +/// plugin is never consulted. +class ThermionTextureBootstrap extends StatefulWidget { + const ThermionTextureBootstrap({ super.key, required this.initialize, required this.child, @@ -32,16 +46,17 @@ class ThermionFlutterPluginInitializer extends StatefulWidget { final ContextBootstrapDestroyer? destroyContextBootstrap; @override - State createState() => - _ThermionFlutterPluginInitializerState(); + State createState() => + _ThermionTextureBootstrapState(); } -class _ThermionFlutterPluginInitializerState - extends State { +class _ThermionTextureBootstrapState extends State { PlatformTextureDescriptor? _descriptor; Future? _destroyFuture; bool _disposing = false; + bool get _injected => widget.createContextBootstrap != null; + @override void initState() { super.initState(); @@ -63,7 +78,7 @@ class _ThermionFlutterPluginInitializerState } Future _bootstrap() async { - final descriptor = await widget.createContextBootstrap?.call(); + final descriptor = await _allocate(); _descriptor = descriptor; try { @@ -100,10 +115,27 @@ class _ThermionFlutterPluginInitializerState } } + Future _allocate() { + final create = widget.createContextBootstrap; + if (create != null) { + return create(); + } + return ThermionFlutterPlugin.instance.createContextBootstrap(); + } + Future _destroy(PlatformTextureDescriptor descriptor) { - return _destroyFuture ??= - widget.destroyContextBootstrap?.call(descriptor) ?? - descriptor.destroy(); + return _destroyFuture ??= () { + final destroy = widget.destroyContextBootstrap; + if (destroy != null) { + return destroy(descriptor); + } + if (_injected) { + return descriptor.destroy(); + } + return ThermionFlutterPlugin.instance.destroyContextBootstrap( + descriptor, + ); + }(); } @override diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart index 83a892f9e..a595df874 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:thermion_flutter/thermion_flutter.dart' hide Texture; +import 'texture_bootstrap.dart'; + enum ManipulatorType { NONE, ORBIT, FREE_FLIGHT } class ViewerWidget extends StatefulWidget { @@ -413,7 +415,7 @@ class _ViewerWidgetState extends State { final child = viewport == null ? widget.initial : SizedBox.expand(child: viewport); - return ThermionFlutterPlugin.instance.buildInitializationScope( + return ThermionTextureBootstrap( initialize: _initializeViewer, child: child, ); diff --git a/thermion_flutter/thermion_flutter/test/thermion_flutter_plugin_initializer_test.dart b/thermion_flutter/thermion_flutter/test/texture_bootstrap_test.dart similarity index 94% rename from thermion_flutter/thermion_flutter/test/thermion_flutter_plugin_initializer_test.dart rename to thermion_flutter/thermion_flutter/test/texture_bootstrap_test.dart index 8299b279a..a60a38d14 100644 --- a/thermion_flutter/thermion_flutter/test/thermion_flutter_plugin_initializer_test.dart +++ b/thermion_flutter/thermion_flutter/test/texture_bootstrap_test.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:thermion_flutter/src/platform/src/platform_texture_descriptor.dart'; -import 'package:thermion_flutter/src/platform/src/thermion_flutter_plugin_initializer.dart'; +import 'package:thermion_flutter/src/widgets/src/texture_bootstrap.dart'; void main() { testWidgets( @@ -14,7 +14,7 @@ void main() { descriptor.events = events; await tester.pumpWidget( - ThermionFlutterPluginInitializer( + ThermionTextureBootstrap( createContextBootstrap: () async { events.add('create'); return descriptor; @@ -52,7 +52,7 @@ void main() { var initializeCount = 0; await tester.pumpWidget( - ThermionFlutterPluginInitializer( + ThermionTextureBootstrap( createContextBootstrap: () async => descriptor, initialize: () async { initializeCount++; From 7217dc96d0c633ddf667c7a4121ae37ed5c452ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 13:51:14 +0000 Subject: [PATCH 15/16] chore: update generated artifacts + format (CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with GitHub Actions --- .../lib/src/widgets/src/texture_bootstrap.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart index 8a6b5c352..a934c1425 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart @@ -132,9 +132,7 @@ class _ThermionTextureBootstrapState extends State { if (_injected) { return descriptor.destroy(); } - return ThermionFlutterPlugin.instance.destroyContextBootstrap( - descriptor, - ); + return ThermionFlutterPlugin.instance.destroyContextBootstrap(descriptor); }(); } From e6f1b5bd3f125b4f321a49c2e61500d605176710 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 21 Aug 2026 22:07:17 +0800 Subject: [PATCH 16/16] ci(linux): drop temporary push trigger from display smoke The branch was validated on the self-hosted X11/Wayland runner; keep the workflow dispatch-only for future manual runs. Co-Authored-By: Claude --- .github/workflows/linux-display-smoke.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/linux-display-smoke.yml b/.github/workflows/linux-display-smoke.yml index f72cae524..dd01d87da 100644 --- a/.github/workflows/linux-display-smoke.yml +++ b/.github/workflows/linux-display-smoke.yml @@ -1,10 +1,6 @@ name: Linux Display Smoke on: - # TEMPORARY: auto-run on pushes to this branch while it is under test on the - # self-hosted GPU runner. Remove before merging. - push: - branches: [fix/linux-egl-context-bootstrap] workflow_dispatch: inputs: ref: @@ -23,8 +19,8 @@ jobs: runs-on: [self-hosted, linux, x64] timeout-minutes: 30 env: - # Push events have no inputs context; fall back to :0 so the runner's - # desktop session display is used. + # Fall back to :0 (the runner's desktop session display) when the input + # is not provided. DISPLAY: ${{ inputs.x11-display || ':0' }} defaults: run: