flutter3d_pointer_lock 0.5.0
flutter3d_pointer_lock: ^0.5.0 copied to clipboard
Locks the mouse pointer and reports its motion as relative deltas, which Flutter offers on no desktop platform and does not surface in a browser. Formerly pointer_lock.
flutter3d_pointer_lock #
Mouse capture for a game loop that reads mouse deltas once per step, from the flutter3d engine.
Until 0.4.2+2 this package was published as
pointer_lock. From 0.4.3 on it isflutter3d_pointer_lock; the code is the same, and 0.5.0's changelog lists what changed after the rename.For an ordinary Flutter app,
helgoboss_pointer_lockis the more natural choice.
flutter pub add flutter3d_pointer_lock
import 'package:flutter3d_pointer_lock/flutter3d_pointer_lock.dart';
Locks the mouse pointer in place and reports its motion as relative deltas.
Flutter exposes no pointer lock on any desktop platform and does not surface the browser's either, so a first-person camera cannot work out of the box: the cursor reaches the edge of the window and the view stops turning. This plugin adds pointer lock on both.
final capture = PointerLock.instance;
if (capture.isSupported) {
await capture.capture();
}
// Once per simulation step:
final delta = capture.drainDelta();
yaw += delta.dx * sensitivity;
pitch += delta.dy * sensitivity;
Pull, not push #
The caller drains the delta with drainDelta instead of receiving it from a
stream, because that is the shape a game wants. A simulation on a fixed
timestep asks "how far did the mouse move since the last step" once per step.
A stream of individual mouse events would move the accumulation into every
caller, and each of them would have to get it right.
drainDelta is synchronous for the same reason. It is called from inside the
step, and awaiting anything there would mean the step no longer sees a
consistent snapshot of its inputs.
Releasing #
Losing window focus drops the capture and announces it on onStateChanged.
Otherwise a hidden cursor would be left over another application, and the user
could not get it back. Treat an unrequested CaptureState.released as a reason
to pause.
The plugin does not watch for Escape. Key handling belongs to the application,
which calls release() itself.
Platforms #
| Platform | Status | How |
|---|---|---|
| macOS | supported | CGAssociateMouseAndMouseCursorPosition(0), NSCursor.hide(), a local NSEvent monitor |
| Web, desktop browser | supported | document.requestPointerLock through package:web. Pure Dart, so nothing is registered and flutter test --platform chrome reaches it |
| Web, phone or tablet | not applicable | reported by (pointer: coarse); isSupported is false so the game shows its touch controls |
| Windows | supported | Raw Input for the motion, ClipCursor to the window's centre, ShowCursor(FALSE) |
| Linux | supported on X11 | gdk_seat_grab with a blank cursor, the pointer warped back to the centre after each motion. Wayland forbids the warp, so there the deltas stop at the window's edge |
| Android | supported with a mouse, API 26 on | View.requestPointerCapture on the Flutter view, the motion from its captured-pointer listener; a click is handed to Flutter at the view's centre |
| iPadOS | supported with a mouse or a trackpad, full screen | prefersPointerLocked on the Flutter view controller, the motion from GCMouse; the scene's UIPointerLockState says when the lock is held |
| iPhone, a phone or tablet with no mouse | not applicable | no pointer to capture; isSupported is false |
The backend answers isSupported; the caller does not have to guess. An
application can then offer another control scheme instead of discovering the
gap at the first call. The engine above this plugin reads exactly that flag: in
a build where it is false, the camera turns by dragging.
On a phone or a tablet the answer follows the mouse: it is false until the
native side has said one is connected, a moment after start, and changes when
one comes or goes. Listen to onSupportChanged to swap touch controls for the
mouse:
capture.onSupportChanged.listen((supported) {
setState(() => showTouchControls = !supported);
});
What an iPad asks for #
iPadOS grants the lock only to a scene that fills the screen, so a game in
Split View or Slide Over stays released, and onStateChanged never reports a
capture it did not get. The Flutter view controller has to be the root of its
window, or a container above it has to return it from
childViewControllerForPointerLock. The plugin adds the
prefersPointerLocked answer to the controller's class at run time, so the
application does not subclass anything.
What a browser does that a desktop does not #
A capture must come from a user gesture. The browser refuses
requestPointerLock when it is called from a timer, a future or a frame
callback, so ask inside the handler of the press that prompted it.
A refusal arrives as an event, not an exception. It comes as
pointerlockerror, and on browsers that return a promise, also as a rejected
promise. The plugin handles both, and a refused capture leaves the state
released instead of pretending it succeeded.
The player can leave without asking. Escape releases the lock, and so does
switching tabs. Both arrive as an unrequested CaptureState.released, which is
the signal to pause.
In an iframe the parent page decides. A page embedding the game needs
allow="pointer-lock" on the iframe, or every capture is refused and nothing in
the console says why.
Hot restart #
The native side outlives the Dart isolate. Without a reset, a hot restart while the pointer is captured would leave the cursor hidden, with nothing left that remembers to ask for it back. So construction always issues a reset first.
Prior art #
helgoboss_pointer_lock
(source, MIT) solves
the same problem across more platforms, and the macOS technique here came from
it. This plugin differs from it in three places a game needed:
- an observer for focus loss, so Cmd+Tab does not strand the cursor;
- deltas read by the caller once per step;
isSupported, for a build that also targets mobile.
Cost #
On the desktops, one platform-channel message per mouse event, carrying a
two-element Float64List. Nobody has yet measured whether that shows up in a
frame profile at a 1000 Hz polling rate. Android and iPadOS already sum the
motion natively and send it once a frame, and the desktops can do the same if
the profile says so.
Part of flutter3d, an independent
implementation of a 3D engine for Flutter. It is not a fork or a binding of
another engine, and it is not affiliated with Google or the Flutter project. It has four
switchable rendering backends: Impeller via Flutter GPU, WebGL2, WebGPU and a
software rasteriser. It loads glTF, OBJ and .f3d, and has six lighting models,
shadows, bloom, skinning, animation, BVH culling and picking, plus a
deterministic fixed-step game layer with collision, navigation, positional
audio, and gamepad and touch input. Four example games (shooter, platformer,
racing, strategy) are each built on a genre package:
flutter3d_game_shooter,
flutter3d_game_platformer,
flutter3d_game_racing,
flutter3d_game_strategy.
A new game starts from the editor's scaffold, which writes one from a template:
https://flutter3d.pleion.dev/first-project/. Documentation:
https://flutter3d.pleion.dev.