diff --git a/README.md b/README.md index 12955bf2..c81966ec 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ ``` flutter channel master flutter upgrade +flutter config --enable-native-assets ``` ``` diff --git a/docs/camera_manipulation.mdx b/docs/camera_manipulation.mdx new file mode 100644 index 00000000..79f47394 --- /dev/null +++ b/docs/camera_manipulation.mdx @@ -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. + + + diff --git a/examples/flutter/quickstart/lib/main.dart b/examples/flutter/quickstart/lib/main.dart index 3e71f6e9..0d84f364 100644 --- a/examples/flutter/quickstart/lib/main.dart +++ b/examples/flutter/quickstart/lib/main.dart @@ -58,7 +58,7 @@ class _MyHomePageState extends State { // Geometry and models are represented as "entities". Here, we load a glTF // file containing a plain cube. - // By default, all paths are treated as asset paths. To load from a file + // By default, all paths are treated as asset paths. To load from a file // instead, use file:// URIs. var entity = await _thermionViewer!.loadGlb("assets/cube.glb", keepData: true); @@ -79,6 +79,13 @@ class _MyHomePageState extends State { await _thermionViewer!.loadSkybox("assets/default_env_skybox.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 // false is designed to allow you to pause rendering to conserve battery life await _thermionViewer!.setRendering(true); @@ -87,7 +94,7 @@ class _MyHomePageState extends State { } 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 // 2) remove all local references to the ThermionViewer // 3) call dispose on the ThermionViewer @@ -120,14 +127,14 @@ class _MyHomePageState extends State { Align( alignment: Alignment.bottomCenter, child: Padding( - padding: const EdgeInsets.all(16), - child:Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (_thermionViewer == null) _loadButton(), - if (_thermionViewer != null) _unloadButton(), - ]))) + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (_thermionViewer == null) _loadButton(), + if (_thermionViewer != null) _unloadButton(), + ]))) ]); } } diff --git a/thermion_dart/lib/src/input/input.dart b/thermion_dart/lib/src/input/input.dart index 1ac364f3..6a8072f8 100644 --- a/thermion_dart/lib/src/input/input.dart +++ b/thermion_dart/lib/src/input/input.dart @@ -2,6 +2,6 @@ library; export 'src/input_handler.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/third_person_camera_delegate.dart'; diff --git a/thermion_dart/lib/src/input/src/delegate_gesture_handler.dart b/thermion_dart/lib/src/input/src/delegate_input_handler.dart similarity index 97% rename from thermion_dart/lib/src/input/src/delegate_gesture_handler.dart rename to thermion_dart/lib/src/input/src/delegate_input_handler.dart index 5a53ed19..4f69bd33 100644 --- a/thermion_dart/lib/src/input/src/delegate_gesture_handler.dart +++ b/thermion_dart/lib/src/input/src/delegate_input_handler.dart @@ -63,18 +63,17 @@ class DelegateInputHandler implements InputHandler { factory DelegateInputHandler.fixedOrbit(ThermionViewer viewer, {double minimumDistance = 10.0, - Future Function(Vector3)? getDistanceToTarget, + Vector3? target, ThermionEntity? entity, PickDelegate? pickDelegate}) => DelegateInputHandler( viewer: viewer, pickDelegate: pickDelegate, transformDelegate: FixedOrbitRotateInputHandlerDelegate(viewer, - getDistanceToTarget: getDistanceToTarget, minimumDistance: minimumDistance), actions: { InputType.MMB_HOLD_AND_MOVE: InputAction.ROTATE, - InputType.SCROLLWHEEL: InputAction.TRANSLATE + InputType.SCROLLWHEEL: InputAction.ZOOM }); factory DelegateInputHandler.flight(ThermionViewer viewer, diff --git a/thermion_dart/lib/src/input/src/implementations/fixed_orbit_camera_rotation_delegate.dart b/thermion_dart/lib/src/input/src/implementations/fixed_orbit_camera_rotation_delegate.dart index 68aec940..b6b82702 100644 --- a/thermion_dart/lib/src/input/src/implementations/fixed_orbit_camera_rotation_delegate.dart +++ b/thermion_dart/lib/src/input/src/implementations/fixed_orbit_camera_rotation_delegate.dart @@ -5,26 +5,30 @@ import '../../../viewer/viewer.dart'; import '../../input.dart'; import '../input_handler.dart'; +/// +/// An [InputHandlerDelegate] that orbits the camera around a fixed +/// point. +/// class FixedOrbitRotateInputHandlerDelegate implements InputHandlerDelegate { final ThermionViewer viewer; late Future _camera; final double minimumDistance; - Future Function(Vector3)? getDistanceToTarget; + late final Vector3 target; Vector2 _queuedRotationDelta = Vector2.zero(); double _queuedZoomDelta = 0.0; - static final _up = Vector3(0, 1, 0); Timer? _updateTimer; FixedOrbitRotateInputHandlerDelegate( this.viewer, { - this.getDistanceToTarget, + Vector3? target, this.minimumDistance = 10.0, }) { + this.target = target ?? Vector3.zero(); _camera = viewer.getMainCamera().then((Camera cam) async { 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(); await cam.setTransform(viewMatrix); @@ -81,99 +85,53 @@ class FixedOrbitRotateInputHandlerDelegate implements InputHandlerDelegate { var inverseProjectionMatrix = projectionMatrix.clone()..invert(); Vector3 currentPosition = modelMatrix.getTranslation(); - Vector3 forward = -currentPosition.normalized(); + Vector3 forward = modelMatrix.forward; if (forward.length == 0) { forward = Vector3(0, 0, -1); 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 if (_queuedZoomDelta != 0.0) { - var distToIntersection = - (currentPosition - intersection).length - minimumDistance; + var newPosition = currentPosition + + (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 (distToIntersection < 0) { - currentPosition += - (intersection.normalized().scaled(-distToIntersection * 10)); - } else { - bool zoomingOut = _queuedZoomDelta > 0; - late Vector3 offset; + if (distToTarget >= minimumDistance) { + currentPosition = newPosition; + // Calculate view matrix + forward = (currentPosition - target).normalized(); + var right = modelMatrix.up.cross(forward).normalized(); + var up = forward.cross(right); - // 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). + Matrix4 newViewMatrix = makeViewMatrix(currentPosition, target, up); + newViewMatrix.invert(); - // 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; + await (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); } - // Calculate view matrix - forward = -currentPosition.normalized(); - right = _up.cross(forward).normalized(); - up = forward.cross(right); - - Matrix4 newViewMatrix = makeViewMatrix(currentPosition, Vector3.zero(), up); - newViewMatrix.invert(); - - // Set the camera model matrix - var camera = await _camera; - await camera.setModelMatrix(newViewMatrix); - // Reset queued deltas _queuedRotationDelta = Vector2.zero(); _queuedZoomDelta = 0.0; diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart index 2f6042e6..bbe23380 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart @@ -29,11 +29,11 @@ class ThermionListenerWidget extends StatefulWidget { /// /// The handler to use for interpreting gestures/pointer movements. /// - final InputHandler gestureHandler; + final InputHandler inputHandler; const ThermionListenerWidget({ Key? key, - required this.gestureHandler, + required this.inputHandler, this.child, }) : super(key: key); @@ -66,9 +66,9 @@ class _ThermionListenerWidgetState extends State { } if (event is KeyDownEvent || event is KeyRepeatEvent) { - widget.gestureHandler.keyDown(key!); + widget.inputHandler.keyDown(key!); } else if (event is KeyUpEvent) { - widget.gestureHandler.keyUp(key!); + widget.inputHandler.keyUp(key!); return true; } return false; @@ -82,12 +82,12 @@ class _ThermionListenerWidgetState extends State { Widget _desktop(double pixelRatio) { return Listener( - onPointerHover: (event) => widget.gestureHandler.onPointerHover( + onPointerHover: (event) => widget.inputHandler.onPointerHover( event.localPosition.toVector2() * pixelRatio, event.delta.toVector2() * pixelRatio), onPointerSignal: (PointerSignalEvent pointerSignal) { if (pointerSignal is PointerScrollEvent) { - widget.gestureHandler.onPointerScroll( + widget.inputHandler.onPointerScroll( pointerSignal.localPosition.toVector2() * pixelRatio, pointerSignal.scrollDelta.dy * pixelRatio); } @@ -95,14 +95,14 @@ class _ThermionListenerWidgetState extends State { onPointerPanZoomStart: (pzs) { 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.buttons & kMiddleMouseButton != 0), - onPointerMove: (d) => widget.gestureHandler.onPointerMove( + onPointerMove: (d) => widget.inputHandler.onPointerMove( d.localPosition.toVector2() * pixelRatio, d.delta.toVector2() * pixelRatio, d.buttons & kMiddleMouseButton != 0), - onPointerUp: (d) => widget.gestureHandler + onPointerUp: (d) => widget.inputHandler .onPointerUp(d.buttons & kMiddleMouseButton != 0), child: widget.child, ); @@ -110,7 +110,7 @@ class _ThermionListenerWidgetState extends State { Widget _mobile(double pixelRatio) { return _MobileListenerWidget( - gestureHandler: widget.gestureHandler, pixelRatio: pixelRatio, child:widget.child); + inputHandler: widget.inputHandler, pixelRatio: pixelRatio, child:widget.child); } @override @@ -118,7 +118,7 @@ class _ThermionListenerWidgetState extends State { return PixelRatioAware(builder: (ctx, pixelRatio) { return FutureBuilder( initialData: 1.0, - future: widget.gestureHandler.initialized, + future: widget.inputHandler.initialized, builder: (_, initialized) { if (initialized.data != true) { return widget.child ?? Container(); @@ -133,12 +133,12 @@ class _ThermionListenerWidgetState extends State { } class _MobileListenerWidget extends StatefulWidget { - final InputHandler gestureHandler; + final InputHandler inputHandler; final double pixelRatio; final Widget? child; 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); @override @@ -157,18 +157,18 @@ class _MobileListenerWidgetState extends State<_MobileListenerWidget> { Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.translucent, - onTapDown: (details) => widget.gestureHandler.onPointerDown( + onTapDown: (details) => widget.inputHandler.onPointerDown( details.localPosition.toVector2() * widget.pixelRatio, false), onDoubleTap: () { - widget.gestureHandler.setActionForType(InputType.SCALE1, + widget.inputHandler.setActionForType(InputType.SCALE1, isPan ? InputAction.TRANSLATE : InputAction.ROTATE); }, onScaleStart: (details) async { - await widget.gestureHandler.onScaleStart( + await widget.inputHandler.onScaleStart( details.localFocalPoint.toVector2(), details.pointerCount); }, onScaleUpdate: (ScaleUpdateDetails details) async { - await widget.gestureHandler.onScaleUpdate( + await widget.inputHandler.onScaleUpdate( details.localFocalPoint.toVector2(), details.focalPointDelta.toVector2(), details.horizontalScale, @@ -177,7 +177,7 @@ class _MobileListenerWidgetState extends State<_MobileListenerWidget> { details.pointerCount); }, onScaleEnd: (details) async { - await widget.gestureHandler.onScaleEnd(details.pointerCount); + await widget.inputHandler.onScaleEnd(details.pointerCount); }, child: widget.child); }