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/.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 b8499785d..9eaed6e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,33 @@ ### 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). +- `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 + 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/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_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/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/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/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/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/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/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 7565fa7a7..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 @@ -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(); @@ -1191,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< @@ -2094,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, ); @@ -2110,6 +2130,9 @@ external void Engine_buildSkyboxRenderThread( ffi.Float, ffi.Float, ffi.Float, + ffi.Bool, + ffi.Float, + ffi.Uint8, ffi.Pointer)>>, ) >(isLeaf: true) @@ -2119,6 +2142,9 @@ external void Engine_buildColoredSkyboxRenderThread( double g, double b, double a, + bool showSun, + double intensity, + int priority, ffi.Pointer)>> onComplete, ); @@ -2334,19 +2360,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, ) @@ -3710,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); @@ -4504,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 3d0bc948a..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 @@ -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, @@ -522,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, @@ -1022,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( @@ -1030,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( @@ -1131,11 +1145,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, ); @@ -1880,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); @@ -2325,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); @@ -2487,11 +2509,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); @@ -4051,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); } @@ -5300,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; @@ -5316,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( @@ -5324,6 +5380,9 @@ void Engine_buildColoredSkyboxRenderThread( g, b, a, + showSun, + intensity, + priority, onComplete.cast(), ); return result; @@ -5579,19 +5638,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, ) { @@ -7368,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; @@ -8589,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; @@ -8882,21 +8957,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 +8972,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 +11268,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 +11447,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 +11455,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/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 5c53708b2..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'; @@ -214,6 +213,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..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'; @@ -1266,12 +1265,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); @@ -1583,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); } @@ -1600,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/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/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 3f767fc96..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 { @@ -353,9 +352,6 @@ abstract class FilamentApp { String? resourceUri, }); - // - Future createColorGrading(ToneMapper mapper); - // Future createGizmo(View view, GizmoType type); @@ -406,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/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..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 setBackgroundColor(double r, double g, double b, double a) { + Future getSkybox() { + return scene.getSkybox(); + } + + @override + 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); } @@ -585,12 +601,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..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 @@ -199,9 +213,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/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/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..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 *)); @@ -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/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/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/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..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); @@ -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/native/src/opengl/linux/LinuxOpenGLContext.cpp b/thermion_dart/native/src/opengl/linux/LinuxOpenGLContext.cpp index 52d0fabac..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,25 +27,81 @@ 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 (_display != EGL_NO_DISPLAY) { - eglTerminate(_display); - _display = EGL_NO_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(); } if (_gbmDevice) { @@ -51,12 +115,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 +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,29 +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. - 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; + // 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"); + 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; if (!eglInitialize(_display, &major, &minor)) { + _lastError = "Failed to initialize EGLDisplay"; LOG_ERROR("Failed to initialize EGL display"); _display = EGL_NO_DISPLAY; return; @@ -103,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; } - } - 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 +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) { @@ -225,11 +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; 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 +440,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_dart/native/web/web.version b/thermion_dart/native/web/web.version index d5f9e7ba8..212ec6734 100644 --- a/thermion_dart/native/web/web.version +++ b/thermion_dart/native/web/web.version @@ -1 +1 @@ -7d72355 +ee0909f 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/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/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/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..a4560963d 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')); @@ -309,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(); @@ -325,10 +335,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 +371,7 @@ class TestHelper { await fn.call(viewer.$1); await viewer.$1.dispose(); + await disposeColorGradings(); await FilamentApp.instance!.destroySwapChain(viewer.$2); } } @@ -399,6 +420,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 +458,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 +668,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 +797,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/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_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( 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..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 @@ -94,6 +94,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_native.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart index d9044c714..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 @@ -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'; @@ -159,6 +160,23 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { _lifecycle.resumeExplicitly(); } + @internal + @override + Future createContextBootstrap() async { + if (!Platform.isLinux || + _resolveBackend() != Backend.OPENGL || + FilamentApp.instance != null) { + return null; + } + return _textureSurfaces.createContextBootstrap(); + } + + @internal + @override + Future destroyContextBootstrap(PlatformTextureDescriptor descriptor) { + return _textureSurfaces.destroyContextBootstrap(descriptor); + } + @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..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,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,22 @@ abstract class ThermionFlutterPlugin { FilamentApp.instance?.setTargetFramerate(fps); } + /// 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; + + /// 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]. /// 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..a934c1425 --- /dev/null +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/texture_bootstrap.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/widgets.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(); +typedef ContextBootstrapDestroyer = Future Function( + PlatformTextureDescriptor descriptor, +); + +/// 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. +/// +/// 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, + this.createContextBootstrap, + this.destroyContextBootstrap, + }); + + final Future Function() initialize; + final Widget child; + final ContextBootstrapAllocator? createContextBootstrap; + final ContextBootstrapDestroyer? destroyContextBootstrap; + + @override + State createState() => + _ThermionTextureBootstrapState(); +} + +class _ThermionTextureBootstrapState extends State { + PlatformTextureDescriptor? _descriptor; + Future? _destroyFuture; + bool _disposing = false; + + bool get _injected => widget.createContextBootstrap != null; + + @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 _allocate(); + _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 _allocate() { + final create = widget.createContextBootstrap; + if (create != null) { + return create(); + } + return ThermionFlutterPlugin.instance.createContextBootstrap(); + } + + Future _destroy(PlatformTextureDescriptor descriptor) { + return _destroyFuture ??= () { + final destroy = widget.destroyContextBootstrap; + if (destroy != null) { + return destroy(descriptor); + } + if (_injected) { + return descriptor.destroy(); + } + return ThermionFlutterPlugin.instance.destroyContextBootstrap(descriptor); + }(); + } + + @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/widgets/src/viewer_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/viewer_widget.dart index 44f169778..dea8f32b7 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 { @@ -90,16 +92,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 +116,7 @@ class _ViewerWidgetState extends State { ); } + if (_disposing) return; final viewer = await ThermionFlutterPlugin.createViewer(); this.viewer = viewer; if (_disposing) { @@ -171,12 +165,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 +291,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, + ), ); } } @@ -418,6 +426,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 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..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,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"); @@ -69,49 +89,85 @@ 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); + 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,10 +178,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 +371,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; } @@ -358,6 +414,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 14c285a78..27dcf45d4 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; }; @@ -78,12 +80,22 @@ 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; +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..6fb29511b 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,28 +146,39 @@ static void destroy_all_contexts(ThermionFlutterPlugin *self) delete self->vulkan_context; self->vulkan_context = nullptr; } - if (self->use_direct_opengl) + if (self->thermion_platform) { - 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; - } + 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->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; + thermion_flutter_render_api = EGL_NONE; + thermion_flutter_render_gl_major = 0; + thermion_flutter_render_gl_minor = 0; self->backend_type = 0; } @@ -128,132 +191,218 @@ 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 - } - - // === 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; + 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; + } + + 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; + } + + 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 (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) + { + 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; + } + + if (glMajor < 4 || (glMajor == 4 && glMinor < 1)) + { + 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(flutterDpy)); + if (!self->opengl_context->IsValid()) { - std::cerr << "[ThermionGL] No EGL context current on this thread," - << " falling back to LinuxOpenGLContext" << std::endl; + 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; } - // 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(); + 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) @@ -266,7 +415,20 @@ static FlMethodResponse *handle_get_driver_platform(ThermionFlutterPlugin *self, int64_t platform = 0; if (backend == BACKEND_OPENGL) { - ensure_opengl_context(self); + 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( + 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) { platform = reinterpret_cast(self->thermion_platform); @@ -296,10 +458,23 @@ static FlMethodResponse *handle_get_shared_context(ThermionFlutterPlugin *self, int64_t sharedCtx = 0; if (backend == BACKEND_OPENGL) { - ensure_opengl_context(self); + 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( + 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 @@ -372,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; @@ -384,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_ERROR", "Failed to make utility context current", nullptr)); + "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_MAKE_CURRENT_ERROR", message, nullptr)); } // Create GL texture on utility context @@ -404,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( @@ -421,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); } @@ -439,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); } @@ -505,7 +698,20 @@ static FlMethodResponse *handle_create_texture_opengl_dmabuf(ThermionFlutterPlug static FlMethodResponse *handle_create_texture_opengl(ThermionFlutterPlugin *self, int width, int height) { - ensure_opengl_context(self); + 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( + 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)); + } if (self->use_direct_opengl) { @@ -514,6 +720,53 @@ 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); + 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 +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); } } @@ -582,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; } } @@ -699,6 +1045,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); @@ -757,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; 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..a60a38d14 --- /dev/null +++ b/thermion_flutter/thermion_flutter/test/texture_bootstrap_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/widgets/src/texture_bootstrap.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( + ThermionTextureBootstrap( + 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( + ThermionTextureBootstrap( + 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() {} +}