Merge branch 'develop' of https://github.com/nmfisher/thermion into develop
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
```
|
||||
flutter channel master
|
||||
flutter upgrade
|
||||
flutter config --enable-native-assets
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
29
docs/camera_manipulation.mdx
Normal file
29
docs/camera_manipulation.mdx
Normal 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.
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
|
||||
// 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<MyHomePage> {
|
||||
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<MyHomePage> {
|
||||
}
|
||||
|
||||
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<MyHomePage> {
|
||||
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(),
|
||||
])))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -63,18 +63,17 @@ class DelegateInputHandler implements InputHandler {
|
||||
|
||||
factory DelegateInputHandler.fixedOrbit(ThermionViewer viewer,
|
||||
{double minimumDistance = 10.0,
|
||||
Future<double?> 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,
|
||||
@@ -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> _camera;
|
||||
final double minimumDistance;
|
||||
Future<double?> 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;
|
||||
|
||||
@@ -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<ThermionListenerWidget> {
|
||||
}
|
||||
|
||||
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<ThermionListenerWidget> {
|
||||
|
||||
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<ThermionListenerWidget> {
|
||||
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<ThermionListenerWidget> {
|
||||
|
||||
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<ThermionListenerWidget> {
|
||||
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<ThermionListenerWidget> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user