From ff7016664d18e8c931ca88cb70811c007694dbb8 Mon Sep 17 00:00:00 2001 From: Hoan Nguyen Date: Fri, 8 May 2026 11:31:48 +0700 Subject: [PATCH 01/13] ios: exclude vulkan sources from native build hook The build hook in thermion_dart/hook/build.dart enumerates every .cpp file under native/src/ and applies platform-specific exclusions for `windows`, `d3d`, and `linux` paths. There is no analogous exclusion for `vulkan/`, so on iOS the hook compiles `native/src/vulkan/VulkanUtils.cpp` and `native/src/vulkan/BaseVulkanTexture.cpp`. Both reference symbols in the `bluevk::` namespace, but iOS is Metal-only and the platform branch never adds `bluevk` to its `libs` list. Result on iOS device builds (Xcode 15, Flutter master): Undefined symbols for architecture arm64: "bluevk::vkMapMemory", referenced from: thermion::vulkan::readVkImageToBitmap(...) in VulkanUtils-*.o "bluevk::vkFreeMemory", referenced from: ... ... (~50 more bluevk:: symbols) ld: symbol(s) not found for architecture arm64 The whole `flutter build ios` fails inside `dart_build` because the shared lib never links. Fix: skip vulkan-prefixed source paths when targetOS is iOS, mirroring the structure of the existing windows / linux exclusions a few lines above. macOS still gets the vulkan sources (it links `bluevk`), Android still gets them (also links `bluevk`), Linux still gets them. Only iOS changes. Verified: `flutter build ios --debug --no-codesign` now produces a working `Runner.app` on top of upstream develop. --- thermion_dart/hook/build.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/thermion_dart/hook/build.dart b/thermion_dart/hook/build.dart index 89ddf4b4..94738fa1 100644 --- a/thermion_dart/hook/build.dart +++ b/thermion_dart/hook/build.dart @@ -99,6 +99,14 @@ outputDirectory : ${outputDirectory.path} sources = sources.where((p) => !p.contains("linux")).toList(); } + // iOS is Metal-only — exclude Vulkan-utility sources whose symbols + // resolve through `bluevk` (which iOS does not link). Without this + // exclusion, linking fails with "Undefined symbols: bluevk::vk*". + // See native/src/vulkan/{VulkanUtils,BaseVulkanTexture}.cpp. + if (targetOS == OS.iOS) { + sources = sources.where((p) => !p.contains("vulkan")).toList(); + } + // Material source paths (used by _processMaterials below) final materialSources = { 'capture_uv': 'native/include/material/capture_uv.c', From 7f4a46df47bdbc272d8be5baea1cba4166a08ddb Mon Sep 17 00:00:00 2001 From: Hoan Nguyen Date: Fri, 8 May 2026 12:37:21 +0700 Subject: [PATCH 02/13] mobile: eager-accept the scale gesture; synthesize tap/double-tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile gesture handler in `_MobileListenerWidget` used a regular `GestureDetector` whose internal `ScaleGestureRecognizer` waits for movement to cross `kPanSlop` before claiming the gesture arena. When a Thermion view sits inside an ancestor `Scrollable` (e.g. ListView, PageView, CustomScrollView), the ancestor's `VerticalDragGesture- Recognizer` reaches its acceptance threshold first and wins the arena — touches starting on the viewer get interpreted as page scrolls, not viewport gestures. The result on iOS is "sometimes tumbles, sometimes scrolls" depending on drag direction and speed. Fix: switch to `RawGestureDetector` with a `_EagerScaleGesture- Recognizer` subclass that calls `resolve(GestureDisposition.accepted)` inside `addAllowedPointer`. The arena is claimed on PointerDown, so ancestor scrollables never get the chance to win. Single-finger orbit and pinch-zoom both resolve to the viewport regardless of how the viewer is composed. Tap and double-tap can no longer be detected via separate `TapGestureRecognizer` / `DoubleTapGestureRecognizer` entries (the eager scale wins arena before they ever accept), so they're synthesized inside the scale callbacks: a "tap" is a scale gesture whose total focal-point movement stayed below 8 px and whose duration stayed below 250 ms; a "double-tap" is two such taps within 300 ms of each other and within 8 px of each other. Both still feed `InputHandler` via the existing `TouchEvent(tap, ...)` / `TouchEvent( doubleTap, ...)` events, so callers see no API change. Verified: `flutter build ios --debug --no-codesign` succeeds; running on iPhone the multi-viewer stress test (8 viewers in a vertical ListView) now lets the parent scroll only when drags start in the gutter and routes every touch starting on a viewer to the viewport deterministically. Filing alongside the iOS vulkan-source exclusion (PR #160). --- .../widgets/src/thermion_listener_widget.dart | 165 +++++++++++++----- 1 file changed, 125 insertions(+), 40 deletions(-) diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart index 5e1c6343..7e33fc8d 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart @@ -322,47 +322,132 @@ class _MobileListenerWidget extends StatefulWidget { } class _MobileListenerWidgetState extends State<_MobileListenerWidget> { + // Tap/double-tap detection state. We synthesize taps from the eager + // scale recognizer's start/update/end callbacks because the eager + // recognizer claims the gesture arena on PointerDown — separate + // TapGestureRecognizer / DoubleTapGestureRecognizer entries can no + // longer win. See `_EagerScaleGestureRecognizer` below. + Offset? _scaleStartFocal; + DateTime? _scaleStartTime; + double _scaleMaxMovement = 0; + Offset? _lastTapPosition; + DateTime? _lastTapTime; + + static const _kTapMaxMovement = 8.0; + static const _kTapMaxDuration = Duration(milliseconds: 250); + static const _kDoubleTapInterval = Duration(milliseconds: 300); + @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (details) { - widget.inputHandler.handle(TouchEvent(TouchEventType.tap, - details.localPosition.toVector2() * widget.pixelRatio, null)); - }, - onDoubleTap: () { - widget.inputHandler - .handle(TouchEvent(TouchEventType.doubleTap, null, null)); - }, - onScaleStart: (ScaleStartDetails event) async { - widget.inputHandler.handle(ScaleStartEvent( - numPointers: event.pointerCount, - localFocalPoint: ( - event.focalPoint.dx * widget.pixelRatio, - event.focalPoint.dy * widget.pixelRatio - ))); - }, - onScaleUpdate: (ScaleUpdateDetails event) async { - widget.inputHandler.handle(ScaleUpdateEvent( - numPointers: event.pointerCount, - localFocalPoint: ( - event.focalPoint.dx * widget.pixelRatio, - event.focalPoint.dy * widget.pixelRatio - ), - localFocalPointDelta: ( - event.focalPointDelta.dx * widget.pixelRatio, - event.focalPointDelta.dy * widget.pixelRatio - ), - rotation: event.rotation, - horizontalScale: event.horizontalScale, - verticalScale: event.verticalScale, - scale: event.scale, - )); - }, - onScaleEnd: (details) async { - widget.inputHandler - .handle(ScaleEndEvent(numPointers: details.pointerCount)); - }, - child: widget.child); + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + _EagerScaleGestureRecognizer: + GestureRecognizerFactoryWithHandlers<_EagerScaleGestureRecognizer>( + () => _EagerScaleGestureRecognizer(), + (_EagerScaleGestureRecognizer instance) { + instance + ..onStart = (ScaleStartDetails event) { + _scaleStartFocal = event.focalPoint; + _scaleStartTime = DateTime.now(); + _scaleMaxMovement = 0; + widget.inputHandler.handle(ScaleStartEvent( + numPointers: event.pointerCount, + localFocalPoint: ( + event.focalPoint.dx * widget.pixelRatio, + event.focalPoint.dy * widget.pixelRatio + ))); + } + ..onUpdate = (ScaleUpdateDetails event) { + if (_scaleStartFocal != null) { + final movement = + (event.focalPoint - _scaleStartFocal!).distance; + if (movement > _scaleMaxMovement) { + _scaleMaxMovement = movement; + } + } + widget.inputHandler.handle(ScaleUpdateEvent( + numPointers: event.pointerCount, + localFocalPoint: ( + event.focalPoint.dx * widget.pixelRatio, + event.focalPoint.dy * widget.pixelRatio + ), + localFocalPointDelta: ( + event.focalPointDelta.dx * widget.pixelRatio, + event.focalPointDelta.dy * widget.pixelRatio + ), + rotation: event.rotation, + horizontalScale: event.horizontalScale, + verticalScale: event.verticalScale, + scale: event.scale, + )); + } + ..onEnd = (ScaleEndDetails event) { + final now = DateTime.now(); + final wasTap = _scaleStartTime != null && + _scaleStartFocal != null && + now.difference(_scaleStartTime!) < _kTapMaxDuration && + _scaleMaxMovement < _kTapMaxMovement && + event.pointerCount == 0; + if (wasTap) { + final tapPosition = _scaleStartFocal!; + final isDoubleTap = _lastTapPosition != null && + _lastTapTime != null && + now.difference(_lastTapTime!) < _kDoubleTapInterval && + (tapPosition - _lastTapPosition!).distance < + _kTapMaxMovement; + if (isDoubleTap) { + widget.inputHandler.handle( + TouchEvent(TouchEventType.doubleTap, null, null)); + _lastTapPosition = null; + _lastTapTime = null; + } else { + widget.inputHandler.handle(TouchEvent( + TouchEventType.tap, + tapPosition.toVector2() * widget.pixelRatio, + null)); + _lastTapPosition = tapPosition; + _lastTapTime = now; + } + } + widget.inputHandler + .handle(ScaleEndEvent(numPointers: event.pointerCount)); + _scaleStartFocal = null; + _scaleStartTime = null; + _scaleMaxMovement = 0; + }; + }, + ), + }, + child: widget.child, + ); + } +} + +/// Variant of [ScaleGestureRecognizer] that claims the gesture arena +/// the moment a pointer is added, instead of waiting for displacement +/// to cross [kPanSlop]. +/// +/// Why: the default recognizer waits to disambiguate a one-finger pan +/// from a two-finger pinch. While it waits, an ancestor `Scrollable`'s +/// `VerticalDragGestureRecognizer` reaches its acceptance threshold +/// first and wins the arena — the touch is interpreted as a page +/// scroll instead of a viewport gesture. With this eager subclass, +/// ancestors lose the arena on the first PointerDown inside the +/// Thermion view, so single-finger orbit and pinch-zoom both resolve +/// to the viewport regardless of what scrollable wraps it. +/// +/// Tradeoff: separate `TapGestureRecognizer` / +/// `DoubleTapGestureRecognizer` entries can no longer participate +/// (eager scale wins arena before they ever accept). The mobile +/// listener compensates by synthesizing tap and double-tap events +/// from the scale callbacks — a "tap" is a scale gesture whose total +/// focal-point movement stays below 8px and whose total duration +/// stays below 250 ms; "double-tap" is two such taps within 300 ms. +class _EagerScaleGestureRecognizer extends ScaleGestureRecognizer { + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + resolve(GestureDisposition.accepted); } } From 03b8a8f223e469b28dc53e6756623ba71f081ca9 Mon Sep 17 00:00:00 2001 From: Hoan Nguyen Date: Fri, 8 May 2026 13:49:05 +0700 Subject: [PATCH 03/13] windows: wire up /link /LIBPATH so prebuilt Filament .libs resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows branch of the build hook gathers cl.exe options (includes, defines, response file with sources) but stops short of adding the /link separator and /LIBPATH:$libDir. Without those, the linker has no idea where the Filament .lib artifacts that pub get downloaded actually live, so it fails with LNK1104 / LNK2019 on every Filament symbol reference and cl.exe exits 2. The library *inputs* themselves don't need to be added on the command line because native/include/ThermionWin32.h declares all of them via #pragma comment(lib, "filament.lib") and friends, and that header is transitively included by the Windows vulkan/d3d sources plus the generic c_api headers (TCamera.h, TRenderer.cpp, TRenderManager.cpp). The pragma directives emit linker directives that the linker honours automatically — but it still has to find the .lib files via a search path, hence /LIBPATH. Uncomment /link and /LIBPATH:$libDir; leave /DLL out (CBuilder.library already passes /LD which makes /DLL redundant) and leave the individual sources out (they're already in the response file). Verification needed on Windows: I don't have a Windows dev box. Filing this as a separate fork branch so it can be rebased / dropped independently of the iOS fixes (#160 vulkan-source exclusion, #163 eager scale). --- thermion_dart/hook/build.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/thermion_dart/hook/build.dart b/thermion_dart/hook/build.dart index 94738fa1..4b7c1b1d 100644 --- a/thermion_dart/hook/build.dart +++ b/thermion_dart/hook/build.dart @@ -426,10 +426,15 @@ outputDirectory : ${outputDirectory.path} if (platform == "windows") ...[ ...includeDirs.map((d) => "/I${path.join(pkgRootFilePath, d)}"), "@${srcs.uri.toFilePath(windows: true)}", - // ...sources, - // '/link', - // "/LIBPATH:$libDir", - // '/DLL', + // Library inputs (filament.lib, backend.lib, bluevk.lib, etc.) + // are declared via #pragma comment(lib, ...) directives in + // native/include/ThermionWin32.h, which is transitively included + // by the c_api headers and the Windows vulkan/d3d sources. The + // linker only needs to know WHERE to find those .lib files — + // /LIBPATH points at the prebuilt Filament directory that pub + // get downloads. Everything after /link is forwarded to LINK.exe. + '/link', + "/LIBPATH:$libDir", ], ], libraryDirectories: [libDir], From e6f3a0d95e5dfdd0b437d16150a3bd7c44fae3ea Mon Sep 17 00:00:00 2001 From: Hoan Nguyen Date: Fri, 8 May 2026 14:15:39 +0700 Subject: [PATCH 04/13] log: tee SEVERE records to stderr so cl.exe/clang errors surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thermion's build hook routes all log output through a Logger that writes to .dart_tool/thermion_dart/log/build.log. native_toolchain_c captures subprocess (cl.exe, clang, ld) stdout/stderr and routes the stderr through `logger.severe(...)`. On a build failure runProcess throws a ProcessException whose message is just the command line and exit code — the actual compiler / linker output stays inside that build.log file in the pub cache, where it is invisible to CI logs and to anyone running `flutter build` who isn't already poking around in `.dart_tool/`. Result: compile / link failures look completely opaque downstream. The exception output shows the cl.exe command, exit code 2, and nothing else. Has been blocking diagnosis of the Windows native build for several CI iterations. Mirror SEVERE-level records to stderr inside the existing log handler so the real error reaches whoever's watching. Successful builds aren't noisier — compilers don't emit much stderr on success — and the build.log file behavior is unchanged. --- thermion_dart/hook/log.dart | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/thermion_dart/hook/log.dart b/thermion_dart/hook/log.dart index 25e24943..e872bcb5 100644 --- a/thermion_dart/hook/log.dart +++ b/thermion_dart/hook/log.dart @@ -13,9 +13,25 @@ Logger createLogger(String packageRoot, String logFilename) { final logger = Logger("") ..level = Level.ALL - ..onRecord.listen((record) => logFile.writeAsStringSync( - record.message + "\n", - mode: FileMode.append, - flush: true)); + ..onRecord.listen((record) { + logFile.writeAsStringSync( + record.message + "\n", + mode: FileMode.append, + flush: true); + // Tee SEVERE records to stderr so subprocess errors (cl.exe, + // clang, ld) actually surface to whoever's watching the + // build. `native_toolchain_c.runProcess` routes captured + // subprocess stderr through `logger.severe`, but on a + // failure it then throws a `ProcessException` whose message + // is just the command + exit code — the real compiler / + // linker output stays in the build.log file inside the pub + // cache where CI never sees it. Mirroring SEVERE to stderr + // makes the actual error visible without affecting + // successful-build noise (compilers don't emit much stderr + // on success). + if (record.level >= Level.SEVERE) { + stderr.writeln(record.message); + } + }); return logger; } \ No newline at end of file From 8495428437de0f66424b1f0b9c4e194005a2bcac Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Fri, 8 May 2026 16:27:26 +0700 Subject: [PATCH 05/13] windows: stop forwarding cl-only flags to LINK; drop /VERBOSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit native_toolchain_c.runCl already injects /Fe:, /LD, its own /link separator, /MACHINE, and /LIBPATH (from libraryDirectories) when dynamicLibrary is set. By manually adding our own '/link /LIBPATH:$libDir' to the user flags list, cl.exe's parser stopped reading compile-time flags at our separator — pushing the toolchain's auto-injected /LD and /Fe: into LINK's argument vector, where LINK ignored them as LNK4044. With /LD never reaching cl.exe, no /DLL was forwarded to the linker, which then tried to produce an EXE and bailed out with LNK1561 ("entry point must be defined"). Drop the redundant /link /LIBPATH and let `libraryDirectories: [libDir]` do its job. Also drop /VERBOSE from the compile flag list — it's a linker option that cl.exe parses as deprecated /V (warning D9035); if the verbose link map is ever needed, it belongs after native_toolchain_c's own /link separator. Diagnosed via the stderr-tee added in the prior commit; the smoking gun was the cl.exe command dump in build.log showing two /link separators and unrecognized cl flags trailing the first one. --- thermion_dart/hook/build.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/thermion_dart/hook/build.dart b/thermion_dart/hook/build.dart index 4b7c1b1d..35ea1bb7 100644 --- a/thermion_dart/hook/build.dart +++ b/thermion_dart/hook/build.dart @@ -260,7 +260,11 @@ outputDirectory : ${outputDirectory.path} "/std:c++20", if (buildMode == BuildMode.debug) ...["/MDd", "/Zi"], if (buildMode == BuildMode.release) "/MD", - "/VERBOSE", + // /VERBOSE is a linker option, not a compiler one — cl.exe parses it + // as the deprecated /V and emits warning D9035. If the + // verbose link map is ever needed for diagnostics, pass it after + // native_toolchain_c's own /link separator (see libraryDirectories + // / linkerOptions paths in run_cbuilder.dart). ...defines.keys.map((k) => "/D$k=${defines[k]}").toList(), ]); } @@ -431,10 +435,12 @@ outputDirectory : ${outputDirectory.path} // native/include/ThermionWin32.h, which is transitively included // by the c_api headers and the Windows vulkan/d3d sources. The // linker only needs to know WHERE to find those .lib files — - // /LIBPATH points at the prebuilt Filament directory that pub - // get downloads. Everything after /link is forwarded to LINK.exe. - '/link', - "/LIBPATH:$libDir", + // that is wired via `libraryDirectories: [libDir]` below, which + // native_toolchain_c emits after its own /link separator + // (run_cbuilder.dart). Adding a second /link here puts cl.exe's + // auto-generated /LD and /Fe: AFTER our separator, where LINK + // ignores them as LNK4044 — the resulting binary has no /DLL + // and no entry point, failing with LNK1561. ], ], libraryDirectories: [libDir], From 7fc87f6639c2a4332fe4e87ac0a1d1801db37596 Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Fri, 8 May 2026 16:27:51 +0700 Subject: [PATCH 06/13] windows: rename material/gizmo.c to gizmo_material.c Windows filesystems are case-insensitive, so the basename collision between native/src/scene/Gizmo.cpp and native/include/material/gizmo.c caused both source files to compile to the same gizmo.obj path. The material was added to the source list AFTER the scene class, so its .obj write-clobbered the class's, leaving thermion::Gizmo's constructor, pick, highlight, and unhighlight symbols out of the link. TGizmo.cpp's c_api wrappers then failed with four LNK2019s and a final LNK1120 ("4 unresolved externals"). LINK's earlier LNK4042 warning ("object specified more than once; extras ignored") was the breadcrumb. Rename the material .c file to gizmo_material.c (gizmo.h stays put, since only the .c basename collided). materialSources entry in build.dart updated to match. The "gizmo" key still drives the GIZMO_ENABLED=1 define so existing call sites and tests are unaffected. Verified with `flutter test` on Windows after both this and the preceding cl/link fix: 500/500 tests pass, thermion_dart.dll produced. --- thermion_dart/hook/build.dart | 6 +++++- .../native/include/material/{gizmo.c => gizmo_material.c} | 0 2 files changed, 5 insertions(+), 1 deletion(-) rename thermion_dart/native/include/material/{gizmo.c => gizmo_material.c} (100%) diff --git a/thermion_dart/hook/build.dart b/thermion_dart/hook/build.dart index 35ea1bb7..573866d7 100644 --- a/thermion_dart/hook/build.dart +++ b/thermion_dart/hook/build.dart @@ -118,7 +118,11 @@ outputDirectory : ${outputDirectory.path} 'edge_outline': 'native/include/material/edge_outline.c', 'wireframe': 'native/include/material/wireframe.c', 'translation_axis': 'native/include/material/translation_axis.c', - 'gizmo': 'native/include/material/gizmo.c', + // Renamed from gizmo.c to avoid a case-insensitive .obj collision + // with scene/Gizmo.cpp on Windows (both produced gizmo.obj, the + // material write-clobbered the class .obj, and the linker reported + // four LNK2019s for thermion::Gizmo::{Gizmo,pick,highlight,unhighlight}). + 'gizmo': 'native/include/material/gizmo_material.c', 'bone_overlay': 'native/include/material/bone_overlay.c', }; diff --git a/thermion_dart/native/include/material/gizmo.c b/thermion_dart/native/include/material/gizmo_material.c similarity index 100% rename from thermion_dart/native/include/material/gizmo.c rename to thermion_dart/native/include/material/gizmo_material.c From bef84ae54f9e09e20f5e49da1a496180c4d25176 Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Fri, 8 May 2026 21:20:40 +0700 Subject: [PATCH 07/13] windows: pass build-hook assets through link hook unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link hook called CLinker.library(... LinkerOptions.manual(...)) with no `sources`. native_toolchain_c.runCl translates that into: cl.exe /O2 /LD /Fe:thermion_dart.dll /link /OPT:REF /MACHINE:X64 /LIBPATH: i.e. cl.exe with no inputs at all, which exits immediately with `cl : Command line error D8003 : missing source filename`. Under `flutter test` the link hook is not invoked, so this only surfaces during a real `flutter build windows --release` — where it shows up as the terse "Linking native assets failed" through MSBuild, with the actual D8003 stranded inside the per-package build.log in the pub cache. The link phase here is optional: the build hook already produced thermion_dart.dll and added a CodeAsset for it. Pass those build-hook assets through unchanged on Windows. Keep the CLinker call on platforms where it currently works. --- thermion_dart/hook/link.dart | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/thermion_dart/hook/link.dart b/thermion_dart/hook/link.dart index a191fb7c..13ed63aa 100644 --- a/thermion_dart/hook/link.dart +++ b/thermion_dart/hook/link.dart @@ -12,6 +12,26 @@ void main(List args) async { var pkgRootFilePath = packageRoot.toFilePath(windows: Platform.isWindows); final logger = createLogger(pkgRootFilePath, "link.log"); + // The CLinker.library(... LinkerOptions.manual(...)) call below + // delegates to native_toolchain_c.runCl on Windows, which builds a + // cl.exe command line from the constructor's `sources` list. Our + // call passes no sources (the build hook already produced + // thermion_dart.dll), so cl.exe is invoked with only flags and exits + // immediately with `cl : Command line error D8003: missing source + // filename`. The link phase is optional here — pass the build + // hook's code assets through unchanged on Windows. Keep the + // CLinker call on platforms where it currently works. + if (input.config.code.targetOS == OS.windows) { + for (final asset in input.assets.code) { + output.assets.code.add(asset); + } + logger.info( + "Link step skipped on Windows; passed through " + "${input.assets.code.length} code asset(s).", + ); + return; + } + final clinker = CLinker.library( name: "thermion_dart", linkerOptions: LinkerOptions.manual(stripDebug: false)); From 341fc8cc2e3e8dc016e5c2c5595f51038e1c6680 Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Fri, 8 May 2026 21:20:53 +0700 Subject: [PATCH 08/13] windows: PUBLIC plugin include scope, drop bogus include_directories call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `thermion_flutter_plugin`'s own translation units include Filament headers (transitively, via `WindowsVulkanContext.h` → `Platform.h`), but `target_include_directories(... INTERFACE ...)` only exposes the DART_PKG_HEADERS paths to consumers, not to the plugin itself. cl.exe then could not find `` headers when compiling the plugin, producing C3083 / C2039 / C4430 on filament headers. Switch the scope to PUBLIC so the plugin's own compile and any consumer both see the headers. Also drop the immediately-following include_directories(${PLUGIN_NAME} INTERFACE ...) call. `include_directories()` accepts only path arguments — the target name and `INTERFACE` keyword are silently treated as bogus "directory" entries, so the call doesn't do what its author intended; it just adds two non-existent paths to the directory-level include set. --- .../thermion_flutter/windows/CMakeLists.txt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/thermion_flutter/thermion_flutter/windows/CMakeLists.txt b/thermion_flutter/thermion_flutter/windows/CMakeLists.txt index 2ba2c059..365cf7e7 100644 --- a/thermion_flutter/thermion_flutter/windows/CMakeLists.txt +++ b/thermion_flutter/thermion_flutter/windows/CMakeLists.txt @@ -29,16 +29,21 @@ target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) set(GENERATED_HEADERS_CMAKE "${CMAKE_CURRENT_SOURCE_DIR}/../.dart_tool/generated_headers.cmake") include("${GENERATED_HEADERS_CMAKE}") -target_include_directories(${PLUGIN_NAME} INTERFACE +# PUBLIC scope: thermion_flutter_plugin.cpp itself needs the Filament +# headers (Platform.h transitively pulls in `utils::io::ostream`, etc.) +# AND any consumer that includes thermion_flutter_plugin.h needs them +# too. The previous INTERFACE-only scope hid the headers from this +# target's own compile, so `error C3083: 'io': the symbol to the left +# of a '::' must be a type` triggered on Platform.h:99. +# +# The second `include_directories(${PLUGIN_NAME} INTERFACE ...)` call +# below is also invalid CMake syntax — `include_directories` takes +# only paths, no target or scope keyword — so it silently does +# nothing. Removed. +target_include_directories(${PLUGIN_NAME} PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}" - "${DART_PKG_HEADERS}" -) - -include_directories(${PLUGIN_NAME} INTERFACE - "${CMAKE_CURRENT_SOURCE_DIR}/include" - "${CMAKE_CURRENT_SOURCE_DIR}" - "${DART_PKG_HEADERS}" + ${DART_PKG_HEADERS} ) get_cmake_property(_variableNames VARIABLES) From 1c0864d8e0783d420a710e04a9a9967d19b805ce Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Fri, 8 May 2026 21:21:21 +0700 Subject: [PATCH 09/13] windows: forward-declare utils::io::ostream in plugin header Filament's `backend/Platform.h` friend-declares `utils::io::ostream& operator<<(...)` without first declaring the nested namespace. Sibling Filament headers like `backend/DriverEnums.h` carry the forward declaration namespace utils::io { class ostream; } so the rest of Filament compiles cleanly. The plugin's translation unit reaches `Platform.h` (via `WindowsVulkanContext.h`) before any of those siblings, leaving the namespace undeclared and cl.exe rejecting the friend with C3083 ("the symbol to the left of '::' must be a type"), C2039 ("'ostream': is not a member of 'utils'"), and C4430. Mirror Filament's own forward declaration in the plugin header, just before the WindowsVulkanContext include. Pulling the full `` would also work but drags in more than the friend needs; the namespace + class fwd-decl matches what DriverEnums.h does. --- .../windows/thermion_flutter_plugin.h | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h b/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h index 46b9d70a..82af6e08 100644 --- a/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h +++ b/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h @@ -14,6 +14,20 @@ #include "windows/import.h" +// Filament's `backend/Platform.h` (pulled in transitively by +// `WindowsVulkanContext.h` below) friend-declares +// `utils::io::ostream& operator<<(...)` without first declaring the +// nested namespace itself. Sibling Filament headers like +// `backend/DriverEnums.h` carry the forward declaration so the rest +// of Filament compiles cleanly, but this plugin's translation unit +// reaches `Platform.h` before any of them, leaving the namespace +// undeclared and cl.exe rejecting the friend with +// C3083 / C2039 / C4430. Mirror Filament's own forward declaration +// here so the friend resolves. +namespace utils::io { +class ostream; +} // namespace utils::io + #include "vulkan/windows/WindowsVulkanContext.h" namespace thermion::tflutter::windows { From 7fb4cd36d2e88e565b2da63360bad07c566f349e Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Sun, 10 May 2026 22:54:38 +0700 Subject: [PATCH 10/13] windows: don't error in cmake config when generated_headers.cmake is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `thermion_flutter`'s build hook produces `generated_headers.cmake` from `thermion_dart`'s metadata during `flutter assemble dart_build`, which runs as a custom build step inside MSBuild AFTER cmake config completes. On a fresh checkout the file doesn't exist when cmake first reads `windows/CMakeLists.txt`, so the unconditional `include()` errored with "include could not find requested file" and aborted cmake — leaving the build stuck because the dart_build step never got to run, so the file was never produced. The only way out was to fire the hook out of band first (e.g. `flutter test`) and re-run `flutter run`. Include conditionally and register the file as a configure dependency. On a clean checkout this means a two-pass build: 1. First pass: file missing, cmake succeeds with empty DART_PKG_HEADERS, dart_build runs and writes the file. Plugin compile fails because the Filament headers are not on the include path yet. 2. Second pass: cmake re-runs (CMAKE_CONFIGURE_DEPENDS noticed the new file), DART_PKG_HEADERS is populated, plugin compile succeeds. Subsequent builds are no-ops once the file is settled. Status message points the user at the recovery path so the first-pass plugin failure is self-explanatory. --- .../thermion_flutter/windows/CMakeLists.txt | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/thermion_flutter/thermion_flutter/windows/CMakeLists.txt b/thermion_flutter/thermion_flutter/windows/CMakeLists.txt index 365cf7e7..bea37109 100644 --- a/thermion_flutter/thermion_flutter/windows/CMakeLists.txt +++ b/thermion_flutter/thermion_flutter/windows/CMakeLists.txt @@ -27,7 +27,34 @@ target_compile_features(${PLUGIN_NAME} PUBLIC cxx_std_20) target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) set(GENERATED_HEADERS_CMAKE "${CMAKE_CURRENT_SOURCE_DIR}/../.dart_tool/generated_headers.cmake") -include("${GENERATED_HEADERS_CMAKE}") + +# `generated_headers.cmake` is produced by `thermion_flutter`'s build hook +# (`hook/build.dart`) during `flutter assemble dart_build`, which runs as a +# custom build step inside MSBuild AFTER cmake config completes. On a fresh +# checkout the file does not yet exist, so an unconditional `include()` +# would error out (`include could not find requested file`) and abort cmake +# — preventing the dart_build step from ever running and leaving a fresh +# `flutter run` / `flutter build windows` permanently stuck unless the user +# knows to fire the hook out of band first (e.g. via `flutter test`). +# +# Include conditionally and register the file as a configure dependency so +# that, once dart_build produces it, the next build invocation +# auto-regenerates cmake and the plugin compile picks up the correct +# `DART_PKG_HEADERS`. On a clean checkout this means a two-pass build: +# first pass populates the file (plugin compile fails because +# DART_PKG_HEADERS is empty), second pass succeeds. +if(EXISTS "${GENERATED_HEADERS_CMAKE}") + include("${GENERATED_HEADERS_CMAKE}") +else() + message(STATUS + "thermion_flutter: ${GENERATED_HEADERS_CMAKE} not yet present. " + "Cmake will continue with an empty DART_PKG_HEADERS so that " + "`flutter assemble dart_build` can run and produce the file; " + "the next build will pick it up automatically.") + set(DART_PKG_HEADERS "") +endif() +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${GENERATED_HEADERS_CMAKE}") # PUBLIC scope: thermion_flutter_plugin.cpp itself needs the Filament # headers (Platform.h transitively pulls in `utils::io::ostream`, etc.) From f2145bd94f1712afda8a85331b8bc98a9557d779 Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Mon, 11 May 2026 11:53:35 +0700 Subject: [PATCH 11/13] ffi: don't reassign _voidCallbackNativeCallable per withVoidCallback call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withVoidCallback was reassigning the file-scope `_voidCallbackNativeCallable` on every invocation, orphaning the previous NativeCallable. Native code calling back through the previous trampoline pointer would then race the Dart GC: once the orphan was finalized, the trampoline's entry in the VM's FFI callback metadata table was removed, and the next invocation hit a `RELEASE_ASSERT` inside `dart::DLRT_GetFfiCallbackMetadata` and called `abort()`, taking down the embedder. Surfaced reliably on Windows multi-viewer (8 viewers × ~60 fps × multiple FFI awaits per frame): GC swept a stale trampoline within ~20 s and the Flutter window died as "Not Responding" while VS caught "Unhandled exception at flutter_windows.dll: Fatal program exit requested." The call stack chain was `abort` → `dart::Assert::Fail` → `dart::DLRT_GetFfiCallbackMetadata` → JIT-compiled Dart trampoline → `thermion_dart` callback site. The module-scoped NativeCallable initialised on its `late` declaration is permanently referenced and its handler (`_voidCallbackHandler`) is stateless — it dispatches by `requestId` through the `_requests` map. A single trampoline is sufficient and correct; reassignment was unnecessary and unsafe. Regression origin: upstream commit 760ae8ed8 ("add makeInt32List method") introduced the reassignment as a drive-by change inside an unrelated commit; no rationale documented. This restores the pre-760ae8ed8 lifetime discipline, matching withPointerCallback / withBoolCallback / etc. which all use local NativeCallables and close() them after the round-trip. Verified on Windows 11 + Intel UHD Graphics + Flutter stable: 8-viewer Thermion stress test ran indefinitely without VM abort. --- thermion_dart/lib/src/bindings/src/ffi.dart | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/thermion_dart/lib/src/bindings/src/ffi.dart b/thermion_dart/lib/src/bindings/src/ffi.dart index 5b54ddce..9aea4bb7 100644 --- a/thermion_dart/lib/src/bindings/src/ffi.dart +++ b/thermion_dart/lib/src/bindings/src/ffi.dart @@ -132,8 +132,24 @@ Future withVoidCallback( final completer = Completer(); _requests[requestId] = completer; - _voidCallbackNativeCallable = - NativeCallable.listener(_voidCallbackHandler); + // Use the module-scoped `_voidCallbackNativeCallable` initialised at + // file load. Do NOT reassign it per call: every reassignment orphans + // the previous NativeCallable, which native code may still hold a + // pointer to. When Dart GC sweeps the orphan, its trampoline + // metadata is freed; the next invocation from the native side + // calls a removed trampoline, hits the + // `DLRT_GetFfiCallbackMetadata` release-assert, and the VM aborts. + // On Windows multi-viewer (8 viewers × ~60 fps × multiple FFI + // awaits per frame), churn is high enough that GC reliably sweeps + // a stale entry within ~20 seconds and the embedder dies — observed + // as "Not Responding" on the main window. The single global + // listener's handler (`_voidCallbackHandler`) is stateless; it + // dispatches by `requestId` through `_requests`, so one trampoline + // is sufficient and correct. + // + // Upstream introduced the reassignment in 760ae8ed8 as a drive-by + // change inside an unrelated commit ("add makeInt32List method"). + // To file upstream once verified. func.call(requestId, _voidCallbackNativeCallable.nativeFunction.cast()); await completer.future; From 80cba47cf3e37120828f7a02b07bdf3967cd0964 Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Mon, 11 May 2026 11:53:59 +0700 Subject: [PATCH 12/13] windows: bound Blit's vkWaitForFences; skip graveyard drain on timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WindowsVulkanContext::Blit's two vkWaitForFences calls used UINT64_MAX (infinite wait), and its post-blit graveyard-drain path called vkDeviceWaitIdle (no timeout parameter at the Vulkan API level). Blit runs on the Flutter UI thread (called from ThermionFlutterPlugin::HandleMethodCall for markTextureFrameAvailable), and the Vulkan→D3D11 shared-handle fence chain between this thread and Flutter's compositor on its raster thread is mutually dependent: the GPU can't signal Blit's fence until the compositor has consumed the previous frame, and the compositor needs the UI thread to pump Win32 messages to advance. With unbounded waits, a three-way wedge hung the entire app — UI thread → GPU → compositor → UI thread — manifesting as "Not Responding" after sustained multi-viewer load. Both fence waits now bound to 50 ms: - Pre-submit wait timeout: return early, skip this frame's blit. Next frame retries. Visual effect under sustained backpressure is one stale frame, recoverable; previously the entire window locked up. - Post-submit wait timeout: log and continue. The submit was accepted by the queue; the shared texture's keyed mutex serialises D3D11's read on its own thread, so returning early doesn't tear. The next call's pre-submit wait reconciles. If the post-submit wait timed out, the graveyard drain is skipped entirely. Otherwise we'd fall into vkDeviceWaitIdle which has no timeout parameter in the Vulkan API — when the GPU is busy enough that our fence wait just timed out, vkDeviceWaitIdle would block the UI thread forever, recreating the original hang on a different line. Graveyard items accumulate briefly; drain resumes the moment a Blit completes cleanly. This fix alone doesn't eliminate UI-thread blocking — it makes the worst case bounded (~50 ms per call) rather than infinite. The follow-up commit moves Blit off the UI thread entirely via a dedicated worker thread + bounded queue, which is what restores responsiveness under load. --- .../vulkan/windows/WindowsVulkanContext.cpp | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/thermion_dart/native/src/vulkan/windows/WindowsVulkanContext.cpp b/thermion_dart/native/src/vulkan/windows/WindowsVulkanContext.cpp index 7496ed10..72adc6ad 100644 --- a/thermion_dart/native/src/vulkan/windows/WindowsVulkanContext.cpp +++ b/thermion_dart/native/src/vulkan/windows/WindowsVulkanContext.cpp @@ -414,8 +414,29 @@ class WindowsVulkanContext::Impl { // submitInfo.signalSemaphoreCount = 1; // submitInfo.pSignalSemaphores = &sharedSemaphore; - // Wait for any previous blit to complete and reset fence - result = bluevk::vkWaitForFences(device, 1, &blitFence, VK_TRUE, UINT64_MAX); + // Wait for any previous blit to complete and reset fence. + // + // Bounded to 50 ms. With UINT64_MAX we deadlocked the whole + // app on Windows multi-viewer at sustained 60 fps × N + // viewers: Blit runs on the Flutter UI thread (called from + // ThermionFlutterPlugin::HandleMethodCall for + // markTextureFrameAvailable), the keyed-mutex-style fence + // chain between Vulkan (writer) and D3D11 (Flutter + // compositor reader on its own raster thread) needs the + // UI thread to pump Win32 messages for the compositor to + // advance, and the GPU can't signal this fence until the + // compositor has consumed the previous frame. Three-way + // wait — UI thread → GPU → compositor → UI thread. + // + // On timeout, skip this frame's blit entirely so the UI + // thread returns, the Win32 message pump dispatches, + // Flutter's compositor drains, and the next Blit retries. + // Visual effect is one stale frame on a backed-up GPU; + // far preferable to a hard hang. + result = bluevk::vkWaitForFences(device, 1, &blitFence, VK_TRUE, 50'000'000); + if (result == VK_TIMEOUT) { + return; + } if (result != VK_SUCCESS) { std::cerr << "vkWaitForFences failed: " << result << std::endl; return; @@ -428,9 +449,23 @@ class WindowsVulkanContext::Impl { return; } - // Wait for blit to complete before returning - result = bluevk::vkWaitForFences(device, 1, &blitFence, VK_TRUE, UINT64_MAX); - if (result != VK_SUCCESS) { + // Wait for blit to complete before returning. + // + // Bounded to 50 ms for the same reason as above. On + // timeout we return without an error: the submit has + // been accepted by the queue, the keyed mutex on the + // shared texture will serialise D3D11's read on its own + // thread, and the *next* call's pre-submit wait above + // will catch up. Returning unblocks the UI thread. + bool postBlitTimedOut = false; + result = bluevk::vkWaitForFences(device, 1, &blitFence, VK_TRUE, 50'000'000); + if (result == VK_TIMEOUT) { + postBlitTimedOut = true; + std::cerr << "vkWaitForFences (post-blit) timed out after 50 ms; " + "GPU still processing. Returning to unblock UI thread; " + "next Blit will sync." + << std::endl; + } else if (result != VK_SUCCESS) { std::cerr << "vkWaitForFences (post-blit) failed: " << result << std::endl; } @@ -439,14 +474,37 @@ class WindowsVulkanContext::Impl { // owns them), so clearing _graveyardRT just frees the wrapper. // The D3D and interop textures are freed here after the driver // has retired all references. - if (!_graveyardD3D.empty()) { + // + // SKIP the drain entirely if the post-blit wait just timed + // out — `vkDeviceWaitIdle` has NO timeout parameter in the + // Vulkan API, and if the GPU is busy enough that our + // bounded fence wait timed out, `vkDeviceWaitIdle` will + // block this thread (the Flutter UI thread) forever, which + // is exactly the original hang we're trying to fix. + // Defer the drain to a future Blit call when the GPU + // has caught up. The graveyard accumulates briefly but + // bounded: drain resumes as soon as steady-state catches + // up. Diagnostic stderr around the drain confirms whether + // this skip path is being hit and whether `vkDeviceWaitIdle` + // ever returns when we do invoke it. + if (!_graveyardD3D.empty() && !postBlitTimedOut) { _graveyardFrames++; if (_graveyardFrames >= GRAVEYARD_DRAIN_FRAMES) { + std::cerr << "Blit: graveyard drain start (size=" + << _graveyardD3D.size() + << ", frames=" << _graveyardFrames << ")" + << std::endl; bluevk::vkDeviceWaitIdle(device); + std::cerr << "Blit: graveyard drain done (vkDeviceWaitIdle returned)" + << std::endl; _graveyardRT.clear(); _graveyardVk.clear(); _graveyardD3D.clear(); } + } else if (!_graveyardD3D.empty() && postBlitTimedOut) { + std::cerr << "Blit: graveyard drain DEFERRED (postBlit timed out, size=" + << _graveyardD3D.size() << ")" + << std::endl; } } From 6b253b22a997f5d13d41c1d47b6d5daf049fdd00 Mon Sep 17 00:00:00 2001 From: hoanDellLaptop Date: Mon, 11 May 2026 11:54:23 +0700 Subject: [PATCH 13/13] windows: move Blit off Flutter UI thread (dedicated worker + bounded queue) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preceding commit bounded Blit's vkWaitForFences to 50 ms, but under sustained multi-viewer load (8 viewers × Filament frame completions) the Flutter UI thread was still spending most of its wall-clock budget inside markTextureFrameAvailable → Blit → fence wait. The Win32 message pump couldn't keep up; Windows declared the window "Not Responding" even though the process kept rendering. First attempt at moving the work off the UI thread spawned a detached std::thread per call. That immediately crashed inside igvk64.dll (Intel's Vulkan driver) with a 0xC0000005 access violation, because WindowsVulkanContext::Blit reuses a shared command pool / queue / fence / command buffer that the Vulkan spec requires the application to externally synchronise — multiple threads calling Blit at once raced on those resources and the Intel driver dereferenced cleared state. Correct fix: one dedicated worker thread, one std::queue, one Blit in flight at any instant. The UI thread enqueues (handle + flutterTextureId + context + registrar captured by value) and returns immediately; the worker drains the queue, calls context->Blit(handle) then registrar->MarkTextureFrameAvailable(fid) serially, so the per-Blit Vulkan resources are never touched concurrently. The queue is bounded to kMaxBlitQueueDepth (16). When the worker falls behind the producer (i.e. the GPU/compositor is saturated), the oldest enqueued job is dropped. Visual effect under overload is one stale frame from a previous render reaching the compositor — graceful degradation in place of a hard hang or driver crash. The worker is lazy-started on the first EnqueueBlit call via a compare_exchange_strong on an atomic guard, so apps that never enter a Thermion screen never spin up the worker. The plugin destructor signals _blitWorkerShouldStop, notifies the cv, and joins — clean teardown across hot restart and app exit. The worker drains the queue before exiting. Verified on Windows 11 + Intel UHD Graphics: 8-viewer Thermion stress screen runs indefinitely (including across a Windows lock screen / unlock cycle) with no "Not Responding", no Intel driver crash, no UI thread saturation. flutter::TextureRegistrar::MarkTextureFrameAvailable and flutter::MethodResult::Success are both documented thread-safe in the Windows embedder, so calling them from the worker thread and from the UI thread respectively is correct. --- .../windows/thermion_flutter_plugin.cpp | 127 +++++++++++++++++- 1 file changed, 122 insertions(+), 5 deletions(-) diff --git a/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.cpp b/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.cpp index 515922d5..fab6f5e4 100644 --- a/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.cpp +++ b/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.cpp @@ -27,6 +27,10 @@ #include #include #include +#include +#include +#include +#include #include "flutter_d3d_texture.h" @@ -66,14 +70,111 @@ namespace thermion::tflutter::windows { this->HandleMethodCall(call, std::move(result)); }); } - ThermionFlutterPlugin::~ThermionFlutterPlugin() { - StopFrameScheduler(); - } - // this is only for storing Flutter surface descriptors // (as opposed to the D3D/Vulkan handles, which are stored in the WindowsVulkanContext) static std::vector> _flutterTextures; + // ──────────────────────────────────────────────────────────────── + // Dedicated Blit-worker thread + // ──────────────────────────────────────────────────────────────── + // + // Why: `markTextureFrameAvailable` was originally synchronous on + // the Flutter UI thread, calling `WindowsVulkanContext::Blit` which + // includes vkQueueSubmit + vkWaitForFences. Under multi-viewer + // load (8 viewers × Filament frames) this saturated the UI + // thread, killed the Win32 message pump, and Windows declared + // "Not Responding". A first naive fix spawned a detached + // `std::thread` per call — but `WindowsVulkanContext::Blit`'s + // shared command pool / queue / fence are NOT thread-safe (the + // Vulkan spec requires app-side synchronisation of VkQueue + // access), and the Intel driver crashed with `0xC0000005` + // access-violation inside `igvk64.dll` as soon as two Blits + // overlapped. + // + // Correct fix: ONE worker thread, ONE queue, one Blit in flight + // at any moment. The UI thread enqueues and returns immediately. + // If the queue grows past kMaxBlitQueueDepth the OLDEST entry is + // dropped — Flutter shows a previously-blitted texture for one + // frame, which is preferable to unbounded queue growth under + // sustained GPU pressure. + struct BlitJob { + thermion::vulkan::windows::WindowsVulkanContext* context; + flutter::TextureRegistrar* registrar; + HANDLE handle; + int64_t flutterTextureId; + }; + + static constexpr size_t kMaxBlitQueueDepth = 16; + + static std::mutex _blitMutex; + static std::condition_variable _blitCv; + static std::queue _blitQueue; + static std::thread _blitWorker; + static std::atomic _blitWorkerStarted{false}; + static std::atomic _blitWorkerShouldStop{false}; + + static void BlitWorkerLoop() { + while (true) { + BlitJob job; + { + std::unique_lock lock(_blitMutex); + _blitCv.wait(lock, [] { + return !_blitQueue.empty() || _blitWorkerShouldStop.load(); + }); + if (_blitWorkerShouldStop.load() && _blitQueue.empty()) { + return; + } + job = _blitQueue.front(); + _blitQueue.pop(); + } + if (job.context) { + job.context->Blit(job.handle); + } + if (job.registrar) { + job.registrar->MarkTextureFrameAvailable(job.flutterTextureId); + } + } + } + + static void EnsureBlitWorkerStarted() { + bool expected = false; + if (_blitWorkerStarted.compare_exchange_strong(expected, true)) { + _blitWorker = std::thread(BlitWorkerLoop); + } + } + + static void EnqueueBlit(BlitJob&& job) { + EnsureBlitWorkerStarted(); + { + std::lock_guard lock(_blitMutex); + // Bounded queue — drop oldest under sustained GPU pressure + // rather than growing without limit. Visual effect under + // overload: one stale frame, no hang, no crash. + while (_blitQueue.size() >= kMaxBlitQueueDepth) { + _blitQueue.pop(); + } + _blitQueue.push(std::move(job)); + } + _blitCv.notify_one(); + } + + ThermionFlutterPlugin::~ThermionFlutterPlugin() { + StopFrameScheduler(); + + // Stop the Blit worker thread cleanly. We signal the stop flag, + // wake the worker (it may be sleeping on the condition variable + // with an empty queue), and join. The worker drains queued + // jobs first, then returns. Safe against hot restart / app + // teardown. + if (_blitWorkerStarted.load()) { + _blitWorkerShouldStop.store(true); + _blitCv.notify_all(); + if (_blitWorker.joinable()) { + _blitWorker.join(); + } + } + } + void ThermionFlutterPlugin::CreateTexture( const flutter::MethodCall &methodCall, std::unique_ptr> result) @@ -294,8 +395,24 @@ namespace thermion::tflutter::windows _pendingSwaps.erase(swapIt); } } else { + // Enqueue the Blit + MarkTextureFrameAvailable on the + // dedicated worker thread. Synchronous Blit on the UI + // thread saturates the Win32 message pump under multi- + // viewer load and the per-call detached-thread variant + // crashed inside the Intel Vulkan driver because Blit's + // command pool / queue / fence are not thread-safe. The + // worker thread serialises all Blits process-wide and + // returns the UI thread immediately. See BlitWorkerLoop + // / EnqueueBlit above. HANDLE d3dTextureHandle = (*it)->GetD3DTextureHandle(); - _context->Blit(d3dTextureHandle); + EnqueueBlit(BlitJob{ + _context, + _textureRegistrar, + d3dTextureHandle, + *flutterTextureId, + }); + result->Success(flutter::EncodableValue((int64_t) nullptr)); + return; } _textureRegistrar->MarkTextureFrameAvailable(*flutterTextureId);