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 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

@@ -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(),
])))
]);
}
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);
}