Merge branch 'develop' of https://github.com/nmfisher/thermion into develop

This commit is contained in:
Nick Fisher
2024-10-23 01:24:44 +11:00
7 changed files with 108 additions and 114 deletions

View File

@@ -27,6 +27,7 @@
``` ```
flutter channel master flutter channel master
flutter upgrade flutter upgrade
flutter config --enable-native-assets
``` ```
``` ```

View File

@@ -0,0 +1,29 @@
## Camera Manipulation (Flutter)
> You can find the entire project below in the [flutter/quickstart](https://github.com/nmfisher/thermion/examples/flutter/camera_manipulation) folder.
A `ThermionListenerWidget` is one option for manipulating the camera with an input device (e.g. mouse or touchscreen gestures).
This will generally wrap a `ThermionWidget`, meaning the entire viewport will act as a receiver for gesture events.
> You can position this independently (for example, stacked vertically beneath the viewport), but this will not translate picking queries correctly.
```
@override
Widget build(BuildContext context) {
return Stack(children: [
if (_thermionViewer != null)
Positioned.fill(
child: ThermionListenerWidget(
inputHandler:
DelegateInputHandler.fixedOrbit(_thermionViewer!),
child: ThermionWidget(
viewer: _thermionViewer!,
))),
]);
```
`ThermionListenerWidget` is a very simple widget; it simply forwards pointer, gesture and keyboard events to an instance of [InputHandler] that you provide.

View File

@@ -79,6 +79,13 @@ class _MyHomePageState extends State<MyHomePage> {
await _thermionViewer!.loadSkybox("assets/default_env_skybox.ktx"); await _thermionViewer!.loadSkybox("assets/default_env_skybox.ktx");
await _thermionViewer!.loadIbl("assets/default_env_ibl.ktx"); await _thermionViewer!.loadIbl("assets/default_env_ibl.ktx");
// The underlying Filament rendering engine exposes a number of
// post-processing options (anti-aliasing, bloom, etc).
// Post-processing is disabled by default, but most users will want to
// enable it for color correction.
// If you're not sure what you're doing, always set this to true.
await _thermionViewer!.setPostProcessing(true);
// Finally, you need to explicitly enable rendering. Setting rendering to // Finally, you need to explicitly enable rendering. Setting rendering to
// false is designed to allow you to pause rendering to conserve battery life // false is designed to allow you to pause rendering to conserve battery life
await _thermionViewer!.setRendering(true); await _thermionViewer!.setRendering(true);
@@ -87,7 +94,7 @@ class _MyHomePageState extends State<MyHomePage> {
} }
Future _unload() async { Future _unload() async {
// when you are no longer need the 3D viewport: // when you've finished rendering and you no longer need a 3D viewport:
// 1) remove all instances of ThermionWidget from the widget tree // 1) remove all instances of ThermionWidget from the widget tree
// 2) remove all local references to the ThermionViewer // 2) remove all local references to the ThermionViewer
// 3) call dispose on the ThermionViewer // 3) call dispose on the ThermionViewer
@@ -121,7 +128,7 @@ class _MyHomePageState extends State<MyHomePage> {
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child:Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [

View File

@@ -2,6 +2,6 @@ library;
export 'src/input_handler.dart'; export 'src/input_handler.dart';
export 'src/delegates.dart'; export 'src/delegates.dart';
export 'src/delegate_gesture_handler.dart'; export 'src/delegate_input_handler.dart';
export 'src/implementations/default_pick_delegate.dart'; export 'src/implementations/default_pick_delegate.dart';
export 'src/implementations/third_person_camera_delegate.dart'; export 'src/implementations/third_person_camera_delegate.dart';

View File

@@ -63,18 +63,17 @@ class DelegateInputHandler implements InputHandler {
factory DelegateInputHandler.fixedOrbit(ThermionViewer viewer, factory DelegateInputHandler.fixedOrbit(ThermionViewer viewer,
{double minimumDistance = 10.0, {double minimumDistance = 10.0,
Future<double?> Function(Vector3)? getDistanceToTarget, Vector3? target,
ThermionEntity? entity, ThermionEntity? entity,
PickDelegate? pickDelegate}) => PickDelegate? pickDelegate}) =>
DelegateInputHandler( DelegateInputHandler(
viewer: viewer, viewer: viewer,
pickDelegate: pickDelegate, pickDelegate: pickDelegate,
transformDelegate: FixedOrbitRotateInputHandlerDelegate(viewer, transformDelegate: FixedOrbitRotateInputHandlerDelegate(viewer,
getDistanceToTarget: getDistanceToTarget,
minimumDistance: minimumDistance), minimumDistance: minimumDistance),
actions: { actions: {
InputType.MMB_HOLD_AND_MOVE: InputAction.ROTATE, InputType.MMB_HOLD_AND_MOVE: InputAction.ROTATE,
InputType.SCROLLWHEEL: InputAction.TRANSLATE InputType.SCROLLWHEEL: InputAction.ZOOM
}); });
factory DelegateInputHandler.flight(ThermionViewer viewer, factory DelegateInputHandler.flight(ThermionViewer viewer,

View File

@@ -5,26 +5,30 @@ import '../../../viewer/viewer.dart';
import '../../input.dart'; import '../../input.dart';
import '../input_handler.dart'; import '../input_handler.dart';
///
/// An [InputHandlerDelegate] that orbits the camera around a fixed
/// point.
///
class FixedOrbitRotateInputHandlerDelegate implements InputHandlerDelegate { class FixedOrbitRotateInputHandlerDelegate implements InputHandlerDelegate {
final ThermionViewer viewer; final ThermionViewer viewer;
late Future<Camera> _camera; late Future<Camera> _camera;
final double minimumDistance; final double minimumDistance;
Future<double?> Function(Vector3)? getDistanceToTarget; late final Vector3 target;
Vector2 _queuedRotationDelta = Vector2.zero(); Vector2 _queuedRotationDelta = Vector2.zero();
double _queuedZoomDelta = 0.0; double _queuedZoomDelta = 0.0;
static final _up = Vector3(0, 1, 0);
Timer? _updateTimer; Timer? _updateTimer;
FixedOrbitRotateInputHandlerDelegate( FixedOrbitRotateInputHandlerDelegate(
this.viewer, { this.viewer, {
this.getDistanceToTarget, Vector3? target,
this.minimumDistance = 10.0, this.minimumDistance = 10.0,
}) { }) {
this.target = target ?? Vector3.zero();
_camera = viewer.getMainCamera().then((Camera cam) async { _camera = viewer.getMainCamera().then((Camera cam) async {
var viewMatrix = makeViewMatrix(Vector3(0.0, 0, -minimumDistance), var viewMatrix = makeViewMatrix(Vector3(0.0, 0, -minimumDistance),
Vector3.zero(), Vector3(0.0, 1.0, 0.0)); this.target, Vector3(0.0, 1.0, 0.0));
viewMatrix.invert(); viewMatrix.invert();
await cam.setTransform(viewMatrix); await cam.setTransform(viewMatrix);
@@ -81,98 +85,52 @@ class FixedOrbitRotateInputHandlerDelegate implements InputHandlerDelegate {
var inverseProjectionMatrix = projectionMatrix.clone()..invert(); var inverseProjectionMatrix = projectionMatrix.clone()..invert();
Vector3 currentPosition = modelMatrix.getTranslation(); Vector3 currentPosition = modelMatrix.getTranslation();
Vector3 forward = -currentPosition.normalized(); Vector3 forward = modelMatrix.forward;
if (forward.length == 0) { if (forward.length == 0) {
forward = Vector3(0, 0, -1); forward = Vector3(0, 0, -1);
currentPosition = Vector3(0, 0, minimumDistance); currentPosition = Vector3(0, 0, minimumDistance);
} }
Vector3 right = _up.cross(forward).normalized();
Vector3 up = forward.cross(right);
// Calculate the point where the camera forward ray intersects with the
// surface of the target sphere
var distanceToTarget =
(await getDistanceToTarget?.call(currentPosition)) ?? 0;
Vector3 intersection =
(-forward).scaled(currentPosition.length - distanceToTarget);
final intersectionInViewSpace = viewMatrix *
Vector4(intersection.x, intersection.y, intersection.z, 1.0);
final intersectionInClipSpace = projectionMatrix * intersectionInViewSpace;
final intersectionInNdcSpace =
intersectionInClipSpace / intersectionInClipSpace.w;
// Calculate new camera position based on rotation
final ndcX = 2 * ((-_queuedRotationDelta.x) / viewport.width);
final ndcY = 2 * ((_queuedRotationDelta.y) / viewport.height);
final ndc = Vector4(ndcX, ndcY, intersectionInNdcSpace.z, 1.0);
var clipSpace = Vector4(
ndc.x * intersectionInClipSpace.w,
ndcY * intersectionInClipSpace.w,
ndc.z * intersectionInClipSpace.w,
intersectionInClipSpace.w);
Vector4 cameraSpace = inverseProjectionMatrix * clipSpace;
Vector4 worldSpace = modelMatrix * cameraSpace;
var worldSpace3 = worldSpace.xyz.normalized() * currentPosition.length;
currentPosition = worldSpace3;
// Zoom // Zoom
if (_queuedZoomDelta != 0.0) { if (_queuedZoomDelta != 0.0) {
var distToIntersection = var newPosition = currentPosition +
(currentPosition - intersection).length - minimumDistance; (currentPosition - target).scaled(_queuedZoomDelta * 0.1);
var distToTarget = (newPosition - target).length;
// if we somehow overshot the minimum distance, reset the camera to the minimum distance // if we somehow overshot the minimum distance, reset the camera to the minimum distance
if (distToIntersection < 0) { if (distToTarget >= minimumDistance) {
currentPosition += currentPosition = newPosition;
(intersection.normalized().scaled(-distToIntersection * 10));
} else {
bool zoomingOut = _queuedZoomDelta > 0;
late Vector3 offset;
// when zooming, we don't always use fractions of the distance from
// the camera to the target (this is due to float precision issues at
// large distances, and also it doesn't work well for UI).
// if we're zooming out and the distance is less than 10m, we zoom out by 1 unit
if (zoomingOut) {
if (distToIntersection < 10) {
offset = intersection.normalized();
} else {
offset = intersection.normalized().scaled(distToIntersection / 10);
}
// if we're zooming in and the distance is less than 5m, zoom in by 1/2 the distance,
// otherwise 1/10 of the distance each time
} else {
if (distToIntersection < 5) {
offset = intersection.normalized().scaled(-distToIntersection / 2);
} else {
offset = intersection.normalized().scaled(-distToIntersection / 10);
}
if (offset.length > distToIntersection) {
offset = Vector3.zero();
}
}
currentPosition += offset;
}
}
// Calculate view matrix // Calculate view matrix
forward = -currentPosition.normalized(); forward = (currentPosition - target).normalized();
right = _up.cross(forward).normalized(); var right = modelMatrix.up.cross(forward).normalized();
up = forward.cross(right); var up = forward.cross(right);
Matrix4 newViewMatrix = makeViewMatrix(currentPosition, Vector3.zero(), up); Matrix4 newViewMatrix = makeViewMatrix(currentPosition, target, up);
newViewMatrix.invert(); newViewMatrix.invert();
// Set the camera model matrix await (await _camera).setModelMatrix(newViewMatrix);
var camera = await _camera; }
await camera.setModelMatrix(newViewMatrix); } else if (_queuedRotationDelta.length != 0) {
double rotateX = _queuedRotationDelta.x * 0.01;
double rotateY = _queuedRotationDelta.y * 0.01;
var modelMatrix = await viewer.getCameraModelMatrix();
// for simplicity, we always assume a fixed coordinate system where
// we are rotating around world Y and camera X
var rot1 = Matrix4.identity()
..setRotation(Quaternion.axisAngle(Vector3(0, 1, 0), -rotateX)
.asRotationMatrix());
var rot2 = Matrix4.identity()
..setRotation(Quaternion.axisAngle(modelMatrix.right, rotateY)
.asRotationMatrix());
modelMatrix = rot1 *
rot2 * modelMatrix;
await (await _camera).setModelMatrix(modelMatrix);
}
// Reset queued deltas // Reset queued deltas
_queuedRotationDelta = Vector2.zero(); _queuedRotationDelta = Vector2.zero();

View File

@@ -29,11 +29,11 @@ class ThermionListenerWidget extends StatefulWidget {
/// ///
/// The handler to use for interpreting gestures/pointer movements. /// The handler to use for interpreting gestures/pointer movements.
/// ///
final InputHandler gestureHandler; final InputHandler inputHandler;
const ThermionListenerWidget({ const ThermionListenerWidget({
Key? key, Key? key,
required this.gestureHandler, required this.inputHandler,
this.child, this.child,
}) : super(key: key); }) : super(key: key);
@@ -66,9 +66,9 @@ class _ThermionListenerWidgetState extends State<ThermionListenerWidget> {
} }
if (event is KeyDownEvent || event is KeyRepeatEvent) { if (event is KeyDownEvent || event is KeyRepeatEvent) {
widget.gestureHandler.keyDown(key!); widget.inputHandler.keyDown(key!);
} else if (event is KeyUpEvent) { } else if (event is KeyUpEvent) {
widget.gestureHandler.keyUp(key!); widget.inputHandler.keyUp(key!);
return true; return true;
} }
return false; return false;
@@ -82,12 +82,12 @@ class _ThermionListenerWidgetState extends State<ThermionListenerWidget> {
Widget _desktop(double pixelRatio) { Widget _desktop(double pixelRatio) {
return Listener( return Listener(
onPointerHover: (event) => widget.gestureHandler.onPointerHover( onPointerHover: (event) => widget.inputHandler.onPointerHover(
event.localPosition.toVector2() * pixelRatio, event.localPosition.toVector2() * pixelRatio,
event.delta.toVector2() * pixelRatio), event.delta.toVector2() * pixelRatio),
onPointerSignal: (PointerSignalEvent pointerSignal) { onPointerSignal: (PointerSignalEvent pointerSignal) {
if (pointerSignal is PointerScrollEvent) { if (pointerSignal is PointerScrollEvent) {
widget.gestureHandler.onPointerScroll( widget.inputHandler.onPointerScroll(
pointerSignal.localPosition.toVector2() * pixelRatio, pointerSignal.localPosition.toVector2() * pixelRatio,
pointerSignal.scrollDelta.dy * pixelRatio); pointerSignal.scrollDelta.dy * pixelRatio);
} }
@@ -95,14 +95,14 @@ class _ThermionListenerWidgetState extends State<ThermionListenerWidget> {
onPointerPanZoomStart: (pzs) { onPointerPanZoomStart: (pzs) {
throw Exception("TODO - is this a pinch zoom on laptop trackpad?"); throw Exception("TODO - is this a pinch zoom on laptop trackpad?");
}, },
onPointerDown: (d) => widget.gestureHandler.onPointerDown( onPointerDown: (d) => widget.inputHandler.onPointerDown(
d.localPosition.toVector2() * pixelRatio, d.localPosition.toVector2() * pixelRatio,
d.buttons & kMiddleMouseButton != 0), d.buttons & kMiddleMouseButton != 0),
onPointerMove: (d) => widget.gestureHandler.onPointerMove( onPointerMove: (d) => widget.inputHandler.onPointerMove(
d.localPosition.toVector2() * pixelRatio, d.localPosition.toVector2() * pixelRatio,
d.delta.toVector2() * pixelRatio, d.delta.toVector2() * pixelRatio,
d.buttons & kMiddleMouseButton != 0), d.buttons & kMiddleMouseButton != 0),
onPointerUp: (d) => widget.gestureHandler onPointerUp: (d) => widget.inputHandler
.onPointerUp(d.buttons & kMiddleMouseButton != 0), .onPointerUp(d.buttons & kMiddleMouseButton != 0),
child: widget.child, child: widget.child,
); );
@@ -110,7 +110,7 @@ class _ThermionListenerWidgetState extends State<ThermionListenerWidget> {
Widget _mobile(double pixelRatio) { Widget _mobile(double pixelRatio) {
return _MobileListenerWidget( return _MobileListenerWidget(
gestureHandler: widget.gestureHandler, pixelRatio: pixelRatio, child:widget.child); inputHandler: widget.inputHandler, pixelRatio: pixelRatio, child:widget.child);
} }
@override @override
@@ -118,7 +118,7 @@ class _ThermionListenerWidgetState extends State<ThermionListenerWidget> {
return PixelRatioAware(builder: (ctx, pixelRatio) { return PixelRatioAware(builder: (ctx, pixelRatio) {
return FutureBuilder( return FutureBuilder(
initialData: 1.0, initialData: 1.0,
future: widget.gestureHandler.initialized, future: widget.inputHandler.initialized,
builder: (_, initialized) { builder: (_, initialized) {
if (initialized.data != true) { if (initialized.data != true) {
return widget.child ?? Container(); return widget.child ?? Container();
@@ -133,12 +133,12 @@ class _ThermionListenerWidgetState extends State<ThermionListenerWidget> {
} }
class _MobileListenerWidget extends StatefulWidget { class _MobileListenerWidget extends StatefulWidget {
final InputHandler gestureHandler; final InputHandler inputHandler;
final double pixelRatio; final double pixelRatio;
final Widget? child; final Widget? child;
const _MobileListenerWidget( const _MobileListenerWidget(
{Key? key, required this.gestureHandler, required this.pixelRatio, this.child}) {Key? key, required this.inputHandler, required this.pixelRatio, this.child})
: super(key: key); : super(key: key);
@override @override
@@ -157,18 +157,18 @@ class _MobileListenerWidgetState extends State<_MobileListenerWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTapDown: (details) => widget.gestureHandler.onPointerDown( onTapDown: (details) => widget.inputHandler.onPointerDown(
details.localPosition.toVector2() * widget.pixelRatio, false), details.localPosition.toVector2() * widget.pixelRatio, false),
onDoubleTap: () { onDoubleTap: () {
widget.gestureHandler.setActionForType(InputType.SCALE1, widget.inputHandler.setActionForType(InputType.SCALE1,
isPan ? InputAction.TRANSLATE : InputAction.ROTATE); isPan ? InputAction.TRANSLATE : InputAction.ROTATE);
}, },
onScaleStart: (details) async { onScaleStart: (details) async {
await widget.gestureHandler.onScaleStart( await widget.inputHandler.onScaleStart(
details.localFocalPoint.toVector2(), details.pointerCount); details.localFocalPoint.toVector2(), details.pointerCount);
}, },
onScaleUpdate: (ScaleUpdateDetails details) async { onScaleUpdate: (ScaleUpdateDetails details) async {
await widget.gestureHandler.onScaleUpdate( await widget.inputHandler.onScaleUpdate(
details.localFocalPoint.toVector2(), details.localFocalPoint.toVector2(),
details.focalPointDelta.toVector2(), details.focalPointDelta.toVector2(),
details.horizontalScale, details.horizontalScale,
@@ -177,7 +177,7 @@ class _MobileListenerWidgetState extends State<_MobileListenerWidget> {
details.pointerCount); details.pointerCount);
}, },
onScaleEnd: (details) async { onScaleEnd: (details) async {
await widget.gestureHandler.onScaleEnd(details.pointerCount); await widget.inputHandler.onScaleEnd(details.pointerCount);
}, },
child: widget.child); child: widget.child);
} }