diff --git a/.github/workflows/linux-display-smoke.yml b/.github/workflows/linux-display-smoke.yml new file mode 100644 index 000000000..dd01d87da --- /dev/null +++ b/.github/workflows/linux-display-smoke.yml @@ -0,0 +1,144 @@ +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: + # Fall back to :0 (the runner's desktop session display) when the input + # is not provided. + DISPLAY: ${{ inputs.x11-display || ':0' }} + 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: | + 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 + 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/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/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/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, diff --git a/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h b/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h index f61ddde46..7fc0c7171 100644 --- a/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h +++ b/thermion_dart/native/include/opengl/linux/LinuxOpenGLContext.h @@ -15,13 +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: - LinuxOpenGLContext(); + // 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); @@ -29,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 52d0fabac..1310aa786 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,25 +27,80 @@ namespace thermion::opengl::linux_platform { -class LinuxOpenGLContext::Impl { +class ScopedEglThreadState { public: - ~Impl() { - _surfaces.clear(); - - if (_platform) { - ThermionPlatformEGLHeadless_Destroy(_platform); - _platform = nullptr; + 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, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); - eglDestroyContext(_display, _context); - _context = EGL_NO_CONTEXT; + eglMakeCurrent(_display, _draw, _read, _context); } + } - if (_display != EGL_NO_DISPLAY) { - eglTerminate(_display); - _display = EGL_NO_DISPLAY; + 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() { + // 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 (_eglThread.joinable()) { + RunOnEglThread([this]() { + if (_platform) { + ThermionPlatformEGLHeadless_Destroy(_platform); + _platform = nullptr; + } + 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(); } if (_gbmDevice) { @@ -51,12 +114,13 @@ class LinuxOpenGLContext::Impl { } } - Impl() { + explicit Impl(void* borrowedDisplay) { std::cerr << "[ThermionGL:Context] Initializing EGL/GBM..." << std::endl; // 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 +129,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,106 +137,174 @@ 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. - 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); - } - if (_display == EGL_NO_DISPLAY) { - LOG_ERROR("Failed to get EGL display"); - return; + // 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 = false; + } else { + // Non-Flutter fallback: obtain a display tied to the GBM device. + PFNEGLGETPLATFORMDISPLAYEXTPROC eglGetPlatformDisplayEXT = + (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress( + "eglGetPlatformDisplayEXT"); + if (eglGetPlatformDisplayEXT) { + _display = eglGetPlatformDisplayEXT( + EGL_PLATFORM_GBM_KHR, _gbmDevice, nullptr); + } + if (_display == EGL_NO_DISPLAY) { + std::cerr + << "[ThermionGL:Context] GBM platform display failed, " + "trying default" + << std::endl; + _display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + } + if (_display == EGL_NO_DISPLAY) { + _lastError = "Failed to obtain an EGLDisplay"; + LOG_ERROR("Failed to get EGL display"); + return; + } + _ownsDisplay = true; } - { - EGLint major, minor; + + // 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]() { + EGLint major = 0; + EGLint minor = 0; if (!eglInitialize(_display, &major, &minor)) { + _lastError = _ownsDisplay + ? "Failed to initialize EGLDisplay" + : "Failed to initialize Flutter's captured EGLDisplay"; LOG_ERROR("Failed to initialize EGL display"); _display = EGL_NO_DISPLAY; return; } - std::cerr << "[ThermionGL:Context] EGL initialized: " - << 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; - } + std::cerr << "[ThermionGL:Context] Using " + << (_ownsDisplay ? "GBM" : "Flutter") + << " EGL display=" << _display << " (" << 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. + 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; } - } - std::cerr << "[ThermionGL:Context] EGL context created OK" << std::endl; + 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; + } + } - // 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); - } + std::cerr << "[ThermionGL:Context] EGL context created OK" + << std::endl; + }); + } + + 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; @@ -188,7 +321,9 @@ class LinuxOpenGLContext::Impl { } void DestroyRenderingSurface(int64_t surfaceId) { - _surfaces.erase(surfaceId); + RunOnEglThread([this, surfaceId]() { + _surfaces.erase(surfaceId); + }); } uint32_t GetGLTextureId(int64_t surfaceId) { @@ -218,18 +353,77 @@ class LinuxOpenGLContext::Impl { } void* GetPlatform() { - if (!_platform) { - _platform = ThermionPlatformEGLHeadless_Create(_display); - } + RunOnEglThread([this]() { + if (!_platform) { + _platform = ThermionPlatformEGLHeadless_Create(_display); + } + }); return _platform; } 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; 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; @@ -237,10 +431,19 @@ 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; +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/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..a974ba2b6 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,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; // ignore: implementation_imports import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; @@ -7,6 +8,7 @@ import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.d import 'native_rendering_lifecycle_controller.dart'; import 'native_texture_surface_manager.dart'; import 'platform_texture_descriptor.dart'; +import 'platform_texture_descriptor_registry_native.dart'; import '../../../thermion_flutter.dart'; /// Initializes the native Filament application and delegates frame lifecycle @@ -159,6 +161,45 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { _lifecycle.resumeExplicitly(); } + @internal + @override + bool get requiresContextBootstrap => + Platform.isLinux && + _resolveBackend() == Backend.OPENGL && + FilamentApp.instance == null; + + @internal + @override + Future createContextBootstrap() async { + if (!requiresContextBootstrap) { + return null; + } + final textureId = await NativePlatformTextureDescriptorRegistry.channel + .invokeMethod('createContextBootstrap', const [1, 1]); + if (textureId == null || textureId < 0) { + throw StateError('Failed to create Flutter context bootstrap texture'); + } + return textureId; + } + + @internal + @override + Future awaitContextBootstrap(int textureId) async { + await NativePlatformTextureDescriptorRegistry.channel.invokeMethod( + 'awaitTextureReady', + textureId, + ); + } + + @internal + @override + Future destroyContextBootstrap(int textureId) async { + await NativePlatformTextureDescriptorRegistry.channel.invokeMethod( + 'destroyTexture', + textureId, + ); + } + @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..972cd2cab 100644 --- a/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart +++ b/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:thermion_dart/thermion_dart.dart'; // ignore: implementation_imports import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; @@ -83,6 +84,31 @@ abstract class ThermionFlutterPlugin { FilamentApp.instance?.setTargetFramerate(fps); } + /// Whether viewer initialization must wait for Flutter to composite a + /// bootstrap texture. False on every platform except Linux OpenGL before + /// the first engine is created. + @internal + bool get requiresContextBootstrap => false; + + /// 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 + Future createContextBootstrap() async => null; + + /// Waits until Flutter has populated a texture returned by + /// [createContextBootstrap]. Not part of the public API. + @internal + Future awaitContextBootstrap(int textureId) async {} + + /// Releases a texture returned by [createContextBootstrap]. Not part of the + /// public API. + @internal + Future destroyContextBootstrap(int textureId) async {} + /// 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/texture_bootstrap.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart new file mode 100644 index 000000000..82b3d76e7 --- /dev/null +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart @@ -0,0 +1,185 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +// ignore: implementation_imports +import 'package:thermion_flutter/src/thermion_flutter_plugin.dart'; + +typedef ContextBootstrapAllocator = Future Function(); +typedef ContextBootstrapWaiter = Future Function(int textureId); +typedef ContextBootstrapDestroyer = Future Function(int textureId); + +/// 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], waits for the engine to populate it, runs +/// [initialize], and only then removes the layer and destroys the texture. +/// +/// 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. +/// +/// The lifecycle hooks come from [ThermionFlutterPlugin] by default; tests may +/// inject their own. +class ThermionTextureBootstrap extends StatefulWidget { + const ThermionTextureBootstrap({ + super.key, + required this.initialize, + required this.child, + this.createContextBootstrap, + this.awaitContextBootstrap, + this.destroyContextBootstrap, + }); + + final Future Function() initialize; + final Widget child; + final ContextBootstrapAllocator? createContextBootstrap; + final ContextBootstrapWaiter? awaitContextBootstrap; + final ContextBootstrapDestroyer? destroyContextBootstrap; + + @override + State createState() => + _ThermionTextureBootstrapState(); +} + +class _ThermionTextureBootstrapState extends State { + int? _textureId; + 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 textureId = await _allocate(); + _textureId = textureId; + + try { + if (_disposing) return; + + if (textureId != null) { + if (mounted) { + setState(() {}); + } + try { + await _awaitReady(textureId); + } catch (_) { + // Destroying the texture is how dispose() cancels a pending + // native populate handshake. + if (_disposing) return; + rethrow; + } + } + + if (_disposing) return; + await widget.initialize(); + } finally { + if (_textureId == textureId) { + _textureId = null; + } + if (textureId != null && mounted && !_disposing) { + // Remove the Texture layer before unregistering its native texture. + setState(() {}); + await WidgetsBinding.instance.endOfFrame; + } + if (textureId != null) { + await _destroy(textureId); + } + } + } + + Future _allocate() { + final create = widget.createContextBootstrap; + if (create != null) { + return create(); + } + return ThermionFlutterPlugin.instance.createContextBootstrap(); + } + + Future _awaitReady(int textureId) { + final wait = widget.awaitContextBootstrap; + if (wait != null) { + return wait(textureId); + } + return ThermionFlutterPlugin.instance.awaitContextBootstrap(textureId); + } + + Future _destroy(int textureId) { + return _destroyFuture ??= () { + final destroy = widget.destroyContextBootstrap; + if (destroy != null) { + return destroy(textureId); + } + return ThermionFlutterPlugin.instance.destroyContextBootstrap(textureId); + }(); + } + + @override + void dispose() { + _disposing = true; + final textureId = _textureId; + if (textureId != null) { + unawaited( + _destroy(textureId).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 textureId = _textureId; + if (textureId == 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: textureId, + filterQuality: FilterQuality.none, + freeze: false, + ), + ), + ), + ], + ); + } +} 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 a1681801d..2d5040d02 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 { @@ -87,20 +89,27 @@ class _ViewerWidgetState extends State { Future? _tearDownFuture; Future? _inputHandlerUpdate; bool _disposing = false; + late final bool _requiresContextBootstrap; late final _logger = Logger(runtimeType.toString()); @override void initState() { super.initState(); - _initialization = _createViewer(); - unawaited( - _initialization!.catchError((Object error, StackTrace stack) { - _reportAsyncError('initialization', error, stack); - }), - ); + _requiresContextBootstrap = + ThermionFlutterPlugin.instance.requiresContextBootstrap; + if (!_requiresContextBootstrap) { + _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 if (widget.enableHighlights) { @@ -123,6 +132,7 @@ class _ViewerWidgetState extends State { ); } + if (_disposing) return; final viewer = await ThermionFlutterPlugin.createViewer(); this.viewer = viewer; if (_disposing) { @@ -432,6 +442,15 @@ 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); + if (!_requiresContextBootstrap) { + return child; + } + return ThermionTextureBootstrap( + 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..9955cdbe8 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,12 +14,31 @@ // 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; 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"); @@ -64,54 +84,89 @@ thermion_texture_populate(FlTextureGL *texture, ThermionTextureGL *self = THERMION_TEXTURE_GL(texture); - // Direct sharing path: texture is directly visible from Flutter's context. - // If gl_texture_id == 0, this is a deferred texture — create it now on - // Flutter's render context (which is guaranteed current during populate). - if (self->use_direct_sharing) { + // The bootstrap texture is allocated while Flutter's render context is + // current, solely to capture that context before Filament initializes. + if (self->kind == THERMION_TEXTURE_KIND_CONTEXT_BOOTSTRAP) { 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); + thermion_flutter_render_context = flutterContext; + thermion_flutter_render_display = flutterDisplay; + 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); + } } - - // Resolve pending awaitTextureReady method call + 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, + 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 + // 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,55 +177,7 @@ 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. - if (self->use_egl_image) { - if (!self->initialized) { - ensure_egl_procs(); - if (!s_glEGLImageTargetTexture2DOES) { - g_set_error(error, g_quark_from_string("thermion"), 1, - "glEGLImageTargetTexture2DOES not available"); - return FALSE; - } - - if (self->egl_image == EGL_NO_IMAGE_KHR) { - g_set_error(error, g_quark_from_string("thermion"), 2, - "No EGLImage available"); - return FALSE; - } - - // Create a new texture on Flutter's context and bind the EGLImage - glGenTextures(1, &self->flutter_gl_texture_id); - glBindTexture(GL_TEXTURE_2D, self->flutter_gl_texture_id); - 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); - - while (glGetError() != GL_NO_ERROR) {} - - s_glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, self->egl_image); - - GLenum glErr = glGetError(); - if (glErr != GL_NO_ERROR) { - std::cerr << "[ThermionEGL] GL error after EGLImage import: 0x" - << std::hex << glErr << std::dec << std::endl; - } - - glBindTexture(GL_TEXTURE_2D, 0); - self->initialized = TRUE; - } - - *target = GL_TEXTURE_2D; - *name = self->flutter_gl_texture_id; - *width = self->width; - *height = self->height; - return TRUE; - } - - // DMA-BUF path (fallback): lazy-init EGLImage import on first populate + // DMA-BUF path: lazy-init EGLImage import on first populate if (!self->initialized) { ensure_egl_procs(); @@ -261,27 +268,18 @@ static void thermion_texture_gl_dispose(GObject* object) { self->pending_ready_call = nullptr; } - if (self->use_direct_sharing) { - // Direct sharing: texture is owned by the plugin (deleted on utility context). + if (self->kind == THERMION_TEXTURE_KIND_CONTEXT_BOOTSTRAP) { + // Bootstrap texture is owned by the plugin and deleted on a context in + // Flutter's share group. // Nothing to clean up here — just zero out. self->gl_texture_id = 0; G_OBJECT_CLASS(thermion_texture_gl_parent_class)->dispose(object); return; } - if (self->use_egl_image) { - // EGLImage path: clean up the Flutter-side texture (created on Flutter's context) - if (self->flutter_gl_texture_id != 0) { - glDeleteTextures(1, &self->flutter_gl_texture_id); - self->flutter_gl_texture_id = 0; - } - // The source texture (gl_texture_id) is cleaned up by the plugin. + if (self->gl_texture_id != 0) { + glDeleteTextures(1, &self->gl_texture_id); self->gl_texture_id = 0; - } else { - if (self->gl_texture_id != 0) { - glDeleteTextures(1, &self->gl_texture_id); - self->gl_texture_id = 0; - } } if (self->egl_image != EGL_NO_IMAGE_KHR && s_eglDestroyImageKHR) { @@ -302,7 +300,6 @@ void thermion_texture_gl_class_init(ThermionTextureGLClass* klass) { void thermion_texture_gl_init(ThermionTextureGL* self) { self->gl_texture_id = 0; - self->flutter_gl_texture_id = 0; self->width = 0; self->height = 0; self->registrar = nullptr; @@ -314,8 +311,7 @@ void thermion_texture_gl_init(ThermionTextureGL* self) { self->egl_image = EGL_NO_IMAGE_KHR; self->initialized = FALSE; self->surface_id = -1; - self->use_egl_image = FALSE; - self->use_direct_sharing = FALSE; + self->kind = THERMION_TEXTURE_KIND_DMA_BUF; self->pending_ready_call = nullptr; } @@ -338,22 +334,19 @@ ThermionTextureGL* thermion_texture_gl_create( return textureGL; } -ThermionTextureGL* thermion_texture_gl_create_shared( +ThermionTextureGL* thermion_texture_gl_create_context_bootstrap( uint32_t width, uint32_t height, - GLuint gl_texture_id, - EGLImage egl_image, - int64_t surface_id, FlTextureRegistrar* registrar) { - auto textureGL = THERMION_TEXTURE_GL(g_object_new(thermion_texture_gl_get_type(), nullptr)); + auto textureGL = THERMION_TEXTURE_GL( + g_object_new(thermion_texture_gl_get_type(), nullptr)); textureGL->width = width; textureGL->height = height; - textureGL->gl_texture_id = gl_texture_id; - textureGL->egl_image = egl_image; - textureGL->surface_id = surface_id; textureGL->registrar = registrar; - textureGL->use_egl_image = TRUE; - // initialized = FALSE so populate() will import the EGLImage on first call + textureGL->kind = THERMION_TEXTURE_KIND_CONTEXT_BOOTSTRAP; + // 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; } diff --git a/thermion_flutter/thermion_flutter/linux/egl_texture.h b/thermion_flutter/thermion_flutter/linux/egl_texture.h index 14c285a78..1ff596412 100644 --- a/thermion_flutter/thermion_flutter/linux/egl_texture.h +++ b/thermion_flutter/thermion_flutter/linux/egl_texture.h @@ -21,6 +21,11 @@ G_BEGIN_DECLS +typedef enum { + THERMION_TEXTURE_KIND_DMA_BUF, + THERMION_TEXTURE_KIND_CONTEXT_BOOTSTRAP, +} ThermionTextureKind; + #define THERMION_TEXTURE_GL(obj) \ (G_TYPE_CHECK_INSTANCE_CAST((obj), thermion_texture_gl_get_type(), \ ThermionTextureGL)) @@ -40,13 +45,7 @@ struct _ThermionTextureGL { EGLImage egl_image; gboolean initialized; int64_t surface_id; // for Blit() and destruction - // EGLImage bridge path: texture bridged from Filament's context to - // Flutter's render context via EGLImage. - gboolean use_egl_image; - // Flutter-side GL texture (created on Flutter's context, backed by egl_image) - GLuint flutter_gl_texture_id; - // Direct sharing path: same EGL share group as Flutter, no EGLImage needed - gboolean use_direct_sharing; + ThermionTextureKind kind; // Deferred "awaitTextureReady" response (stored until populate creates the GL texture) FlMethodCall* pending_ready_call; }; @@ -69,21 +68,22 @@ FLUTTER_PLUGIN_EXPORT ThermionTextureGL* thermion_texture_gl_create( int64_t surface_id, 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( +// 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, - GLuint gl_texture_id, - EGLImage egl_image, - int64_t surface_id, 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 create the DMA-BUF producer and consumer +// contexts 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 074b3ccee..a1636a245 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 @@ -17,17 +18,36 @@ #include "egl_texture.h" -// EGL_KHR_gl_texture_2D_image constants (in case epoxy doesn't define them) -#ifndef EGL_GL_TEXTURE_2D_KHR -#define EGL_GL_TEXTURE_2D_KHR 0x30B1 -#endif -#ifndef EGL_GL_TEXTURE_LEVEL_KHR -#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" #include "vulkan/ExternalVulkanImage.h" // Backend type constants (match Dart Backend enum indices) @@ -39,16 +59,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 +107,14 @@ struct _ThermionFlutterPlugin thermion::vulkan::linux_platform::LinuxVulkanContext *vulkan_context; std::unordered_map *external_images; - // OpenGL path — direct sharing with Flutter's EGL context (preferred) - 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 - EGLConfig egl_config; // config matching Flutter's context - gboolean use_direct_opengl; // TRUE if direct sharing path succeeded - void* thermion_platform; // standalone OpenGLPlatform (EGLHeadless) - - // OpenGL path — fallback (LinuxOpenGLContext with GBM/DMA-BUF) + // OpenGL path — imports Flutter's EGL context before Filament starts. + EGLenum flutter_egl_api; + // Used to release Flutter-owned texture names imported from DMA-BUF. + EGLContext flutter_utility_egl_context; + EGLDisplay egl_display; // Flutter's EGL display + // OpenGL producer context with GBM/DMA-BUF transport. thermion::opengl::linux_platform::LinuxOpenGLContext *opengl_context; + std::string opengl_initialization_error; // Shared std::vector *textures; @@ -94,28 +129,25 @@ static void destroy_all_contexts(ThermionFlutterPlugin *self) delete self->vulkan_context; self->vulkan_context = nullptr; } - if (self->use_direct_opengl) + if (self->flutter_utility_egl_context != EGL_NO_CONTEXT && + self->egl_display != EGL_NO_DISPLAY) { - 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; - self->use_direct_opengl = FALSE; - if (self->thermion_platform) - { - ThermionPlatformEGLHeadless_Destroy(self->thermion_platform); - self->thermion_platform = nullptr; - } + eglDestroyContext( + self->egl_display, self->flutter_utility_egl_context); + self->flutter_utility_egl_context = EGL_NO_CONTEXT; } if (self->opengl_context) { delete self->opengl_context; self->opengl_context = nullptr; } + self->flutter_egl_api = EGL_NONE; + self->egl_display = EGL_NO_DISPLAY; + 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; } @@ -128,132 +160,187 @@ static void ensure_vulkan_context(ThermionFlutterPlugin *self) } } -static void ensure_opengl_context(ThermionFlutterPlugin *self) +static EGLContext create_flutter_utility_context( + ThermionFlutterPlugin *self, + EGLDisplay display, + EGLConfig config, + EGLContext sharedContext, + EGLenum api, + EGLint major, + EGLint minor) { - if (self->use_direct_opengl || self->opengl_context) - { - return; // already initialized - } - - // === 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. - { - 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 { - std::cerr << "[ThermionGL] Using Flutter render context=" - << (void*)flutterCtx << std::endl; - } + EglContextGuard guard(display); + if (!eglBindAPI(api)) + { + self->opengl_initialization_error = + "Could not bind Flutter's EGL client API"; + return EGL_NO_CONTEXT; + } - if (flutterCtx != EGL_NO_CONTEXT && flutterDpy != EGL_NO_DISPLAY) - { - 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 (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; - } - } - else - { - std::cerr << "[ThermionGL] No EGL context current on this thread," - << " falling back to LinuxOpenGLContext" << std::endl; - } + EGLint attributes[] = { + EGL_CONTEXT_MAJOR_VERSION, major, + EGL_CONTEXT_MINOR_VERSION, minor, + EGL_NONE}; + EGLContext context = + eglCreateContext(display, config, sharedContext, attributes); + 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; +} + +// Creates only the context needed to delete a populated bootstrap texture. +// This avoids initializing Filament's platform and GBM producer when a widget +// is disposed between the raster handshake and engine initialization. +static bool initialize_bootstrap_cleanup_context( + ThermionFlutterPlugin *self) +{ + EGLDisplay display = thermion_flutter_render_display; + EGLContext flutterContext = thermion_flutter_render_context; + EGLenum api = thermion_flutter_render_api; + EGLint major = thermion_flutter_render_gl_major; + EGLint minor = thermion_flutter_render_gl_minor; + EGLint configId = 0; + if (display == EGL_NO_DISPLAY || flutterContext == EGL_NO_CONTEXT || + (api != EGL_OPENGL_API && api != EGL_OPENGL_ES_API) || + !eglQueryContext(display, flutterContext, EGL_CONFIG_ID, &configId)) + { + return false; + } + + EGLConfig config = nullptr; + EGLint configCount = 0; + EGLint configAttributes[] = {EGL_CONFIG_ID, configId, EGL_NONE}; + if (!eglChooseConfig( + display, configAttributes, &config, 1, &configCount) || + configCount == 0 || config == nullptr) + { + return false; + } + + EGLContext utilityContext = create_flutter_utility_context( + self, display, config, flutterContext, api, major, minor); + if (utilityContext == EGL_NO_CONTEXT) + { + return false; } - // 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->flutter_egl_api = api; + self->flutter_utility_egl_context = utilityContext; + self->egl_display = display; self->backend_type = BACKEND_OPENGL; + return true; +} + +static bool initialize_opengl_dmabuf( + ThermionFlutterPlugin *self, + EGLDisplay display, + EGLConfig config, + EGLContext flutterContext, + EGLenum api, + EGLint major, + EGLint minor) +{ + auto context = new thermion::opengl::linux_platform::LinuxOpenGLContext( + reinterpret_cast(display)); + if (!context->IsValid()) + { + self->opengl_initialization_error = context->GetLastError(); + delete context; + return false; + } + + EGLContext utilityContext = create_flutter_utility_context( + self, display, config, flutterContext, api, major, minor); + if (utilityContext == EGL_NO_CONTEXT) + { + delete context; + return false; + } + + self->opengl_context = context; + self->flutter_egl_api = api; + self->flutter_utility_egl_context = utilityContext; + self->egl_display = display; + self->backend_type = BACKEND_OPENGL; + return true; +} + +static bool ensure_opengl_context(ThermionFlutterPlugin *self) +{ + if (self->opengl_context) + { + 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; + } + + EGLenum clientType = thermion_flutter_render_api; + EGLint glMajor = thermion_flutter_render_gl_major; + EGLint glMinor = thermion_flutter_render_gl_minor; + EGLint configId = 0; + 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" + << std::hex << eglGetError() << std::dec << std::endl; + self->opengl_initialization_error = + "Could not query Flutter's raster EGL context"; + return false; + } + + 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) + { + 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; + } + + // Filament uses desktop OpenGL while Flutter may expose either desktop GL + // or GLES. Keep one transport for both cases: render into a GBM buffer on + // Flutter's EGLDisplay and import it into Flutter through DMA-BUF. + return initialize_opengl_dmabuf( + self, flutterDpy, flutterConfig, flutterCtx, + static_cast(clientType), glMajor, glMinor); } static FlMethodResponse *handle_get_driver_platform(ThermionFlutterPlugin *self, FlMethodCall *method_call) @@ -266,15 +353,21 @@ 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) - { - platform = reinterpret_cast(self->thermion_platform); - } - else + gboolean rasterContextReady = + thermion_flutter_render_context != EGL_NO_CONTEXT && + thermion_flutter_render_display != EGL_NO_DISPLAY; + if (!ensure_opengl_context(self)) { - platform = reinterpret_cast(self->opengl_context->GetPlatform()); + return FL_METHOD_RESPONSE(fl_method_error_response_new( + 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)); } + platform = reinterpret_cast(self->opengl_context->GetPlatform()); } else { @@ -296,16 +389,21 @@ 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) - { - // Direct sharing: return Flutter's context (Filament contexts share with it) - sharedCtx = reinterpret_cast(self->flutter_egl_context); - } - else + gboolean rasterContextReady = + thermion_flutter_render_context != EGL_NO_CONTEXT && + thermion_flutter_render_display != EGL_NO_DISPLAY; + if (!ensure_opengl_context(self)) { - sharedCtx = reinterpret_cast(self->opengl_context->GetSharedContext()); + return FL_METHOD_RESPONSE(fl_method_error_response_new( + 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)); } + sharedCtx = reinterpret_cast(self->opengl_context->GetSharedContext()); } else { @@ -370,102 +468,6 @@ static FlMethodResponse *handle_create_texture_vulkan(ThermionFlutterPlugin *sel return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); } -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. - // - Filament imports the GL texture directly (same share group) - // - Flutter imports the EGLImage in populate() (cross share group) - - EGLDisplay display = self->egl_display; - GLuint glTexId = 0; - EGLImage eglImage = EGL_NO_IMAGE_KHR; - - { - EglContextGuard guard(display); - - // Make utility context current (Group B) - if (!eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->utility_egl_context)) - { - std::cerr << "[ThermionGL] Failed to make utility context current: 0x" - << std::hex << eglGetError() << std::dec << std::endl; - return FL_METHOD_RESPONSE(fl_method_error_response_new( - "EGL_ERROR", "Failed to make utility context current", nullptr)); - } - - // Create GL texture on utility context - glGenTextures(1, &glTexId); - glBindTexture(GL_TEXTURE_2D, glTexId); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, nullptr); - 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); - - // Create EGLImage from the GL texture (bridges Group B → Group A) - // Must be called while the owning context is current - EGLint imageAttribs[] = { EGL_GL_TEXTURE_LEVEL_KHR, 0, EGL_NONE }; - eglImage = eglCreateImageKHR( - display, self->utility_egl_context, - EGL_GL_TEXTURE_2D_KHR, - (EGLClientBuffer)(uintptr_t)glTexId, - imageAttribs); - } // guard restores previous context - - if (eglImage == EGL_NO_IMAGE_KHR) - { - EGLint err = eglGetError(); - std::cerr << "[ThermionGL] eglCreateImageKHR failed: 0x" - << std::hex << err << std::dec << std::endl; - { - EglContextGuard guard(display); - eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->utility_egl_context); - glDeleteTextures(1, &glTexId); - } - return FL_METHOD_RESPONSE(fl_method_error_response_new( - "EGL_ERROR", "Failed to create EGLImage from GL texture", nullptr)); - } - - // Register with Flutter using the EGLImage bridge path — populate() will - // import the EGLImage into a new texture on Flutter's render context - ThermionTextureGL *textureGL = thermion_texture_gl_create_shared( - static_cast(width), static_cast(height), - glTexId, eglImage, - static_cast(glTexId), // surface_id = GL texture ID (for Filament import) - self->texture_registrar); - - FlTexture *flTexture = FL_TEXTURE(textureGL); - if (!fl_texture_registrar_register_texture(self->texture_registrar, flTexture)) - { - eglDestroyImageKHR(display, eglImage); - { - EglContextGuard guard(display); - eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->utility_egl_context); - glDeleteTextures(1, &glTexId); - } - return FL_METHOD_RESPONSE(fl_method_error_response_new( - "REGISTER_FAILED", "Failed to register texture with Flutter", nullptr)); - } - - self->textures->push_back(textureGL); - int64_t flutterTextureId = fl_texture_get_id(flTexture); - - std::cerr << "[ThermionGL] EGLImage bridge: GL=" << glTexId - << " EGLImage=" << (void*)eglImage - << " flutterId=" << flutterTextureId - << " (" << width << "x" << height << ")" << std::endl; - - g_autoptr(FlValue) result = fl_value_new_list(); - fl_value_append_take(result, fl_value_new_int(flutterTextureId)); - fl_value_append_take(result, fl_value_new_int(static_cast(glTexId))); // hardwareId = GL texture (visible to Filament) - fl_value_append_take(result, fl_value_new_int(0)); - - return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); -} - -// DMA-BUF fallback path for OpenGL static FlMethodResponse *handle_create_texture_opengl_dmabuf(ThermionFlutterPlugin *self, int width, int height) { int64_t surfaceId = self->opengl_context->CreateRenderingSurface( @@ -505,15 +507,71 @@ 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) + gboolean rasterContextReady = + thermion_flutter_render_context != EGL_NO_CONTEXT && + thermion_flutter_render_display != EGL_NO_DISPLAY; + if (!ensure_opengl_context(self)) { - return handle_create_texture_opengl_direct(self, width, height); + return FL_METHOD_RESPONSE(fl_method_error_response_new( + 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)); } + 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); + 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)) + { + g_object_unref(textureGL); + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "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); + + 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)); +} + static FlMethodResponse *handle_create_texture(ThermionFlutterPlugin *self, FlMethodCall *method_call) { @@ -542,25 +600,71 @@ static FlMethodResponse *handle_destroy_texture(ThermionFlutterPlugin *self, FlM if (fl_texture_get_id(FL_TEXTURE(tex)) == flutterTextureId) { int64_t surfaceId = tex->surface_id; + ThermionTextureKind textureKind = tex->kind; + GLuint glTextureId = tex->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 (textureKind == THERMION_TEXTURE_KIND_CONTEXT_BOOTSTRAP) { - // 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) + if (glTextureId != 0 && + self->flutter_utility_egl_context == EGL_NO_CONTEXT) + { + // A bootstrap may be cancelled after populate() but before Dart + // requests the driver platform. Create only a cleanup context in + // Flutter's share group; do not initialize Filament or GBM. + initializedOnlyForBootstrapCleanup = + initialize_bootstrap_cleanup_context(self); + } + if (glTextureId != 0 && + self->flutter_utility_egl_context != EGL_NO_CONTEXT) { EglContextGuard guard(self->egl_display); + eglBindAPI(self->flutter_egl_api); eglMakeCurrent(self->egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, - self->utility_egl_context); - glDeleteTextures(1, &texId); + self->flutter_utility_egl_context); + glDeleteTextures(1, &glTextureId); } } else if (self->opengl_context) { + // DMA-BUF path: 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); } } @@ -582,6 +686,19 @@ 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->egl_image = EGL_NO_IMAGE_KHR; + g_object_unref(tex); + } + if (initializedOnlyForBootstrapCleanup) + { + destroy_all_contexts(self); + } break; } } @@ -604,17 +721,6 @@ static FlMethodResponse *handle_mark_texture_frame_available(ThermionFlutterPlug { if (fl_texture_get_id(FL_TEXTURE(tex)) == flutterTextureId) { - // Direct OpenGL path: ensure Filament's rendering is flushed - // before Flutter reads the texture - if (self->backend_type == BACKEND_OPENGL && self->use_direct_opengl) - { - static int markCount = 0; - markCount++; - if (markCount <= 5 || markCount % 60 == 0) { - TRACE( "[MarkFrame] #%d tex_id=%lld\n", markCount, (long long)tex->surface_id); - } - } - // Vulkan path may need blit; OpenGL path never needs blit if (self->backend_type == BACKEND_VULKAN && self->vulkan_context) { @@ -699,6 +805,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); @@ -756,12 +866,9 @@ static void thermion_flutter_plugin_init(ThermionFlutterPlugin *self) self->backend_type = 0; self->view = nullptr; self->vulkan_context = nullptr; - self->flutter_egl_context = EGL_NO_CONTEXT; - self->utility_egl_context = EGL_NO_CONTEXT; + self->flutter_egl_api = EGL_NONE; + self->flutter_utility_egl_context = EGL_NO_CONTEXT; self->egl_display = EGL_NO_DISPLAY; - self->egl_config = nullptr; - self->use_direct_opengl = FALSE; - self->thermion_platform = nullptr; self->opengl_context = nullptr; self->textures = new std::vector(); self->external_images = new std::unordered_map(); diff --git a/thermion_flutter/thermion_flutter/test/texture_bootstrap_test.dart b/thermion_flutter/thermion_flutter/test/texture_bootstrap_test.dart new file mode 100644 index 000000000..cf5a1255f --- /dev/null +++ b/thermion_flutter/thermion_flutter/test/texture_bootstrap_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thermion_flutter/src/widgets/src/texture_bootstrap.dart'; + +void main() { + testWidgets( + 'initializes only after the bootstrap texture is ready and then destroys it', + (tester) async { + const textureId = 7; + final events = []; + final ready = Completer(); + var destroyCount = 0; + + await tester.pumpWidget( + ThermionTextureBootstrap( + createContextBootstrap: () async { + events.add('create'); + return textureId; + }, + awaitContextBootstrap: (id) async { + expect(id, textureId); + events.add('await'); + await ready.future; + }, + destroyContextBootstrap: (id) async { + expect(id, textureId); + events.add('destroy'); + destroyCount++; + }, + initialize: () async { + events.add('initialize'); + }, + child: const SizedBox.expand(), + ), + ); + await tester.pump(); + + expect(find.byType(Texture), findsOneWidget); + expect(events, ['create', 'await']); + + ready.complete(); + await tester.pump(); + await tester.pump(); + + expect(find.byType(Texture), findsNothing); + expect(events, ['create', 'await', 'initialize', 'destroy']); + expect(destroyCount, 1); + }, + ); + + testWidgets('disposing cancels a pending bootstrap exactly once', ( + tester, + ) async { + final ready = Completer(); + var initializeCount = 0; + var destroyCount = 0; + + await tester.pumpWidget( + ThermionTextureBootstrap( + createContextBootstrap: () async => 7, + awaitContextBootstrap: (_) => ready.future, + destroyContextBootstrap: (_) async { + destroyCount++; + if (!ready.isCompleted) { + ready.completeError(StateError('destroyed')); + } + }, + 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(destroyCount, 1); + expect(initializeCount, 0); + expect(tester.takeException(), isNull); + }); +}