diff --git a/thermion_dart/hook/build.dart b/thermion_dart/hook/build.dart index 89ddf4b46..573866d7b 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', @@ -110,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', }; @@ -252,7 +264,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(), ]); } @@ -418,10 +434,17 @@ 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 — + // 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], diff --git a/thermion_dart/hook/link.dart b/thermion_dart/hook/link.dart index a191fb7c9..13ed63aab 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)); diff --git a/thermion_dart/hook/log.dart b/thermion_dart/hook/log.dart index 25e24943c..e872bcb5a 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 diff --git a/thermion_dart/lib/src/bindings/src/ffi.dart b/thermion_dart/lib/src/bindings/src/ffi.dart index 5b54ddcea..9aea4bb7b 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; 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 diff --git a/thermion_dart/native/src/vulkan/windows/WindowsVulkanContext.cpp b/thermion_dart/native/src/vulkan/windows/WindowsVulkanContext.cpp index 7496ed107..72adc6ad4 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; } } 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 5e1c6343b..7e33fc8d0 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); } } diff --git a/thermion_flutter/thermion_flutter/windows/CMakeLists.txt b/thermion_flutter/thermion_flutter/windows/CMakeLists.txt index 2ba2c0598..bea37109f 100644 --- a/thermion_flutter/thermion_flutter/windows/CMakeLists.txt +++ b/thermion_flutter/thermion_flutter/windows/CMakeLists.txt @@ -27,18 +27,50 @@ 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}") -target_include_directories(${PLUGIN_NAME} INTERFACE +# `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.) +# 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) diff --git a/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.cpp b/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.cpp index 515922d5a..fab6f5e43 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); diff --git a/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h b/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h index 46b9d70a1..82af6e082 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 {