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 81ca9a762..5e372bc17 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 @@ -904,6 +904,13 @@ class FFIFilamentApp extends FilamentApp { } swapChain = _swapChains.first; } + + // Capture uses one immutable attachment list for both its primary + // render/readback loop and the WebGL completion frame. Attachment state + // can change while this method awaits render-thread work; querying it per + // view would mix two different frame configurations. + final viewAttachments = renderManager.getViewAttachments(swapChain); + var beginFrame = false; const MAX_BEGIN_FRAME_RETRIES = 3; @@ -925,14 +932,23 @@ class FFIFilamentApp extends FilamentApp { final pixelBuffers = <(View, Uint8List)>[]; - final views = []; + final captureAttachments = []; if (view != null) { - views.add(view); + ViewAttachment? requestedAttachment; + for (final attachment in viewAttachments) { + if (attachment.view == view) { + requestedAttachment = attachment; + break; + } + } + captureAttachments.add(requestedAttachment ?? ViewAttachment(view: view, order: 0, renderable: true)); _logger.finest("Using provided view"); } else { - views.addAll(await renderManager.getAttachedViews(swapChain)); + captureAttachments.addAll(viewAttachments); } + final views = captureAttachments.map((attachment) => attachment.view).toList(growable: false); + for (final view in views) { final vp = await view.getViewport(); if (vp.width == 0 || vp.height == 0) { @@ -958,6 +974,7 @@ class FFIFilamentApp extends FilamentApp { for (var viewIndex = 0; viewIndex < views.length; viewIndex++) { final view = views[viewIndex]; + final attachment = captureAttachments[viewIndex]; final renderTarget = await view.getRenderTarget(); bool hasRenderTarget = renderTarget != null; _logger.finest( @@ -981,7 +998,7 @@ class FFIFilamentApp extends FilamentApp { final readType = readAsUByteForFloat ? PixelDataType.UBYTE : pixelDataType; inflateFromUByte.add(readAsUByteForFloat); - beforeRender?.call(view); + await beforeRender?.call(view); final viewport = await view.getViewport(); @@ -1008,7 +1025,7 @@ class FFIFilamentApp extends FilamentApp { final numBytes = viewport.width * viewport.height * numChannels * channelSizeInBytes; final pixelBuffer = makeUint8List(numBytes); - if (render) { + if (render && attachment.renderable) { await withVoidCallback((requestId, cb) { Renderer_renderRenderThread(renderer, view.getNativeHandle(), requestId, cb); }); @@ -1057,9 +1074,9 @@ class FFIFilamentApp extends FilamentApp { await withBoolCallback( (cb) => Renderer_beginFrameRenderThread(renderer, swapChain!.getNativeHandle(), 0.toBigInt, cb), ); - for (final view in views) { + for (final attachment in captureAttachments.where((attachment) => attachment.renderable)) { await withVoidCallback((requestId, cb) { - Renderer_renderRenderThread(renderer, view.getNativeHandle(), requestId, cb); + Renderer_renderRenderThread(renderer, attachment.view.getNativeHandle(), requestId, cb); }); } await withVoidCallback((requestId, cb) { @@ -1094,6 +1111,7 @@ class FFIFilamentApp extends FilamentApp { if (FILAMENT_WASM) { stackRestore(stackPtr); } + RenderManager_setPaused(renderManager.getNativeHandle(), false); return result; } diff --git a/thermion_dart/lib/src/filament/src/implementation/ffi_render_manager.dart b/thermion_dart/lib/src/filament/src/implementation/ffi_render_manager.dart index 0b192431b..8ebb48d3b 100644 --- a/thermion_dart/lib/src/filament/src/implementation/ffi_render_manager.dart +++ b/thermion_dart/lib/src/filament/src/implementation/ffi_render_manager.dart @@ -27,6 +27,10 @@ class RenderAttachmentState { _renderable[view] = renderable; } + void setRenderables(Map renderability) { + _renderable.addAll(renderability); + } + void detach(View view, {Pointer? swapChain}) { if (swapChain == null) { for (final views in _attachments.values) { @@ -42,13 +46,23 @@ class RenderAttachmentState { _attachments.remove(swapChain); } - Map, List<(int, View)>> activeSnapshot() { + Map, List<(int, View)>> renderableSnapshot() { return { for (final entry in _attachments.entries) entry.key: entry.value.where((attachment) => _renderable[attachment.$2] ?? true).toList(), }; } + List getViewAttachments(Pointer swapChain) { + final attachments = _attachments[swapChain] ?? const <(int, View)>[]; + return List.unmodifiable( + attachments.map( + (attachment) => + ViewAttachment(view: attachment.$2, order: attachment.$1, renderable: _renderable[attachment.$2] ?? true), + ), + ); + } + Iterable getAttachedViews(Pointer swapChain) => _attachments[swapChain]?.map((entry) => entry.$2) ?? []; @@ -142,8 +156,13 @@ class FFIRenderManager extends RenderManager> { @override Future setRenderable(View view, bool renderable) { + return setRenderables({view: renderable}); + } + + @override + Future setRenderables(Map renderability) { return _serialize(() async { - _attachmentState.setRenderable(view, renderable); + _attachmentState.setRenderables(renderability); await _syncViews(); }); } @@ -173,7 +192,7 @@ class FFIRenderManager extends RenderManager> { // Snapshotting also tolerates removal: if an entry was deleted // by the time we get back to it, the views list will be null // and we skip it. - final snapshot = _attachmentState.activeSnapshot(); + final snapshot = _attachmentState.renderableSnapshot(); for (final swapChainHandle in snapshot.keys) { final views = snapshot[swapChainHandle]; @@ -233,7 +252,12 @@ class FFIRenderManager extends RenderManager> { @override Iterable getAttachedViews(SwapChain swapChain) { - return _attachmentState.getAttachedViews(swapChain.getNativeHandle()); + return getViewAttachments(swapChain).map((attachment) => attachment.view); + } + + @override + List getViewAttachments(SwapChain swapChain) { + return _attachmentState.getViewAttachments(swapChain.getNativeHandle()); } @override diff --git a/thermion_dart/lib/src/filament/src/interface/render_manager.dart b/thermion_dart/lib/src/filament/src/interface/render_manager.dart index be8ec44e1..4d86889eb 100644 --- a/thermion_dart/lib/src/filament/src/interface/render_manager.dart +++ b/thermion_dart/lib/src/filament/src/interface/render_manager.dart @@ -1,22 +1,162 @@ import 'package:thermion_dart/src/filament/src/interface/native_handle.dart'; import 'package:thermion_dart/thermion_dart.dart'; +/// Describes a [View] registered for rendering against a [SwapChain]. +/// +/// Attachments are ordered independently for each swapchain. They remain in +/// the attachment list when [renderable] is false, allowing rendering to be +/// suspended without losing the association or render order. +class ViewAttachment { + /// The attached view. + final View view; + + /// The view's position in the swapchain's render sequence. + /// + /// Lower values render first. The relative order of attachments with the + /// same value is unspecified. + final int order; + + /// Whether [view] is currently submitted when its swapchain is rendered. + /// + /// This does not describe scene visibility and does not affect the view's + /// render target. A non-renderable view is still attached. + final bool renderable; + + const ViewAttachment({required this.view, required this.order, required this.renderable}); +} + +/// Coordinates frame submission for the views and swapchains owned by a +/// [FilamentApp]. +/// +/// A swapchain identifies a surface that can begin and end a frame. A view +/// describes what Filament should render. [attach] associates the two so that +/// [render] can submit the view between that swapchain's begin/end-frame +/// calls. Multiple views can be attached to the same swapchain and are +/// submitted in ascending [ViewAttachment.order]. A view may also be attached +/// to more than one swapchain. +/// +/// Attachment controls *when* a view is submitted; it does not control *where* +/// the view draws. The latter is determined by [View.setRenderTarget] (or by +/// the swapchain when the view has no render target). Attaching or detaching a +/// view never changes its render target. +/// +/// Renderability is separate from attachment. [setRenderable] temporarily +/// excludes a view from every swapchain to which it is attached, while +/// preserving those associations and their ordering. This is useful for +/// optional passes such as overlays, which should be cheap to suspend and +/// resume without rebuilding their resources. +/// +/// For each rendered frame, the manager advances registered animations and +/// plugins once, then renders every swapchain that has at least one renderable +/// view. [FilamentApp.render] is the usual entry point because it runs Dart +/// request-frame hooks before delegating here. +/// +/// The manager tracks associations but does not own the attached [View] or +/// [SwapChain] objects. Detach them before destroying those resources. Calls +/// that mutate attachment state are asynchronous because the updated view +/// lists must be synchronized with the render thread. abstract class RenderManager extends NativeHandle { + /// Associates [view] with [swapChain] for future calls to [render]. + /// + /// Views with lower [renderOrder] values are submitted first. Attaching the + /// same view to the same swapchain again updates its order rather than + /// creating a duplicate attachment. Existing attachments to other + /// swapchains are unaffected. + /// + /// A view is renderable by default unless a previous [setRenderable] call + /// set its state before attachment. This method does not modify the view's + /// render target or take ownership of either object. + /// + /// The current native manager submits at most eight renderable views per + /// swapchain; additional renderable attachments are not rendered. Future attach(View view, SwapChain swapChain, {int renderOrder = 0}); - /// Includes or excludes [view] from rendering while preserving its - /// swapchain association. + /// Includes or excludes [view] from frame submission. + /// + /// The setting applies to every swapchain to which [view] is attached. A + /// value of `false` preserves the view's associations, render order, render + /// target, and scene; it only prevents this manager from submitting it. /// /// The requested state is retained when the view has not been attached yet, - /// so platform surfaces can be created asynchronously. + /// so callers can configure a view before its platform surface exists. + /// Await the returned future before assuming the render thread sees the new + /// view list. Future setRenderable(View view, bool renderable); + /// Applies several per-view renderability changes as one state update. + /// + /// All entries are applied to local attachment state before the updated + /// lists are synchronized with the render thread. Synchronous readers such + /// as [getViewAttachments] therefore do not observe partially-updated + /// combinations when related views, such as a group of composite passes, + /// are enabled or disabled together. + /// + /// As with [setRenderable], settings are retained for views that are not yet + /// attached and each setting applies across all attachments of that view. + Future setRenderables(Map renderability); + + /// Removes [view] from one or all swapchains. + /// + /// When [swapChain] is provided, only that association is removed and the + /// view's renderability setting is retained for its other or future + /// attachments. When it is omitted, every association is removed and the + /// retained renderability setting is cleared, so a later attachment starts + /// renderable by default. + /// + /// This method does not destroy the view or any swapchain. Await it before + /// destroying [view], so the render thread cannot retain a dangling native + /// view pointer. Future detach(View view, {SwapChain? swapChain}); + + /// Removes every view attachment for [swapChain]. + /// + /// Per-view renderability settings are retained, because the same views may + /// remain attached to other swapchains or be attached again later. This + /// method does not destroy the swapchain or its views. Await it before + /// destroying [swapChain]. Future detachAll(SwapChain swapChain); + + /// Returns an immutable, ordered copy of [swapChain]'s attachments. + /// + /// The list includes non-renderable views and is safe to retain across + /// asynchronous work: later attachment or renderability changes do not + /// alter it. Each entry records the state at the time of this call. + /// + /// This reads the manager's local state and does not wait for pending + /// mutations. Await calls such as [attach], [detach], or [setRenderable] + /// before reading when their completion matters. + List getViewAttachments(SwapChain swapChain); + + /// Returns all views currently attached to [swapChain] in render order. + /// + /// Non-renderable views are included. This is equivalent to mapping + /// [ViewAttachment.view] over [getViewAttachments]. Iterable getAttachedViews(SwapChain swapChain); + + /// Returns every swapchain to which [view] is currently attached. + /// + /// The view is included regardless of its renderability. The order of the + /// returned swapchains is unspecified. Iterable getAttachedSwapChains(View view); + /// Advances frame state and submits all currently renderable attachments. + /// + /// Animations and plugins are updated once for the frame. Each swapchain + /// with renderable attachments then begins a frame, renders its views in + /// ascending order, and ends the frame. Swapchains without renderable views + /// are skipped. + /// + /// On native platforms the returned future completes after the render-thread + /// operation. On web it requests rendering from the browser animation-frame + /// loop and can complete before that frame is presented. Most callers should + /// use [FilamentApp.render] so request-frame hooks run first. Future render(); + /// Destroys this manager and clears its attachment state. + /// + /// Attached views and swapchains are not destroyed. Normal application + /// teardown should detach and destroy them through [FilamentApp] before + /// destroying the manager. No method or native handle may be used after + /// this call. void destroy(); } diff --git a/thermion_dart/lib/src/filament/src/interface/view.dart b/thermion_dart/lib/src/filament/src/interface/view.dart index e4dc21408..372ef83d7 100644 --- a/thermion_dart/lib/src/filament/src/interface/view.dart +++ b/thermion_dart/lib/src/filament/src/interface/view.dart @@ -376,7 +376,21 @@ abstract class View extends NativeHandle { Future getViewport(); Future setViewport(int width, int height); Future getRenderTarget(); + + /// Binds a render target directly to this Filament view. + /// + /// Presentation systems that can insert additional render passes should use + /// [setPresentationRenderTarget] for the platform-owned output target. Future setRenderTarget(covariant RenderTarget? renderTarget); + + /// Sets the platform-owned target that should receive this view's final + /// presented image. + /// + /// Most views render directly into that target. Composite render pipelines + /// can override this method and route intermediate passes without changing + /// the low-level semantics of [setRenderTarget]. + Future setPresentationRenderTarget(covariant RenderTarget? renderTarget) => setRenderTarget(renderTarget); + Future setCamera(Camera? camera); Future getCamera(); Future setPostProcessing(bool enabled); diff --git a/thermion_dart/test/render_attachment_state_test.dart b/thermion_dart/test/render_attachment_state_test.dart index ad8952280..2bede5e2c 100644 --- a/thermion_dart/test/render_attachment_state_test.dart +++ b/thermion_dart/test/render_attachment_state_test.dart @@ -26,10 +26,10 @@ void main() { state.setRenderable(view, true); expect(state.getAttachedSwapChains(view), isEmpty); - expect(state.activeSnapshot(), isEmpty); + expect(state.renderableSnapshot(), isEmpty); state.attach(view, swapChain); - expect(state.activeSnapshot()[swapChain], [(0, view)]); + expect(state.renderableSnapshot()[swapChain], [(0, view)]); }); test('remembers a paused view before its first attachment', () { @@ -41,7 +41,7 @@ void main() { state.attach(view, swapChain); expect(state.getAttachedSwapChains(view), [swapChain]); - expect(state.activeSnapshot()[swapChain], isEmpty); + expect(state.renderableSnapshot()[swapChain], isEmpty); }); test('resumes the view on its existing swapchain', () { @@ -53,7 +53,7 @@ void main() { state.setRenderable(view, false); state.setRenderable(view, true); - expect(state.activeSnapshot()[swapChain], [(0, view)]); + expect(state.renderableSnapshot()[swapChain], [(0, view)]); }); test('preserves pause state while replacing a swapchain', () { @@ -68,7 +68,7 @@ void main() { state.detachAll(oldSwapChain); expect(state.getAttachedSwapChains(view), [replacementSwapChain]); - expect(state.activeSnapshot()[replacementSwapChain], isEmpty); + expect(state.renderableSnapshot()[replacementSwapChain], isEmpty); }); test('a fully detached view defaults to rendering when reused', () { @@ -82,7 +82,38 @@ void main() { state.detach(view); state.attach(view, newSwapChain); - expect(state.activeSnapshot()[newSwapChain], [(0, view)]); + expect(state.renderableSnapshot()[newSwapChain], [(0, view)]); + }); + + test('view attachments preserve order and non-renderable entries', () { + final state = RenderAttachmentState(); + final mainView = _TestView(1); + final overlayView = _TestView(2); + final swapChain = _swapChain(1); + + state.attach(overlayView, swapChain, renderOrder: 2); + state.attach(mainView, swapChain, renderOrder: 1); + state.setRenderable(overlayView, false); + + final attachments = state.getViewAttachments(swapChain); + expect(attachments.map((attachment) => attachment.view), [mainView, overlayView]); + expect(attachments.map((attachment) => attachment.order), [1, 2]); + expect(attachments.map((attachment) => attachment.renderable), [true, false]); + expect(attachments.where((attachment) => attachment.renderable).map((attachment) => attachment.view), [mainView]); + expect(() => attachments.add(attachments.first), throwsUnsupportedError); + }); + + test('updates grouped view state before returning attachments', () { + final state = RenderAttachmentState(); + final silhouetteView = _TestView(1); + final overlayView = _TestView(2); + final swapChain = _swapChain(1); + + state.attach(silhouetteView, swapChain, renderOrder: 0); + state.attach(overlayView, swapChain, renderOrder: 2); + state.setRenderables({silhouetteView: false, overlayView: false}); + + expect(state.getViewAttachments(swapChain).where((attachment) => attachment.renderable), isEmpty); }); }); } 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 1e4b26348..6cd578418 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 @@ -405,7 +405,7 @@ class NativeTextureSurfaceManager { // it in _viewRenderTargets or destroying the previous target. final swapChains = await app.getSwapChains(); await app.renderManager.attach(view, swapChains.first); - await view.setRenderTarget(renderTarget); + await view.setPresentationRenderTarget(renderTarget); } catch (error, stackTrace) { final rolledBack = await _destroyCreatedRenderTarget( renderTarget: renderTarget, @@ -596,7 +596,7 @@ class NativeTextureSurfaceManager { final swapChains = await app.getSwapChains(); await app.renderManager.attach(view, swapChains.first); await view.setViewport(width, height); - await view.setRenderTarget(renderTarget); + await view.setPresentationRenderTarget(renderTarget); } catch (error, stackTrace) { await _destroyCreatedRenderTarget( renderTarget: renderTarget, @@ -652,12 +652,7 @@ class NativeTextureSurfaceManager { return; } - final overlay = view.getHighlightOverlay(); - if (overlay == null) { - await view.setRenderTarget(null); - } else { - await overlay.overlayView.setRenderTarget(null); - } + await view.setPresentationRenderTarget(null); await _destroyRenderTarget(renderTarget); if (identical(_viewRenderTargets[view], renderTarget)) { _viewRenderTargets.remove(view);