From 43734c0b5e4ccb9b10a625b97fc058d6e45f1679 Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Tue, 13 Aug 2024 11:03:33 +0100 Subject: [PATCH 1/6] Beautiful transitions - initial work --- src/mol-canvas3d/camera.ts | 1 + src/mol-canvas3d/camera/transition.ts | 84 +++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/mol-canvas3d/camera.ts b/src/mol-canvas3d/camera.ts index 178712bc54..ce48fbee08 100644 --- a/src/mol-canvas3d/camera.ts +++ b/src/mol-canvas3d/camera.ts @@ -280,6 +280,7 @@ namespace Camera { export interface Snapshot { mode: Mode + /** Field-of-view in radians (vertical) */ fov: number position: Vec3 diff --git a/src/mol-canvas3d/camera/transition.ts b/src/mol-canvas3d/camera/transition.ts index e45b2410a1..9d7e9b2aca 100644 --- a/src/mol-canvas3d/camera/transition.ts +++ b/src/mol-canvas3d/camera/transition.ts @@ -4,9 +4,10 @@ * @author David Sehnal */ -import { Camera } from '../camera'; -import { Quat, Vec3 } from '../../mol-math/linear-algebra'; import { lerp } from '../../mol-math/interpolate'; +import { Quat, Vec3 } from '../../mol-math/linear-algebra'; +import { Camera } from '../camera'; + export { CameraTransitionManager }; @@ -93,7 +94,7 @@ namespace CameraTransitionManager { const _sourcePosition = Vec3(); const _targetPosition = Vec3(); - export function defaultTransition(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { + export function defaultTransition_orig(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { Camera.copySnapshot(out, target); // Rotate up @@ -132,4 +133,79 @@ namespace CameraTransitionManager { out.fov = lerp(source.fov, target.fov, t); out.fog = lerp(source.fog, target.fog, t); } -} \ No newline at end of file + + export function defaultTransition(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { + const ALPHA = 1; // 0 - equivalent to position interpolation (unless FOV changes), 1 - focuses union of source and target focus spheres in the middle of transition + // TODO make alpha customizable + + Camera.copySnapshot(out, target); + + // Rotate up + Quat.slerp(_rotUp, Quat.Identity, Quat.rotationTo(_rotUp, source.up, target.up), t); + Vec3.transformQuat(out.up, source.up, _rotUp); + + // Lerp target, position & radius + Vec3.lerp(out.target, source.target, target.target, t); + + const shift = Vec3.distance(source.target, target.target); + + // Interpolate radius + out.radius = swellingRadiusInterpolation(source.radius, target.radius, shift, ALPHA, t); + // TODO take change of `clipFar` into account + out.radiusMax = swellingRadiusInterpolation(source.radiusMax, target.radiusMax, shift, ALPHA, t); + + // Interpolate fov & fog + out.fov = lerp(source.fov, target.fov, t); + out.fog = lerp(source.fog, target.fog, t); + // TODO fix Canvas3D.setProps() setting FOV instantly before transition starts! + + // Interpolate distance (indirectly via visible sphere radius) + const rVisSource = visibleSphereRadius(source); + const rVisTarget = visibleSphereRadius(source); + const rVis = swellingRadiusInterpolation(rVisSource, rVisTarget, shift, ALPHA, t); + const dist = cameraTargetDistance(rVis, out.mode, out.fov); + + // Rotate between source and targer direction + Vec3.sub(_sourcePosition, source.position, source.target); + Vec3.normalize(_sourcePosition, _sourcePosition); + + Vec3.sub(_targetPosition, target.position, target.target); + Vec3.normalize(_targetPosition, _targetPosition); + + Quat.rotationTo(_rotDist, _sourcePosition, _targetPosition); + Quat.slerp(_rotDist, Quat.Identity, _rotDist, t); + + Vec3.transformQuat(_sourcePosition, _sourcePosition, _rotDist); + Vec3.scale(_sourcePosition, _sourcePosition, dist); + + Vec3.add(out.position, out.target, _sourcePosition); + } +} + + +/** Sphere radius "interpolation" method that increases radius in the middle of transition. + * `r0`, `r1` - radius of source (t=0) and target (t=1) sphere; + * `dist` - distance between centers of source and target sphere; + * `alpha` - swell parameter (0 = no swell = linear interpolation, 1 = sphere for t=0.5 will be circumscribed to source and radius spheres */ +function swellingRadiusInterpolation(r0: number, r1: number, dist: number, alpha: number, t: number): number { + const a = -2 * alpha * dist; + const b = -a - r0 + r1; + const c = r0; + return a * t ** 2 + b * t + c; +} + +/** Return the radius of the largest sphere centered in camera.target which is fully in FOV */ +function visibleSphereRadius(camera: Camera.Snapshot): number { + const distance = Vec3.distance(camera.target, camera.position); + if (camera.mode === 'orthographic') + return distance * Math.tan(camera.fov / 2); + else // perspective + return distance * Math.sin(camera.fov / 2); +} +/** Return the distance of camera from the center of a sphere with radius `visRadius` so that the sphere just fits into FOV */ +function cameraTargetDistance(visRadius: number, mode: Camera.Mode, fov: number): number { + if (mode === 'orthographic') + return visRadius / Math.tan(fov / 2); + else // perspective + return visRadius / Math.sin(fov / 2); +} From 094b9b2e1ecc8afbd0ca370a2ee3233950757c11 Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Fri, 16 Aug 2024 14:21:03 +0100 Subject: [PATCH 2/6] fixed critical typo bug --- src/mol-canvas3d/camera/transition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mol-canvas3d/camera/transition.ts b/src/mol-canvas3d/camera/transition.ts index 9d7e9b2aca..225b13f7b3 100644 --- a/src/mol-canvas3d/camera/transition.ts +++ b/src/mol-canvas3d/camera/transition.ts @@ -161,7 +161,7 @@ namespace CameraTransitionManager { // Interpolate distance (indirectly via visible sphere radius) const rVisSource = visibleSphereRadius(source); - const rVisTarget = visibleSphereRadius(source); + const rVisTarget = visibleSphereRadius(target); const rVis = swellingRadiusInterpolation(rVisSource, rVisTarget, shift, ALPHA, t); const dist = cameraTargetDistance(rVis, out.mode, out.fov); From 99a2242cb62885ce14b2f1e1a19a931c186cb9dd Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Fri, 16 Aug 2024 14:43:49 +0100 Subject: [PATCH 3/6] Beautiful transitions: trying cubic radius interpolation --- src/mol-canvas3d/camera/transition.ts | 28 ++++++++++++++++++++++++++- tsconfig.commonjs.json | 2 +- tsconfig.json | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/mol-canvas3d/camera/transition.ts b/src/mol-canvas3d/camera/transition.ts index 225b13f7b3..498c270d5f 100644 --- a/src/mol-canvas3d/camera/transition.ts +++ b/src/mol-canvas3d/camera/transition.ts @@ -187,13 +187,39 @@ namespace CameraTransitionManager { * `r0`, `r1` - radius of source (t=0) and target (t=1) sphere; * `dist` - distance between centers of source and target sphere; * `alpha` - swell parameter (0 = no swell = linear interpolation, 1 = sphere for t=0.5 will be circumscribed to source and radius spheres */ -function swellingRadiusInterpolation(r0: number, r1: number, dist: number, alpha: number, t: number): number { +function swellingRadiusInterpolationQuad(r0: number, r1: number, dist: number, alpha: number, t: number): number { const a = -2 * alpha * dist; const b = -a - r0 + r1; const c = r0; return a * t ** 2 + b * t + c; } +function swellingRadiusInterpolation(r0: number, r1: number, dist: number, alpha: 0 | 1, t: number): number { + if (alpha === 0 || dist === 0) { + return (1 - t) * r0 + t * r1; // linear interpolation + } + if (r0 >= dist + r1) { + const alpha = dist / (r0 - r1); + return r1 + (r0 - r1) * magic_cubic(1 - t, alpha); + } + if (r1 >= dist + r0) { + const alpha = dist / (r1 - r0); + return r0 + (r1 - r0) * magic_cubic(t, alpha); + } + const tmax = (dist - r0 + r1) / 2 / dist; + const ymax = (dist + r0 + r1) / 2; + if (t <= tmax) { + return r0 + (ymax - r0) * magic_cubic(t / tmax); + } else { + return r1 + (ymax - r1) * magic_cubic((1 - t) / (1 - tmax)); + } + +} + +function magic_cubic(x: number, alpha: number = 1) { + return (1 + 0.5 * alpha) * x - 0.5 * alpha * x ** 3; +} + /** Return the radius of the largest sphere centered in camera.target which is fully in FOV */ function visibleSphereRadius(camera: Camera.Snapshot): number { const distance = Vec3.distance(camera.target, camera.position); diff --git a/tsconfig.commonjs.json b/tsconfig.commonjs.json index c0c48629bc..1b7b94327e 100644 --- a/tsconfig.commonjs.json +++ b/tsconfig.commonjs.json @@ -6,7 +6,7 @@ "noImplicitAny": true, "noImplicitThis": true, "sourceMap": false, - "noUnusedLocals": true, + "noUnusedLocals": false, "strictNullChecks": true, "strictFunctionTypes": true, "module": "CommonJS", diff --git a/tsconfig.json b/tsconfig.json index 854a12b798..b5cb0b1f2f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "noImplicitAny": true, "noImplicitThis": true, "sourceMap": false, - "noUnusedLocals": true, + "noUnusedLocals": false, "strictNullChecks": true, "strictFunctionTypes": true, "module": "esnext", From 11b92d989da4906feb8866a0b1ae9176e6098b79 Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Mon, 19 Aug 2024 14:41:24 +0100 Subject: [PATCH 4/6] Beautiful transitions: finalized cubic radius interpolation --- src/mol-canvas3d/camera/transition.ts | 73 +++++++++++++++------------ 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/src/mol-canvas3d/camera/transition.ts b/src/mol-canvas3d/camera/transition.ts index 498c270d5f..d02a2eba41 100644 --- a/src/mol-canvas3d/camera/transition.ts +++ b/src/mol-canvas3d/camera/transition.ts @@ -4,6 +4,7 @@ * @author David Sehnal */ +import e from 'express'; import { lerp } from '../../mol-math/interpolate'; import { Quat, Vec3 } from '../../mol-math/linear-algebra'; import { Camera } from '../camera'; @@ -135,24 +136,21 @@ namespace CameraTransitionManager { } export function defaultTransition(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { - const ALPHA = 1; // 0 - equivalent to position interpolation (unless FOV changes), 1 - focuses union of source and target focus spheres in the middle of transition - // TODO make alpha customizable - Camera.copySnapshot(out, target); // Rotate up Quat.slerp(_rotUp, Quat.Identity, Quat.rotationTo(_rotUp, source.up, target.up), t); Vec3.transformQuat(out.up, source.up, _rotUp); - // Lerp target, position & radius + // Interpolate target Vec3.lerp(out.target, source.target, target.target, t); const shift = Vec3.distance(source.target, target.target); // Interpolate radius - out.radius = swellingRadiusInterpolation(source.radius, target.radius, shift, ALPHA, t); + out.radius = swellingRadiusInterpolationSmart(source.radius, target.radius, shift, t); // TODO take change of `clipFar` into account - out.radiusMax = swellingRadiusInterpolation(source.radiusMax, target.radiusMax, shift, ALPHA, t); + out.radiusMax = swellingRadiusInterpolationSmart(source.radiusMax, target.radiusMax, shift, t); // Interpolate fov & fog out.fov = lerp(source.fov, target.fov, t); @@ -162,7 +160,7 @@ namespace CameraTransitionManager { // Interpolate distance (indirectly via visible sphere radius) const rVisSource = visibleSphereRadius(source); const rVisTarget = visibleSphereRadius(target); - const rVis = swellingRadiusInterpolation(rVisSource, rVisTarget, shift, ALPHA, t); + const rVis = swellingRadiusInterpolationSmart(rVisSource, rVisTarget, shift, t); const dist = cameraTargetDistance(rVis, out.mode, out.fov); // Rotate between source and targer direction @@ -182,41 +180,50 @@ namespace CameraTransitionManager { } } - -/** Sphere radius "interpolation" method that increases radius in the middle of transition. +/** Sphere radius "interpolation" method which increases the radius during transition so that for some t (0<=t<=1) the interpolated sphere will contain both source and target spheres. * `r0`, `r1` - radius of source (t=0) and target (t=1) sphere; - * `dist` - distance between centers of source and target sphere; - * `alpha` - swell parameter (0 = no swell = linear interpolation, 1 = sphere for t=0.5 will be circumscribed to source and radius spheres */ -function swellingRadiusInterpolationQuad(r0: number, r1: number, dist: number, alpha: number, t: number): number { - const a = -2 * alpha * dist; - const b = -a - r0 + r1; - const c = r0; - return a * t ** 2 + b * t + c; -} - -function swellingRadiusInterpolation(r0: number, r1: number, dist: number, alpha: 0 | 1, t: number): number { - if (alpha === 0 || dist === 0) { - return (1 - t) * r0 + t * r1; // linear interpolation - } - if (r0 >= dist + r1) { - const alpha = dist / (r0 - r1); - return r1 + (r0 - r1) * magic_cubic(1 - t, alpha); + * `dist` - distance between centers of source and target sphere. */ +function swellingRadiusInterpolationCubic(r0: number, r1: number, dist: number, t: number): number { + if (dist === 0) { + return lerp(r0, r1, t); } - if (r1 >= dist + r0) { + if (r1 >= dist + r0) { // Sphere 1 fully contains sphere 0 const alpha = dist / (r1 - r0); - return r0 + (r1 - r0) * magic_cubic(t, alpha); + return lerp(r0, r1, niceCubic(t, alpha)); + } + if (r0 >= dist + r1) { // Sphere 0 fully contains sphere 1 + const alpha = dist / (r0 - r1); + return lerp(r1, r0, niceCubic(1 - t, alpha)); } const tmax = (dist - r0 + r1) / 2 / dist; - const ymax = (dist + r0 + r1) / 2; + const rmax = (dist + r0 + r1) / 2; if (t <= tmax) { - return r0 + (ymax - r0) * magic_cubic(t / tmax); + return lerp(r0, rmax, niceCubic(t / tmax)); } else { - return r1 + (ymax - r1) * magic_cubic((1 - t) / (1 - tmax)); + return lerp(r1, rmax, niceCubic((1 - t) / (1 - tmax))); } - } - -function magic_cubic(x: number, alpha: number = 1) { +/** Sphere radius "interpolation" method similar to swellingRadiusInterpolationCubic, + * but swells less when source and target sphere overlap, and becomes linear when either sphere contains the center of the other + * (this is to avoid disturbing zoom-out when the source and target are near). */ +function swellingRadiusInterpolationSmart(r0: number, r1: number, dist: number, t: number): number { + const overlapFactor = relativeSphereOverlap(r0, r1, dist); + if (overlapFactor <= 0) return swellingRadiusInterpolationCubic(r0, r1, dist, t); // spheres not overlapping + if (overlapFactor >= 1) return lerp(r0, r1, t); // either sphere contains the center of the other + return lerp(swellingRadiusInterpolationCubic(r0, r1, dist, t), lerp(r0, r1, t) + (1 - overlapFactor), overlapFactor); +} +/** Arbitrary measure of how much two spheres overlap (>0 when spheres do not overlap, >=1 when at least of the spheres contains the center of the other) */ +function relativeSphereOverlap(r0: number, r1: number, dist: number): number { + const overlap = r0 + r1 - dist; + if (r0 === 0 || r1 === 0) { + return overlap >= 0 ? Infinity : -Infinity; + } + return overlap / Math.min(r0, r1); +} +/** Auxiliary cubic function that goes from y(0)=0 to y(1)=1. + * When alpha=1, it is a curve with inflection point in 0 and stationary point in 1. + * When alpha=0, it becomes a linear function. */ +function niceCubic(x: number, alpha: number = 1) { return (1 + 0.5 * alpha) * x - 0.5 * alpha * x ** 3; } From 3b24b818598058d49c649961598869119cf7de52 Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Tue, 3 Sep 2024 15:17:27 +0100 Subject: [PATCH 5/6] Beautiful transitions: playing with ease-in, ease-out, and const rel speed --- src/mol-canvas3d/camera/transition.ts | 55 ++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/mol-canvas3d/camera/transition.ts b/src/mol-canvas3d/camera/transition.ts index d02a2eba41..a77f62f09e 100644 --- a/src/mol-canvas3d/camera/transition.ts +++ b/src/mol-canvas3d/camera/transition.ts @@ -4,7 +4,6 @@ * @author David Sehnal */ -import e from 'express'; import { lerp } from '../../mol-math/interpolate'; import { Quat, Vec3 } from '../../mol-math/linear-algebra'; import { Camera } from '../camera'; @@ -86,6 +85,8 @@ class CameraTransitionManager { } } +const DEFAULT_EASE = [0.5, 0.5] as [easeIn: number, easeOut: number]; // [0, 0] for no ease (linear) + namespace CameraTransitionManager { export type TransitionFunc = (out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot) => void @@ -95,6 +96,8 @@ namespace CameraTransitionManager { const _sourcePosition = Vec3(); const _targetPosition = Vec3(); + export const defaultTransition = defaultTransition_swelling; + export function defaultTransition_orig(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { Camera.copySnapshot(out, target); @@ -135,7 +138,25 @@ namespace CameraTransitionManager { out.fog = lerp(source.fog, target.fog, t); } - export function defaultTransition(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { + export function defaultTransition_linear_ease(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { + t = easeAdjustment(t, ease); + return defaultTransition_orig(out, t, source, target); + } + + export function defaultTransition_linear_constRelSpeed_ease(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { + t = easeAdjustment(t, ease); + const distSource = Vec3.distance(source.target, source.position); + const distTarget = Vec3.distance(target.target, target.position); + const q = constRelSpeedQuotientAdj_linRadIntp(t, distSource, distTarget); + // console.log('adj', q, t, `, R ${distSource}->${distTarget}`) + // TODO calculate from vis.radius, not dist + // TODO only apply constRelSpeedLinRadIntpT2Q to position and distance interpolation, not needed for angles + return defaultTransition_orig(out, q, source, target); + } + + export function defaultTransition_swelling(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { + t = easeAdjustment(t, ease); + Camera.copySnapshot(out, target); // Rotate up @@ -180,6 +201,23 @@ namespace CameraTransitionManager { } } +/** Compute relative transition quotient from relative time, for ease-in/ease-out effect. + * `ease = [0.5, 0.5]` gives a curve very similar to "ease-in-out" aka "cubic-bezier(.42,0,.58,1)" in CSS */ +function easeAdjustment(t: number, ease: [easeIn: number, easeOut: number]): number { + const [tIn, tOut] = ease; + const vMax = 1 / (1 - 0.5 * tIn - 0.5 * tOut); + if (t < tIn) { + // Ease-in phase + return 0.5 * vMax * t ** 2 / tIn; + } else if (t <= 1 - tOut) { + // Linear phase + return 0.5 * vMax * tIn + (t - tIn) * vMax; + } else { + // Ease-out phase + return 1 - 0.5 * vMax * (1 - t) ** 2 / tOut; + } +} + /** Sphere radius "interpolation" method which increases the radius during transition so that for some t (0<=t<=1) the interpolated sphere will contain both source and target spheres. * `r0`, `r1` - radius of source (t=0) and target (t=1) sphere; * `dist` - distance between centers of source and target sphere. */ @@ -242,3 +280,16 @@ function cameraTargetDistance(visRadius: number, mode: Camera.Mode, fov: number) else // perspective return visRadius / Math.sin(fov / 2); } + +/** This adjustment to transition quotient (0-1) ensures that transition speed relative to radius is constant during the transition. + * Only to be applied to position and radius interpolation, not needed for angles. + * This function assumes linear interpolation of radius. For other interpolation methods, more complicated formula will be needed. */ +function constRelSpeedQuotientAdj_linRadIntp(t: number, r0: number, r1: number): number { + if (Math.abs((r0 - r1) / (r0 + r1)) <= 1e-3) { + // Special case for r0===r1 + return t; + } + return r0 / (r1 - r0) * ((r1 / r0) ** t - 1); // = a / b * ((1 + b / a) ** t - 1), where a = r0, b = r1 - r0 +} + +console.log('DEFAULT_EASE', ...DEFAULT_EASE, CameraTransitionManager.defaultTransition.name) From ecf96975181ad6d1265cf7d420cace20391d424b Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Mon, 16 Sep 2024 15:22:58 +0100 Subject: [PATCH 6/6] Beautiful transitions: params --- .../camera/transition-functions.ts | 195 ++++++++++++++++++ src/mol-canvas3d/camera/transition.ts | 194 +++++------------ 2 files changed, 245 insertions(+), 144 deletions(-) create mode 100644 src/mol-canvas3d/camera/transition-functions.ts diff --git a/src/mol-canvas3d/camera/transition-functions.ts b/src/mol-canvas3d/camera/transition-functions.ts new file mode 100644 index 0000000000..d38c3d051d --- /dev/null +++ b/src/mol-canvas3d/camera/transition-functions.ts @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2024 Mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Adam Midlik + */ + +import { lerp } from '../../mol-math/interpolate'; +import { Quat, Vec3 } from '../../mol-math/linear-algebra'; +import { ParamDefinition as PD } from '../../mol-util/param-definition'; +import { Camera } from '../camera'; +import { CameraTransitionManager } from './transition'; + + +export const CameraTransitionParams = { + shape: PD.MappedStatic('linear', { + 'linear': PD.EmptyGroup(), + 'linear-size-relative': PD.EmptyGroup(), + 'leaping': PD.Group({ + smart: PD.Boolean(true, { description: 'Decrease leaping for transitions between near places.' }) + }), + }), + ease: PD.Group({ + easeIn: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }, { description: 'Length of accelerating phase of the transition, relative to total transition duration. easeIn+easeOut must be <= 1.' }), + easeOut: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }, { description: 'Length of decelerating phase of the transition, relative to total transition duration. easeIn+easeOut must be <= 1.' }), + }), +}; +export type CameraTransitionParams = typeof CameraTransitionParams; +export type CameraTransitionProps = PD.ValuesFor; + +// TODO continue here, allowing `CameraTransitionProps` as `transition` in `CameraTransitionManager.apply` and passing from `Camera.setState` in src/mol-canvas3d/camera.ts +export function getTransitionFunction(props: CameraTransitionProps): CameraTransitionManager.TransitionFunc { + const ease = (t: number) => easeAdjustment(t, props.ease.easeIn, props.ease.easeOut); + const shape = getTransitionShapeFunction(props.shape); + return (out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot) => shape(out, ease(t), source, target); +} +function getTransitionShapeFunction(props: CameraTransitionProps['shape']): CameraTransitionManager.TransitionFunc { + if (props.name === 'linear') return defaultTransition_linear; + if (props.name === 'linear-size-relative') return defaultTransition_linear_constRelSpeed; + if (props.name === 'leaping') return defaultTransition_leaping; + throw new Error(`Unknown transition shape: ${(props as CameraTransitionProps['shape']).name}`); +} + + +const _rotUp = Quat.identity(); +const _rotDist = Quat.identity(); + +const _sourcePosition = Vec3(); +const _targetPosition = Vec3(); + + +export const defaultTransition_linear = CameraTransitionManager.defaultTransition; + +export function defaultTransition_linear_constRelSpeed(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { + const distSource = Vec3.distance(source.target, source.position); + const distTarget = Vec3.distance(target.target, target.position); + const q = constRelSpeedQuotientAdj_linRadIntp(t, distSource, distTarget); + // console.log('adj', q, t, `, R ${distSource}->${distTarget}`) + // TODO calculate from vis.radius, not dist + // TODO only apply constRelSpeedLinRadIntpT2Q to position and distance interpolation, not needed for angles + return defaultTransition_linear(out, q, source, target); +} + +export function defaultTransition_leaping(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { + Camera.copySnapshot(out, target); + + // Rotate up + Quat.slerp(_rotUp, Quat.Identity, Quat.rotationTo(_rotUp, source.up, target.up), t); + Vec3.transformQuat(out.up, source.up, _rotUp); + + // Interpolate target + Vec3.lerp(out.target, source.target, target.target, t); + + const shift = Vec3.distance(source.target, target.target); + + // Interpolate radius + out.radius = swellingRadiusInterpolationSmart(source.radius, target.radius, shift, t); + // TODO take change of `clipFar` into account + out.radiusMax = swellingRadiusInterpolationSmart(source.radiusMax, target.radiusMax, shift, t); + + // Interpolate fov & fog + out.fov = lerp(source.fov, target.fov, t); + out.fog = lerp(source.fog, target.fog, t); + // TODO fix Canvas3D.setProps() setting FOV instantly before transition starts! + + // Interpolate distance (indirectly via visible sphere radius) + const rVisSource = visibleSphereRadius(source); + const rVisTarget = visibleSphereRadius(target); + const rVis = swellingRadiusInterpolationSmart(rVisSource, rVisTarget, shift, t); + const dist = cameraTargetDistance(rVis, out.mode, out.fov); + + // Rotate between source and targer direction + Vec3.sub(_sourcePosition, source.position, source.target); + Vec3.normalize(_sourcePosition, _sourcePosition); + + Vec3.sub(_targetPosition, target.position, target.target); + Vec3.normalize(_targetPosition, _targetPosition); + + Quat.rotationTo(_rotDist, _sourcePosition, _targetPosition); + Quat.slerp(_rotDist, Quat.Identity, _rotDist, t); + + Vec3.transformQuat(_sourcePosition, _sourcePosition, _rotDist); + Vec3.scale(_sourcePosition, _sourcePosition, dist); + + Vec3.add(out.position, out.target, _sourcePosition); +} + +/** Compute relative transition quotient from relative time, for ease-in/ease-out effect. + * `ease = [0.5, 0.5]` gives a curve very similar to "ease-in-out" aka "cubic-bezier(.42,0,.58,1)" in CSS */ +function easeAdjustment(t: number, easeIn: number, easeOut: number): number { + const vMax = 1 / (1 - 0.5 * easeIn - 0.5 * easeOut); + if (t < easeIn) { + // Ease-in phase + return 0.5 * vMax * t ** 2 / easeIn; + } else if (t <= 1 - easeOut) { + // Linear phase + return 0.5 * vMax * easeIn + (t - easeIn) * vMax; + } else { + // Ease-out phase + return 1 - 0.5 * vMax * (1 - t) ** 2 / easeOut; + } +} + +/** Sphere radius "interpolation" method which increases the radius during transition so that for some t (0<=t<=1) the interpolated sphere will contain both source and target spheres. + * `r0`, `r1` - radius of source (t=0) and target (t=1) sphere; + * `dist` - distance between centers of source and target sphere. */ +function swellingRadiusInterpolationCubic(r0: number, r1: number, dist: number, t: number): number { + if (dist === 0) { + return lerp(r0, r1, t); + } + if (r1 >= dist + r0) { // Sphere 1 fully contains sphere 0 + const alpha = dist / (r1 - r0); + return lerp(r0, r1, niceCubic(t, alpha)); + } + if (r0 >= dist + r1) { // Sphere 0 fully contains sphere 1 + const alpha = dist / (r0 - r1); + return lerp(r1, r0, niceCubic(1 - t, alpha)); + } + const tmax = (dist - r0 + r1) / 2 / dist; + const rmax = (dist + r0 + r1) / 2; + if (t <= tmax) { + return lerp(r0, rmax, niceCubic(t / tmax)); + } else { + return lerp(r1, rmax, niceCubic((1 - t) / (1 - tmax))); + } +} +/** Sphere radius "interpolation" method similar to swellingRadiusInterpolationCubic, + * but swells less when source and target sphere overlap, and becomes linear when either sphere contains the center of the other + * (this is to avoid disturbing zoom-out when the source and target are near). */ +function swellingRadiusInterpolationSmart(r0: number, r1: number, dist: number, t: number): number { + const overlapFactor = relativeSphereOverlap(r0, r1, dist); + if (overlapFactor <= 0) return swellingRadiusInterpolationCubic(r0, r1, dist, t); // spheres not overlapping + if (overlapFactor >= 1) return lerp(r0, r1, t); // either sphere contains the center of the other + return lerp(swellingRadiusInterpolationCubic(r0, r1, dist, t), lerp(r0, r1, t) + (1 - overlapFactor), overlapFactor); +} +/** Arbitrary measure of how much two spheres overlap (>0 when spheres do not overlap, >=1 when at least of the spheres contains the center of the other) */ +function relativeSphereOverlap(r0: number, r1: number, dist: number): number { + const overlap = r0 + r1 - dist; + if (r0 === 0 || r1 === 0) { + return overlap >= 0 ? Infinity : -Infinity; + } + return overlap / Math.min(r0, r1); +} +/** Auxiliary cubic function that goes from y(0)=0 to y(1)=1. + * When alpha=1, it is a curve with inflection point in 0 and stationary point in 1. + * When alpha=0, it becomes a linear function. */ +function niceCubic(x: number, alpha: number = 1) { + return (1 + 0.5 * alpha) * x - 0.5 * alpha * x ** 3; +} + +/** Return the radius of the largest sphere centered in camera.target which is fully in FOV */ +function visibleSphereRadius(camera: Camera.Snapshot): number { + const distance = Vec3.distance(camera.target, camera.position); + if (camera.mode === 'orthographic') + return distance * Math.tan(camera.fov / 2); + else // perspective + return distance * Math.sin(camera.fov / 2); +} +/** Return the distance of camera from the center of a sphere with radius `visRadius` so that the sphere just fits into FOV */ +function cameraTargetDistance(visRadius: number, mode: Camera.Mode, fov: number): number { + if (mode === 'orthographic') + return visRadius / Math.tan(fov / 2); + else // perspective + return visRadius / Math.sin(fov / 2); +} + +/** This adjustment to transition quotient (0-1) ensures that transition speed relative to radius is constant during the transition. + * Only to be applied to position and radius interpolation, not needed for angles. + * This function assumes linear interpolation of radius. For other interpolation methods, more complicated formula will be needed. */ +function constRelSpeedQuotientAdj_linRadIntp(t: number, r0: number, r1: number): number { + if (Math.abs((r0 - r1) / (r0 + r1)) <= 1e-3) { + // Special case for r0===r1 + return t; + } + return r0 / (r1 - r0) * ((r1 / r0) ** t - 1); // = a / b * ((1 + b / a) ** t - 1), where a = r0, b = r1 - r0 +} diff --git a/src/mol-canvas3d/camera/transition.ts b/src/mol-canvas3d/camera/transition.ts index a77f62f09e..0aec5a8672 100644 --- a/src/mol-canvas3d/camera/transition.ts +++ b/src/mol-canvas3d/camera/transition.ts @@ -11,6 +11,8 @@ import { Camera } from '../camera'; export { CameraTransitionManager }; + + class CameraTransitionManager { private t = 0; @@ -85,7 +87,6 @@ class CameraTransitionManager { } } -const DEFAULT_EASE = [0.5, 0.5] as [easeIn: number, easeOut: number]; // [0, 0] for no ease (linear) namespace CameraTransitionManager { export type TransitionFunc = (out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot) => void @@ -96,9 +97,7 @@ namespace CameraTransitionManager { const _sourcePosition = Vec3(); const _targetPosition = Vec3(); - export const defaultTransition = defaultTransition_swelling; - - export function defaultTransition_orig(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { + export function defaultTransition(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { Camera.copySnapshot(out, target); // Rotate up @@ -138,158 +137,65 @@ namespace CameraTransitionManager { out.fog = lerp(source.fog, target.fog, t); } - export function defaultTransition_linear_ease(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { - t = easeAdjustment(t, ease); - return defaultTransition_orig(out, t, source, target); - } - - export function defaultTransition_linear_constRelSpeed_ease(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { - t = easeAdjustment(t, ease); - const distSource = Vec3.distance(source.target, source.position); - const distTarget = Vec3.distance(target.target, target.position); - const q = constRelSpeedQuotientAdj_linRadIntp(t, distSource, distTarget); - // console.log('adj', q, t, `, R ${distSource}->${distTarget}`) - // TODO calculate from vis.radius, not dist - // TODO only apply constRelSpeedLinRadIntpT2Q to position and distance interpolation, not needed for angles - return defaultTransition_orig(out, q, source, target); - } - - export function defaultTransition_swelling(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { - t = easeAdjustment(t, ease); - - Camera.copySnapshot(out, target); - - // Rotate up - Quat.slerp(_rotUp, Quat.Identity, Quat.rotationTo(_rotUp, source.up, target.up), t); - Vec3.transformQuat(out.up, source.up, _rotUp); + // export function defaultTransition_linear_ease(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { + // t = easeAdjustment(t, ease); + // return defaultTransition_orig(out, t, source, target); + // } - // Interpolate target - Vec3.lerp(out.target, source.target, target.target, t); + // export function defaultTransition_linear_constRelSpeed_ease(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { + // t = easeAdjustment(t, ease); + // const distSource = Vec3.distance(source.target, source.position); + // const distTarget = Vec3.distance(target.target, target.position); + // const q = constRelSpeedQuotientAdj_linRadIntp(t, distSource, distTarget); + // // console.log('adj', q, t, `, R ${distSource}->${distTarget}`) + // // TODO calculate from vis.radius, not dist + // // TODO only apply constRelSpeedLinRadIntpT2Q to position and distance interpolation, not needed for angles + // return defaultTransition_orig(out, q, source, target); + // } - const shift = Vec3.distance(source.target, target.target); + // export function defaultTransition_leaping(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, ease: [easeIn: number, easeOut: number] = DEFAULT_EASE): void { + // t = easeAdjustment(t, ease); - // Interpolate radius - out.radius = swellingRadiusInterpolationSmart(source.radius, target.radius, shift, t); - // TODO take change of `clipFar` into account - out.radiusMax = swellingRadiusInterpolationSmart(source.radiusMax, target.radiusMax, shift, t); + // Camera.copySnapshot(out, target); - // Interpolate fov & fog - out.fov = lerp(source.fov, target.fov, t); - out.fog = lerp(source.fog, target.fog, t); - // TODO fix Canvas3D.setProps() setting FOV instantly before transition starts! + // // Rotate up + // Quat.slerp(_rotUp, Quat.Identity, Quat.rotationTo(_rotUp, source.up, target.up), t); + // Vec3.transformQuat(out.up, source.up, _rotUp); - // Interpolate distance (indirectly via visible sphere radius) - const rVisSource = visibleSphereRadius(source); - const rVisTarget = visibleSphereRadius(target); - const rVis = swellingRadiusInterpolationSmart(rVisSource, rVisTarget, shift, t); - const dist = cameraTargetDistance(rVis, out.mode, out.fov); + // // Interpolate target + // Vec3.lerp(out.target, source.target, target.target, t); - // Rotate between source and targer direction - Vec3.sub(_sourcePosition, source.position, source.target); - Vec3.normalize(_sourcePosition, _sourcePosition); + // const shift = Vec3.distance(source.target, target.target); - Vec3.sub(_targetPosition, target.position, target.target); - Vec3.normalize(_targetPosition, _targetPosition); + // // Interpolate radius + // out.radius = swellingRadiusInterpolationSmart(source.radius, target.radius, shift, t); + // // TODO take change of `clipFar` into account + // out.radiusMax = swellingRadiusInterpolationSmart(source.radiusMax, target.radiusMax, shift, t); - Quat.rotationTo(_rotDist, _sourcePosition, _targetPosition); - Quat.slerp(_rotDist, Quat.Identity, _rotDist, t); + // // Interpolate fov & fog + // out.fov = lerp(source.fov, target.fov, t); + // out.fog = lerp(source.fog, target.fog, t); + // // TODO fix Canvas3D.setProps() setting FOV instantly before transition starts! - Vec3.transformQuat(_sourcePosition, _sourcePosition, _rotDist); - Vec3.scale(_sourcePosition, _sourcePosition, dist); + // // Interpolate distance (indirectly via visible sphere radius) + // const rVisSource = visibleSphereRadius(source); + // const rVisTarget = visibleSphereRadius(target); + // const rVis = swellingRadiusInterpolationSmart(rVisSource, rVisTarget, shift, t); + // const dist = cameraTargetDistance(rVis, out.mode, out.fov); - Vec3.add(out.position, out.target, _sourcePosition); - } -} + // // Rotate between source and targer direction + // Vec3.sub(_sourcePosition, source.position, source.target); + // Vec3.normalize(_sourcePosition, _sourcePosition); -/** Compute relative transition quotient from relative time, for ease-in/ease-out effect. - * `ease = [0.5, 0.5]` gives a curve very similar to "ease-in-out" aka "cubic-bezier(.42,0,.58,1)" in CSS */ -function easeAdjustment(t: number, ease: [easeIn: number, easeOut: number]): number { - const [tIn, tOut] = ease; - const vMax = 1 / (1 - 0.5 * tIn - 0.5 * tOut); - if (t < tIn) { - // Ease-in phase - return 0.5 * vMax * t ** 2 / tIn; - } else if (t <= 1 - tOut) { - // Linear phase - return 0.5 * vMax * tIn + (t - tIn) * vMax; - } else { - // Ease-out phase - return 1 - 0.5 * vMax * (1 - t) ** 2 / tOut; - } -} + // Vec3.sub(_targetPosition, target.position, target.target); + // Vec3.normalize(_targetPosition, _targetPosition); -/** Sphere radius "interpolation" method which increases the radius during transition so that for some t (0<=t<=1) the interpolated sphere will contain both source and target spheres. - * `r0`, `r1` - radius of source (t=0) and target (t=1) sphere; - * `dist` - distance between centers of source and target sphere. */ -function swellingRadiusInterpolationCubic(r0: number, r1: number, dist: number, t: number): number { - if (dist === 0) { - return lerp(r0, r1, t); - } - if (r1 >= dist + r0) { // Sphere 1 fully contains sphere 0 - const alpha = dist / (r1 - r0); - return lerp(r0, r1, niceCubic(t, alpha)); - } - if (r0 >= dist + r1) { // Sphere 0 fully contains sphere 1 - const alpha = dist / (r0 - r1); - return lerp(r1, r0, niceCubic(1 - t, alpha)); - } - const tmax = (dist - r0 + r1) / 2 / dist; - const rmax = (dist + r0 + r1) / 2; - if (t <= tmax) { - return lerp(r0, rmax, niceCubic(t / tmax)); - } else { - return lerp(r1, rmax, niceCubic((1 - t) / (1 - tmax))); - } -} -/** Sphere radius "interpolation" method similar to swellingRadiusInterpolationCubic, - * but swells less when source and target sphere overlap, and becomes linear when either sphere contains the center of the other - * (this is to avoid disturbing zoom-out when the source and target are near). */ -function swellingRadiusInterpolationSmart(r0: number, r1: number, dist: number, t: number): number { - const overlapFactor = relativeSphereOverlap(r0, r1, dist); - if (overlapFactor <= 0) return swellingRadiusInterpolationCubic(r0, r1, dist, t); // spheres not overlapping - if (overlapFactor >= 1) return lerp(r0, r1, t); // either sphere contains the center of the other - return lerp(swellingRadiusInterpolationCubic(r0, r1, dist, t), lerp(r0, r1, t) + (1 - overlapFactor), overlapFactor); -} -/** Arbitrary measure of how much two spheres overlap (>0 when spheres do not overlap, >=1 when at least of the spheres contains the center of the other) */ -function relativeSphereOverlap(r0: number, r1: number, dist: number): number { - const overlap = r0 + r1 - dist; - if (r0 === 0 || r1 === 0) { - return overlap >= 0 ? Infinity : -Infinity; - } - return overlap / Math.min(r0, r1); -} -/** Auxiliary cubic function that goes from y(0)=0 to y(1)=1. - * When alpha=1, it is a curve with inflection point in 0 and stationary point in 1. - * When alpha=0, it becomes a linear function. */ -function niceCubic(x: number, alpha: number = 1) { - return (1 + 0.5 * alpha) * x - 0.5 * alpha * x ** 3; -} + // Quat.rotationTo(_rotDist, _sourcePosition, _targetPosition); + // Quat.slerp(_rotDist, Quat.Identity, _rotDist, t); -/** Return the radius of the largest sphere centered in camera.target which is fully in FOV */ -function visibleSphereRadius(camera: Camera.Snapshot): number { - const distance = Vec3.distance(camera.target, camera.position); - if (camera.mode === 'orthographic') - return distance * Math.tan(camera.fov / 2); - else // perspective - return distance * Math.sin(camera.fov / 2); -} -/** Return the distance of camera from the center of a sphere with radius `visRadius` so that the sphere just fits into FOV */ -function cameraTargetDistance(visRadius: number, mode: Camera.Mode, fov: number): number { - if (mode === 'orthographic') - return visRadius / Math.tan(fov / 2); - else // perspective - return visRadius / Math.sin(fov / 2); -} + // Vec3.transformQuat(_sourcePosition, _sourcePosition, _rotDist); + // Vec3.scale(_sourcePosition, _sourcePosition, dist); -/** This adjustment to transition quotient (0-1) ensures that transition speed relative to radius is constant during the transition. - * Only to be applied to position and radius interpolation, not needed for angles. - * This function assumes linear interpolation of radius. For other interpolation methods, more complicated formula will be needed. */ -function constRelSpeedQuotientAdj_linRadIntp(t: number, r0: number, r1: number): number { - if (Math.abs((r0 - r1) / (r0 + r1)) <= 1e-3) { - // Special case for r0===r1 - return t; - } - return r0 / (r1 - r0) * ((r1 / r0) ** t - 1); // = a / b * ((1 + b / a) ** t - 1), where a = r0, b = r1 - r0 + // Vec3.add(out.position, out.target, _sourcePosition); + // } } - -console.log('DEFAULT_EASE', ...DEFAULT_EASE, CameraTransitionManager.defaultTransition.name)