From cc7754fe6cb9115e17327ce9ff8f0e472f78824b Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Mon, 24 Aug 2026 12:34:20 +0800 Subject: [PATCH 1/3] refactor: centralize presentation render plans --- .../src/implementation/ffi_filament_app.dart | 25 +++++++---- .../implementation/ffi_render_manager.dart | 28 ++++++++++++- .../src/interface/render_manager.dart | 41 +++++++++++++++++++ .../lib/src/filament/src/interface/view.dart | 14 +++++++ .../test/render_attachment_state_test.dart | 30 ++++++++++++++ .../src/native_texture_surface_manager.dart | 11 ++--- 6 files changed, 132 insertions(+), 17 deletions(-) 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..9f6b1dd19 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 plan 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 renderPlan = renderManager.getRenderPlan(swapChain); + var beginFrame = false; const MAX_BEGIN_FRAME_RETRIES = 3; @@ -925,14 +932,16 @@ class FFIFilamentApp extends FilamentApp { final pixelBuffers = <(View, Uint8List)>[]; - final views = []; + final capturePasses = []; if (view != null) { - views.add(view); + capturePasses.add(renderPlan.passFor(view) ?? RenderPass(view: view, order: 0, active: true)); _logger.finest("Using provided view"); } else { - views.addAll(await renderManager.getAttachedViews(swapChain)); + capturePasses.addAll(renderPlan.passes); } + final views = capturePasses.map((pass) => pass.view).toList(growable: false); + for (final view in views) { final vp = await view.getViewport(); if (vp.width == 0 || vp.height == 0) { @@ -958,6 +967,7 @@ class FFIFilamentApp extends FilamentApp { for (var viewIndex = 0; viewIndex < views.length; viewIndex++) { final view = views[viewIndex]; + final pass = capturePasses[viewIndex]; final renderTarget = await view.getRenderTarget(); bool hasRenderTarget = renderTarget != null; _logger.finest( @@ -981,7 +991,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 +1018,7 @@ class FFIFilamentApp extends FilamentApp { final numBytes = viewport.width * viewport.height * numChannels * channelSizeInBytes; final pixelBuffer = makeUint8List(numBytes); - if (render) { + if (render && pass.active) { await withVoidCallback((requestId, cb) { Renderer_renderRenderThread(renderer, view.getNativeHandle(), requestId, cb); }); @@ -1057,9 +1067,9 @@ class FFIFilamentApp extends FilamentApp { await withBoolCallback( (cb) => Renderer_beginFrameRenderThread(renderer, swapChain!.getNativeHandle(), 0.toBigInt, cb), ); - for (final view in views) { + for (final pass in capturePasses.where((pass) => pass.active)) { await withVoidCallback((requestId, cb) { - Renderer_renderRenderThread(renderer, view.getNativeHandle(), requestId, cb); + Renderer_renderRenderThread(renderer, pass.view.getNativeHandle(), requestId, cb); }); } await withVoidCallback((requestId, cb) { @@ -1094,6 +1104,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..019371410 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) { @@ -49,6 +53,16 @@ class RenderAttachmentState { }; } + RenderPlan getRenderPlan(Pointer swapChain) { + final attachments = _attachments[swapChain] ?? const <(int, View)>[]; + return RenderPlan( + attachments.map( + (attachment) => + RenderPass(view: attachment.$2, order: attachment.$1, active: _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(); }); } @@ -233,7 +252,12 @@ class FFIRenderManager extends RenderManager> { @override Iterable getAttachedViews(SwapChain swapChain) { - return _attachmentState.getAttachedViews(swapChain.getNativeHandle()); + return getRenderPlan(swapChain).attachedViews; + } + + @override + RenderPlan getRenderPlan(SwapChain swapChain) { + return _attachmentState.getRenderPlan(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..c5413f836 100644 --- a/thermion_dart/lib/src/filament/src/interface/render_manager.dart +++ b/thermion_dart/lib/src/filament/src/interface/render_manager.dart @@ -1,6 +1,37 @@ import 'package:thermion_dart/src/filament/src/interface/native_handle.dart'; import 'package:thermion_dart/thermion_dart.dart'; +/// One ordered view attachment in a swapchain's render plan. +class RenderPass { + final View view; + final int order; + final bool active; + + const RenderPass({required this.view, required this.order, required this.active}); +} + +/// Immutable snapshot of every ordered view attached to a swapchain. +/// +/// A snapshot keeps capture and other frame-like operations consistent when +/// attachment state changes asynchronously. Inactive passes remain present so +/// their render targets can still be inspected without submitting them. +class RenderPlan { + final List passes; + + RenderPlan(Iterable passes) : passes = List.unmodifiable(passes); + + Iterable get attachedViews => passes.map((pass) => pass.view); + + Iterable get activeViews => passes.where((pass) => pass.active).map((pass) => pass.view); + + RenderPass? passFor(View view) { + for (final pass in passes) { + if (pass.view == view) return pass; + } + return null; + } +} + abstract class RenderManager extends NativeHandle { Future attach(View view, SwapChain swapChain, {int renderOrder = 0}); @@ -11,8 +42,18 @@ abstract class RenderManager extends NativeHandle { /// so platform surfaces can be created asynchronously. Future setRenderable(View view, bool renderable); + /// Applies several renderability changes in one attachment-state update. + /// + /// This avoids exposing intermediate render plans when a group of passes is + /// enabled or disabled together. + Future setRenderables(Map renderability); + Future detach(View view, {SwapChain? swapChain}); Future detachAll(SwapChain swapChain); + + /// Returns one stable snapshot containing both active and inactive passes. + RenderPlan getRenderPlan(SwapChain swapChain); + Iterable getAttachedViews(SwapChain swapChain); Iterable getAttachedSwapChains(View view); 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..7ecdb233b 100644 --- a/thermion_dart/test/render_attachment_state_test.dart +++ b/thermion_dart/test/render_attachment_state_test.dart @@ -84,5 +84,35 @@ void main() { expect(state.activeSnapshot()[newSwapChain], [(0, view)]); }); + + test('render plan preserves order and inactive attachments', () { + 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 plan = state.getRenderPlan(swapChain); + expect(plan.passes.map((pass) => pass.view), [mainView, overlayView]); + expect(plan.passes.map((pass) => pass.order), [1, 2]); + expect(plan.passes.map((pass) => pass.active), [true, false]); + expect(plan.activeViews, [mainView]); + }); + + test('updates grouped pass state before producing a snapshot', () { + 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.getRenderPlan(swapChain).activeViews, 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); From 53a5d635881437c3d1c9bffdc65036e1e0eed293 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Mon, 24 Aug 2026 14:00:00 +0800 Subject: [PATCH 2/3] refactor: simplify view attachment snapshots --- .../src/implementation/ffi_filament_app.dart | 33 ++++++++++------- .../implementation/ffi_render_manager.dart | 16 ++++---- .../src/interface/render_manager.dart | 37 ++++--------------- .../test/render_attachment_state_test.dart | 29 ++++++++------- 4 files changed, 51 insertions(+), 64 deletions(-) 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 9f6b1dd19..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 @@ -905,11 +905,11 @@ class FFIFilamentApp extends FilamentApp { swapChain = _swapChains.first; } - // Capture uses one immutable plan 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 renderPlan = renderManager.getRenderPlan(swapChain); + // 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; @@ -932,15 +932,22 @@ class FFIFilamentApp extends FilamentApp { final pixelBuffers = <(View, Uint8List)>[]; - final capturePasses = []; + final captureAttachments = []; if (view != null) { - capturePasses.add(renderPlan.passFor(view) ?? RenderPass(view: view, order: 0, active: true)); + 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 { - capturePasses.addAll(renderPlan.passes); + captureAttachments.addAll(viewAttachments); } - final views = capturePasses.map((pass) => pass.view).toList(growable: false); + final views = captureAttachments.map((attachment) => attachment.view).toList(growable: false); for (final view in views) { final vp = await view.getViewport(); @@ -967,7 +974,7 @@ class FFIFilamentApp extends FilamentApp { for (var viewIndex = 0; viewIndex < views.length; viewIndex++) { final view = views[viewIndex]; - final pass = capturePasses[viewIndex]; + final attachment = captureAttachments[viewIndex]; final renderTarget = await view.getRenderTarget(); bool hasRenderTarget = renderTarget != null; _logger.finest( @@ -1018,7 +1025,7 @@ class FFIFilamentApp extends FilamentApp { final numBytes = viewport.width * viewport.height * numChannels * channelSizeInBytes; final pixelBuffer = makeUint8List(numBytes); - if (render && pass.active) { + if (render && attachment.renderable) { await withVoidCallback((requestId, cb) { Renderer_renderRenderThread(renderer, view.getNativeHandle(), requestId, cb); }); @@ -1067,9 +1074,9 @@ class FFIFilamentApp extends FilamentApp { await withBoolCallback( (cb) => Renderer_beginFrameRenderThread(renderer, swapChain!.getNativeHandle(), 0.toBigInt, cb), ); - for (final pass in capturePasses.where((pass) => pass.active)) { + for (final attachment in captureAttachments.where((attachment) => attachment.renderable)) { await withVoidCallback((requestId, cb) { - Renderer_renderRenderThread(renderer, pass.view.getNativeHandle(), requestId, cb); + Renderer_renderRenderThread(renderer, attachment.view.getNativeHandle(), requestId, cb); }); } await withVoidCallback((requestId, cb) { 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 019371410..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 @@ -46,19 +46,19 @@ 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(), }; } - RenderPlan getRenderPlan(Pointer swapChain) { + List getViewAttachments(Pointer swapChain) { final attachments = _attachments[swapChain] ?? const <(int, View)>[]; - return RenderPlan( + return List.unmodifiable( attachments.map( (attachment) => - RenderPass(view: attachment.$2, order: attachment.$1, active: _renderable[attachment.$2] ?? true), + ViewAttachment(view: attachment.$2, order: attachment.$1, renderable: _renderable[attachment.$2] ?? true), ), ); } @@ -192,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]; @@ -252,12 +252,12 @@ class FFIRenderManager extends RenderManager> { @override Iterable getAttachedViews(SwapChain swapChain) { - return getRenderPlan(swapChain).attachedViews; + return getViewAttachments(swapChain).map((attachment) => attachment.view); } @override - RenderPlan getRenderPlan(SwapChain swapChain) { - return _attachmentState.getRenderPlan(swapChain.getNativeHandle()); + 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 c5413f836..9c5a9257f 100644 --- a/thermion_dart/lib/src/filament/src/interface/render_manager.dart +++ b/thermion_dart/lib/src/filament/src/interface/render_manager.dart @@ -1,35 +1,13 @@ import 'package:thermion_dart/src/filament/src/interface/native_handle.dart'; import 'package:thermion_dart/thermion_dart.dart'; -/// One ordered view attachment in a swapchain's render plan. -class RenderPass { +/// One ordered view attachment in a swapchain. +class ViewAttachment { final View view; final int order; - final bool active; + final bool renderable; - const RenderPass({required this.view, required this.order, required this.active}); -} - -/// Immutable snapshot of every ordered view attached to a swapchain. -/// -/// A snapshot keeps capture and other frame-like operations consistent when -/// attachment state changes asynchronously. Inactive passes remain present so -/// their render targets can still be inspected without submitting them. -class RenderPlan { - final List passes; - - RenderPlan(Iterable passes) : passes = List.unmodifiable(passes); - - Iterable get attachedViews => passes.map((pass) => pass.view); - - Iterable get activeViews => passes.where((pass) => pass.active).map((pass) => pass.view); - - RenderPass? passFor(View view) { - for (final pass in passes) { - if (pass.view == view) return pass; - } - return null; - } + const ViewAttachment({required this.view, required this.order, required this.renderable}); } abstract class RenderManager extends NativeHandle { @@ -44,15 +22,16 @@ abstract class RenderManager extends NativeHandle { /// Applies several renderability changes in one attachment-state update. /// - /// This avoids exposing intermediate render plans when a group of passes is + /// This avoids exposing intermediate state when a group of views is /// enabled or disabled together. Future setRenderables(Map renderability); Future detach(View view, {SwapChain? swapChain}); Future detachAll(SwapChain swapChain); - /// Returns one stable snapshot containing both active and inactive passes. - RenderPlan getRenderPlan(SwapChain swapChain); + /// Returns a stable, ordered list of all attachments, including views that + /// are currently not renderable. + List getViewAttachments(SwapChain swapChain); Iterable getAttachedViews(SwapChain swapChain); Iterable getAttachedSwapChains(View view); diff --git a/thermion_dart/test/render_attachment_state_test.dart b/thermion_dart/test/render_attachment_state_test.dart index 7ecdb233b..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,10 +82,10 @@ void main() { state.detach(view); state.attach(view, newSwapChain); - expect(state.activeSnapshot()[newSwapChain], [(0, view)]); + expect(state.renderableSnapshot()[newSwapChain], [(0, view)]); }); - test('render plan preserves order and inactive attachments', () { + test('view attachments preserve order and non-renderable entries', () { final state = RenderAttachmentState(); final mainView = _TestView(1); final overlayView = _TestView(2); @@ -95,14 +95,15 @@ void main() { state.attach(mainView, swapChain, renderOrder: 1); state.setRenderable(overlayView, false); - final plan = state.getRenderPlan(swapChain); - expect(plan.passes.map((pass) => pass.view), [mainView, overlayView]); - expect(plan.passes.map((pass) => pass.order), [1, 2]); - expect(plan.passes.map((pass) => pass.active), [true, false]); - expect(plan.activeViews, [mainView]); + 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 pass state before producing a snapshot', () { + test('updates grouped view state before returning attachments', () { final state = RenderAttachmentState(); final silhouetteView = _TestView(1); final overlayView = _TestView(2); @@ -112,7 +113,7 @@ void main() { state.attach(overlayView, swapChain, renderOrder: 2); state.setRenderables({silhouetteView: false, overlayView: false}); - expect(state.getRenderPlan(swapChain).activeViews, isEmpty); + expect(state.getViewAttachments(swapChain).where((attachment) => attachment.renderable), isEmpty); }); }); } From 665ea337eb607f87bb1203b1befc6d8e03e75623 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Mon, 24 Aug 2026 14:54:02 +0800 Subject: [PATCH 3/3] docs: explain render manager lifecycle and semantics --- .../src/interface/render_manager.dart | 138 ++++++++++++++++-- 1 file changed, 129 insertions(+), 9 deletions(-) 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 9c5a9257f..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,42 +1,162 @@ import 'package:thermion_dart/src/filament/src/interface/native_handle.dart'; import 'package:thermion_dart/thermion_dart.dart'; -/// One ordered view attachment in a swapchain. +/// 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 renderability changes in one attachment-state update. + /// 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. /// - /// This avoids exposing intermediate state when a group of views is - /// 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 a stable, ordered list of all attachments, including views that - /// are currently not renderable. + /// 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(); }