Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion thermion_flutter/thermion_flutter/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ On web this means:

### Diagnostics

`_onFrame` wraps each frame in a `Stopwatch` and logs `[DART] 120-frame avg/max/jank/drop` at 120-frame intervals. A frame > 20ms counts as jank; a vsync that arrives while `_rendering` is still `true` counts as a drop. In port mode (debug), port transit latency is measured separately and logged when it exceeds 2ms.
`_onFrame` wraps each frame in a `Stopwatch` and logs at 120-frame intervals. A frame > 20ms counts as jank; a vsync that arrives while `_rendering` is still `true` counts as a drop. In port mode (debug), port transit latency is measured separately and logged when it exceeds 2ms.

## Linux

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import 'dart:async';
import 'dart:ffi' as ffi;
import 'dart:io';
import 'dart:isolate';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/scheduler.dart';
import 'package:logging/logging.dart';
// ignore: implementation_imports
import 'package:thermion_dart/src/bindings/src/thermion_dart_ffi.g.dart'
show
FrameScheduler_start,
FrameScheduler_stop,
FrameCallbackFunction,
FrameScheduler_initDartApi,
FrameScheduler_startWithPort,
FrameScheduler_setRenderThread,
FrameScheduler_setRenderManager,
FrameScheduler_setPostRenderCallback,
FrameScheduler_requestRender,
FrameScheduler_steadyClockUs,
PostRenderCallback,
TRenderManager;

/// Drives per-frame callbacks off the native FrameScheduler (CVDisplayLink /
/// DXGI / AChoreographer / timer) via one of three modes:
///
/// - **Direct callback** (release builds): native calls a Dart function
/// pointer via [ffi.NativeCallable.listener].
/// - **Port mode** (debug builds): native posts messages to a [ReceivePort].
/// Hot-restart safe — messages to dead ports are silently dropped.
/// - **Flutter-synced** (Linux native render loop): Flutter's persistent
/// frame callback drives a non-blocking native render request. [onFrame]
/// is not invoked in this mode.
class FrameScheduler {
FrameScheduler._();

static final FrameScheduler _instance = FrameScheduler._();

/// Returns the process-wide singleton.
static FrameScheduler get instance => _instance;

/// Called each frame in direct/port mode. Not invoked in Flutter-synced mode.
Future<void> Function()? _onFrameCallback;

/// Bind the per-frame callback. Must be called before [start].
void setOnFrame(Future<void> Function() onFrame) {
_onFrameCallback = onFrame;
}

static final _logger = Logger("FrameScheduler");

// Dart_InitializeApiDL is per-process; keep as static.
static bool _dartApiInitialized = false;

bool _active = false;
bool _paused = false;
bool _rendering = false;

ffi.NativeCallable<FrameCallbackFunction>? _frameCallable;
ReceivePort? _framePort;

int _diagFrameCount = 0;
int _diagDropCount = 0;
int _diagJankCount = 0;
double _diagMaxFrameMs = 0;
double _diagSumFrameMs = 0;
final _diagStopwatch = Stopwatch();
int _diagTransitSum = 0;
int _diagTransitCount = 0;
int _diagTransitMax = 0;

bool get isActive => _active;
bool get isPaused => _paused;
bool get isRendering => _rendering;

/// Start the native scheduler. Picks port mode in debug builds on
/// macOS/iOS/Android/Windows, direct callback otherwise.
Future<void> start() async {
_active = true;

final usePortMode = kDebugMode &&
(Platform.isMacOS ||
Platform.isIOS ||
Platform.isAndroid ||
Platform.isWindows);

if (usePortMode) {
await _initializePortMode();
} else {
_frameCallable = ffi.NativeCallable<FrameCallbackFunction>.listener(
_onFrame,
);
FrameScheduler_start(_frameCallable!.nativeFunction, 60);
}
}

/// Configure the native scheduler to run the render loop entirely in
/// native code, synchronized to Flutter's frame clock via a persistent
/// frame callback. [onFrame] is not invoked in this mode.
Future<void> startFlutterSynced({
required ffi.Pointer<ffi.Void> renderThreadHandle,
required ffi.Pointer<TRenderManager> renderManagerHandle,
required PostRenderCallback postRenderCallback,
required ffi.Pointer<ffi.Void> postRenderUserData,
}) async {
_active = true;

FrameScheduler_setRenderThread(renderThreadHandle);
FrameScheduler_setRenderManager(renderManagerHandle);
FrameScheduler_setPostRenderCallback(
postRenderCallback, postRenderUserData);

SchedulerBinding.instance.addPersistentFrameCallback(_onFlutterFrame);
SchedulerBinding.instance.scheduleFrame();

_logger.info('Flutter-synced render loop started');
}

/// Stop the native scheduler and release the Dart-side callback/port.
///
/// Safe to call when already stopped. Always calls the native stop —
/// after hot restart the native scheduler may still be running with a
/// dangling pointer from the previous isolate.
void stop() {
_active = false;

FrameScheduler_stop();

_frameCallable?.close();
_frameCallable = null;

_framePort?.close();
_framePort = null;
}

void pause() => _paused = true;
void resume() => _paused = false;

Future<void> _initializePortMode() async {
if (!_dartApiInitialized) {
FrameScheduler_initDartApi(ffi.NativeApi.initializeApiDLData);
_dartApiInitialized = true;
}

_framePort = ReceivePort();
_framePort!.listen((message) {
if (message is List) {
final frameTimeNanos = message[0] as int;
final postTimeUs = message[1] as int;
final recvTimeUs = FrameScheduler_steadyClockUs();
final transitUs = recvTimeUs - postTimeUs;
_diagTransitSum += transitUs;
_diagTransitCount++;
if (transitUs > _diagTransitMax) _diagTransitMax = transitUs;
if (transitUs > 2000) {
_logger.warning(
'[PORT] transit=${(transitUs / 1000.0).toStringAsFixed(1)}ms');
}
if (_diagTransitCount % 120 == 0) {
final avgMs = _diagTransitSum / (_diagTransitCount * 1000.0);
_logger.info(
'[PORT] 120-frame transit avg=${(avgMs).toStringAsFixed(2)}ms '
'max=${(_diagTransitMax / 1000.0).toStringAsFixed(1)}ms');
_diagTransitSum = 0;
_diagTransitCount = 0;
_diagTransitMax = 0;
}
_onFrame(frameTimeNanos);
} else {
_onFrame(message as int);
}
});

final nativePort = _framePort!.sendPort.nativePort;
FrameScheduler_startWithPort(nativePort, 60);

_logger.info('Frame scheduler started in port mode (hot restart safe)');
}

void _onFrame(int frameTimeNanos) {
if (!_active || _paused || _rendering) return;
final callback = _onFrameCallback;
if (callback == null) {
throw StateError('FrameScheduler.setOnFrame must be called before start');
}
_rendering = true;
_diagStopwatch
..reset()
..start();
callback().then((_) {
_diagStopwatch.stop();
_rendering = false;
final frameMs = _diagStopwatch.elapsedMicroseconds / 1000.0;
_diagFrameCount++;
_diagSumFrameMs += frameMs;
if (frameMs > _diagMaxFrameMs) _diagMaxFrameMs = frameMs;
if (frameMs > 20.0) {
_diagJankCount++;
_logger.warning(
'#$_diagFrameCount JANK renderFrame=${frameMs.toStringAsFixed(1)}ms');
}
if (_diagFrameCount % 120 == 0) {
final avgMs = _diagSumFrameMs / 120.0;
_logger.info('120-frame avg=${avgMs.toStringAsFixed(1)}ms '
'max=${_diagMaxFrameMs.toStringAsFixed(1)}ms '
'jank=$_diagJankCount drop=$_diagDropCount');
_diagJankCount = 0;
_diagDropCount = 0;
_diagMaxFrameMs = 0;
_diagSumFrameMs = 0;
}
}).catchError((error) {
_logger.warning('Frame render error: $error');
_rendering = false;
});
}

void _onFlutterFrame(Duration timeStamp) {
if (!_active || _paused) return;
FrameScheduler_requestRender(timeStamp.inMicroseconds * 1000);
SchedulerBinding.instance.scheduleFrame();
}
}
Loading
Loading