transition easing, refactoring

This commit is contained in:
dsehnal
2026-05-29 12:04:07 +02:00
parent 1ac4980348
commit f238b00ef9
7 changed files with 132 additions and 85 deletions

View File

@@ -19,7 +19,8 @@ Note that since we don't clearly distinguish between a public and private interf
- Fix SSAO half/quarter resolution textures for multi-scale
- Camera improvements
- Add the option to approximate "least obstructed direction" when focusing camera, accessibe via `PluginContext.managers.camera.focusLoci` with `optimizeDirection` option
- Add the option to "zoom out" to entire scene before focusing camera
- Add `CameraFocusOptions.zoomOut` option that zooms out to to make the entire scene visible before focusing on the target
- Add easing support in camera transtion
## [v5.9.0] - 2026-05-03
- Fix edge case when `PluginSpec.animations` is empty

View File

@@ -6,7 +6,7 @@
*/
import { SortedArray } from '../../../mol-data/int';
import * as EasingFns from '../../../mol-math/easing';
import { EasingFunctions } from '../../../mol-math/easing';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { EPSILON, Mat3, Mat4, Quat, Vec3 } from '../../../mol-math/linear-algebra';
import { RuntimeContext } from '../../../mol-task';
@@ -65,27 +65,7 @@ export async function generateStateTransition(ctx: RuntimeContext, snapshot: Sna
return { tree, frametimeMs: dt, frames };
}
const EasingFnMap: Record<MVSAnimationEasing, (t: number) => number> = {
'linear': t => t,
'bounce-in': EasingFns.bounceIn,
'bounce-out': EasingFns.bounceOut,
'bounce-in-out': EasingFns.bounceInOut,
'circle-in': EasingFns.circleIn,
'circle-out': EasingFns.circleOut,
'circle-in-out': EasingFns.circleInOut,
'cubic-in': EasingFns.cubicIn,
'cubic-out': EasingFns.cubicOut,
'cubic-in-out': EasingFns.cubicInOut,
'exp-in': EasingFns.expIn,
'exp-out': EasingFns.expOut,
'exp-in-out': EasingFns.expInOut,
'quad-in': EasingFns.quadIn,
'quad-out': EasingFns.quadOut,
'quad-in-out': EasingFns.quadInOut,
'sin-in': EasingFns.sinIn,
'sin-out': EasingFns.sinOut,
'sin-in-out': EasingFns.sinInOut,
};
const EasingFnMap: Record<MVSAnimationEasing, (t: number) => number> = EasingFunctions;
interface InterpolationCacheEntry {
paletteFn?: (value: number) => Color,

View File

@@ -6,7 +6,7 @@
*/
import { Viewport, cameraProject, cameraUnproject } from './camera/util';
import { CameraTransitionManager } from './camera/transition';
import { CameraTransitionManager, CameraTransitionOptions } from './camera/transition';
import { BehaviorSubject, Subject } from 'rxjs';
import { Scene } from '../mol-gl/scene';
import { assertUnreachable } from '../mol-util/type-helpers';
@@ -138,7 +138,11 @@ export class Camera implements ICamera {
return changed;
}
setState(snapshot: Partial<Camera.Snapshot>, durationMs?: number, options?: { keyframes?: CameraTransitionManager.TransitionKeyframes }) {
setState(
snapshot: Partial<Camera.Snapshot>,
durationMs?: number,
options?: CameraTransitionOptions
) {
this.transition.apply(snapshot, durationMs, undefined, options);
this.stateChanged.next(snapshot);
}

View File

@@ -8,9 +8,15 @@ import { Camera } from '../camera';
import { lerp } from '../../mol-math/interpolate';
import { Quat } from '../../mol-math/linear-algebra/3d/quat';
import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
import { EasingFunction, getEasingFn } from '../../mol-math/easing';
export { CameraTransitionManager };
export interface CameraTransitionOptions {
keyframes?: CameraTransitionManager.TransitionKeyframes,
easing?: EasingFunction
}
class CameraTransitionManager {
private t = 0;
@@ -20,7 +26,7 @@ class CameraTransitionManager {
private durationMs = 0;
private _source: Camera.Snapshot = Camera.createDefaultSnapshot();
private _target: Camera.Snapshot = Camera.createDefaultSnapshot();
private _keyframes: CameraTransitionManager.TransitionKeyframes | undefined = void 0;
private _options: CameraTransitionOptions | undefined = void 0;
private _current = Camera.createDefaultSnapshot();
get source(): Readonly<Camera.Snapshot> { return this._source; }
@@ -30,7 +36,7 @@ class CameraTransitionManager {
to: Partial<Camera.Snapshot>,
durationMs: number = 0,
transition?: CameraTransitionManager.TransitionFunc,
options?: { keyframes?: CameraTransitionManager.TransitionKeyframes },
options?: CameraTransitionOptions,
) {
if (!this.inTransition || durationMs > 0) {
Camera.copySnapshot(this._source, this.camera.state);
@@ -56,7 +62,7 @@ class CameraTransitionManager {
this.inTransition = true;
this.func = transition || CameraTransitionManager.defaultTransition;
this._keyframes = options?.keyframes;
this._options = options;
if (!this.inTransition || durationMs > 0) {
this.start = this.t;
@@ -83,7 +89,7 @@ class CameraTransitionManager {
return;
}
this.func(this._current, normalized, this._source, this._target, { keyframes: this._keyframes });
this.func(this._current, normalized, this._source, this._target, this._options);
Camera.copySnapshot(this.camera.state, this._current);
}
@@ -93,7 +99,7 @@ class CameraTransitionManager {
}
namespace CameraTransitionManager {
export type TransitionKeyframes = { t: number, snapshot: Partial<Camera.Snapshot> }[]
export type TransitionKeyframes = { t: number, snapshot: Partial<Camera.Snapshot>, easing?: EasingFunction }[]
export type TransitionFunc = (out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, options?: { keyframes?: TransitionKeyframes }) => void
const _rotUp = Quat.identity();
@@ -102,16 +108,22 @@ namespace CameraTransitionManager {
const _sourcePosition = Vec3();
const _targetPosition = Vec3();
let _tempSource: Camera.Snapshot | undefined = void 0;
let _tempTarget: Camera.Snapshot | undefined = void 0;
export function defaultTransition(out: Camera.Snapshot, t_: number, source_: Camera.Snapshot, target_: Camera.Snapshot, options?: { keyframes?: TransitionKeyframes }): void {
export function defaultTransition(
out: Camera.Snapshot,
t_: number,
source_: Camera.Snapshot,
target_: Camera.Snapshot,
options?: CameraTransitionOptions
): void {
let sourcePartial: Partial<Camera.Snapshot> = source_;
let targetPartial: Partial<Camera.Snapshot> = target_;
let tStart = 0;
let tEnd = 1;
let easingKindStart = options?.easing;
const keyframes = options?.keyframes;
if (keyframes && keyframes.length > 0) {
@@ -120,22 +132,22 @@ namespace CameraTransitionManager {
if (t_ >= keyframe.t) {
sourcePartial = keyframe.snapshot;
tStart = keyframe.t;
easingKindStart = keyframe.easing ?? easingKindStart;
break;
}
}
for (let i = keyframes.length - 1; i >= 0; i--) {
for (let i = 0; i < keyframes.length; i++) {
const keyframe = keyframes[i];
if (t_ <= keyframe.t) {
if (keyframe.t >= t_) {
targetPartial = keyframe.snapshot;
tEnd = keyframe.t;
}
if (t_ >= keyframe.t) {
break;
}
}
}
const t = (t_ - tStart) / (tEnd - tStart);
const easing = getEasingFn(easingKindStart);
const t = easing((t_ - tStart) / (tEnd - tStart));
if (!_tempSource) _tempSource = Camera.createDefaultSnapshot();
if (!_tempTarget) _tempTarget = Camera.createDefaultSnapshot();

View File

@@ -54,6 +54,7 @@ import { produce } from '../mol-util/produce';
import { ShaderManager } from './helper/shader-manager';
import { toFixed } from '../mol-util/number';
import type { CameraTransitionManager } from './camera/transition';
import { EasingFunction } from '../mol-math/easing';
export const CameraFogParams = {
intensity: PD.Numeric(15, { min: 1, max: 100, step: 1 }),
@@ -322,6 +323,13 @@ namespace Canvas3DContext {
export { Canvas3D };
export interface Canvas3DCameraResetOptions {
durationMs?: number,
snapshot?: Camera.SnapshotProvider,
keyframes?: CameraTransitionManager.TransitionKeyframes,
easing?: EasingFunction,
}
interface Canvas3D {
readonly webgl: WebGLContext,
@@ -372,7 +380,7 @@ interface Canvas3D {
/** performs handleResize on the next animation frame */
requestResize(): void
/** Focuses camera on scene's bounding sphere, centered and zoomed. */
requestCameraReset(options?: { durationMs?: number, snapshot?: Camera.SnapshotProvider, keyframes?: CameraTransitionManager.TransitionKeyframes }): void
requestCameraReset(options?: Canvas3DCameraResetOptions): void
readonly camera: Camera
readonly boundingSphere: Readonly<Sphere3D>
readonly boundingSphereVisible: Readonly<Sphere3D>
@@ -499,9 +507,12 @@ namespace Canvas3D {
});
let cameraResetRequested = false;
let nextCameraResetDuration: number | undefined = void 0;
let nextCameraResetSnapshot: Camera.SnapshotProvider | undefined = void 0;
let nextCameraResetKeyframes: CameraTransitionManager.TransitionKeyframes | undefined = void 0;
const nextCameraResetOptions: Canvas3DCameraResetOptions = {
durationMs: undefined,
snapshot: undefined,
keyframes: undefined,
easing: undefined,
};
let resizeRequested = false;
//
@@ -880,16 +891,18 @@ namespace Canvas3D {
}
if (radius > 0) {
const duration = nextCameraResetDuration === undefined ? p.cameraResetDurationMs : nextCameraResetDuration;
const duration = nextCameraResetOptions.durationMs === undefined ? p.cameraResetDurationMs : nextCameraResetOptions.durationMs;
const focus = camera.getFocus(center, radius);
const next = typeof nextCameraResetSnapshot === 'function' ? nextCameraResetSnapshot(scene, camera) : nextCameraResetSnapshot;
const next = typeof nextCameraResetOptions.snapshot === 'function' ? nextCameraResetOptions.snapshot(scene, camera) : nextCameraResetOptions.snapshot;
const snapshot = next ? { ...focus, ...next } : focus;
camera.setState({ ...snapshot, radiusMax: getSceneRadius() }, duration, { keyframes: nextCameraResetKeyframes });
camera.setState({ ...snapshot, radiusMax: getSceneRadius() }, duration, { keyframes: nextCameraResetOptions.keyframes, easing: nextCameraResetOptions.easing });
}
nextCameraResetDuration = void 0;
nextCameraResetSnapshot = void 0;
nextCameraResetKeyframes = void 0;
nextCameraResetOptions.durationMs = void 0;
nextCameraResetOptions.snapshot = void 0;
nextCameraResetOptions.keyframes = void 0;
nextCameraResetOptions.easing = void 0;
cameraResetRequested = false;
}
@@ -899,7 +912,7 @@ namespace Canvas3D {
function shouldResetCamera() {
if (camera.state.radiusMax === 0) return true;
if (camera.transition.inTransition || nextCameraResetSnapshot) return false;
if (camera.transition.inTransition || nextCameraResetOptions.snapshot) return false;
let cameraSphereOverlapsNone = true, isEmpty = true;
Sphere3D.set(cameraSphere, camera.state.target, camera.state.radius);
@@ -941,7 +954,7 @@ namespace Canvas3D {
if (!p.camera.manualReset && (reprCount.value === 0 || shouldResetCamera())) {
cameraResetRequested = true;
}
if (oldBoundingSphereVisible.radius === 0) nextCameraResetDuration = 0;
if (oldBoundingSphereVisible.radius === 0) nextCameraResetOptions.durationMs = 0;
if (!p.camera.manualReset) camera.setState({ radiusMax: getSceneRadius() }, 0);
reprCount.next(reprRenderObjects.size);
@@ -1225,7 +1238,7 @@ namespace Canvas3D {
syncVisibility: () => {
if (camera.state.radiusMax === 0) {
cameraResetRequested = true;
nextCameraResetDuration = 0;
nextCameraResetOptions.durationMs = 0;
}
if (scene.syncVisibility()) {
@@ -1254,9 +1267,7 @@ namespace Canvas3D {
resizeRequested = true;
},
requestCameraReset: options => {
nextCameraResetDuration = options?.durationMs;
nextCameraResetSnapshot = options?.snapshot;
nextCameraResetKeyframes = options?.keyframes;
Object.assign(nextCameraResetOptions, options);
cameraResetRequested = true;
},
camera,

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-26 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
*
* adapted from https://github.com/d3/d3-ease
*/
@@ -103,3 +104,33 @@ export function sinInOut(t: number) {
}
//
export const EasingFunctions = {
'linear': (t: number) => t,
'bounce-in': bounceIn,
'bounce-out': bounceOut,
'bounce-in-out': bounceInOut,
'circle-in': circleIn,
'circle-out': circleOut,
'circle-in-out': circleInOut,
'cubic-in': cubicIn,
'cubic-out': cubicOut,
'cubic-in-out': cubicInOut,
'exp-in': expIn,
'exp-out': expOut,
'exp-in-out': expInOut,
'quad-in': quadIn,
'quad-out': quadOut,
'quad-in-out': quadInOut,
'sin-in': sinIn,
'sin-out': sinOut,
'sin-in-out': sinInOut,
};
export type EasingKind = keyof typeof EasingFunctions;
export type EasingFunction = EasingKind | ((t: number) => number);
export function getEasingFn(easing: EasingFunction | undefined): (t: number) => number {
if (!easing) return EasingFunctions.linear;
return typeof easing === 'function' ? easing : EasingFunctions[easing] ?? EasingFunctions.linear;
}

View File

@@ -24,18 +24,21 @@ import { pcaFocus } from './focus-camera/focus-first-residue';
import { getFocusSnapshot } from './focus-camera/focus-object';
import { changeCameraRotation, structureLayingTransform } from './focus-camera/orient-axes';
// TODO: make this customizable somewhere?
const DefaultCameraFocusOptions = {
export const DefaultCameraFocusOptions = {
minRadius: 1,
extraRadius: 4,
durationMs: 250,
// When set, zooms out to the current scene bounding sphere before focusing on the target.
zoomOut: false,
zoomOutOptions: {
durationFactor: 3.5,
}
};
export type CameraFocusOptions = typeof DefaultCameraFocusOptions
export type CameraFocusLociOptions = CameraFocusOptions & {
optimizeDirection?: boolean,
optimizeDirectionUp?: Vec3,
zoomOut?: boolean,
}
export class CameraManager {
@@ -164,32 +167,7 @@ export class CameraManager {
snapshot = this.focusLociBase(loci, options);
}
if (!snapshot) return;
const durationMs = options?.durationMs ?? DefaultCameraFocusOptions.durationMs;
if (options?.zoomOut) {
const sphere = this.plugin.canvas3d.boundingSphere;
const mid = this.getFocusSphereSnapshot(sphere, options) as Camera.Snapshot;
const current = this.plugin.canvas3d?.camera.getSnapshot()!;
const distA = Vec3.distance(current.position, mid.position);
const distB = Vec3.distance(mid.position, snapshot.position!);
const t = distA / (distA + distB);
const timeFactor = 1 + 3 * Math.min(t, 0.5);
this.plugin.canvas3d?.requestCameraReset({
snapshot,
durationMs: timeFactor * durationMs,
keyframes: [
{ t, snapshot: mid },
]
});
return;
}
this.plugin.canvas3d.requestCameraReset({ snapshot, durationMs });
this.focusSnapshot(snapshot, options);
}
focusSpheres<T>(xs: ReadonlyArray<T>, sphere: (t: T) => Sphere3D | undefined, options?: Partial<CameraFocusOptions> & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) {
@@ -228,6 +206,36 @@ export class CameraManager {
}
}
private focusSnapshot(snapshot: Partial<Camera.Snapshot> | undefined, options?: Partial<CameraFocusOptions>) {
if (!this.plugin.canvas3d || !snapshot) return;
const durationMs = options?.durationMs ?? DefaultCameraFocusOptions.durationMs;
if (!options?.zoomOut) {
this.plugin.canvas3d.requestCameraReset({ snapshot, durationMs });
return;
}
const sphere = this.plugin.canvas3d.boundingSphere;
const zoomOut = this.getFocusSphereSnapshot(sphere, options) as Camera.Snapshot;
const current = this.plugin.canvas3d?.camera.getSnapshot()!;
const distA = Vec3.distance(current.position, zoomOut.position);
const distB = Vec3.distance(zoomOut.position, snapshot.position!);
const t = distA / (distA + distB);
const durationFactor = options?.zoomOutOptions?.durationFactor ?? DefaultCameraFocusOptions.zoomOutOptions.durationFactor;
const df = 1 + durationFactor * Math.min(t, 0.5);
this.plugin.canvas3d.requestCameraReset({
snapshot,
durationMs: df * durationMs,
easing: 'cubic-out',
keyframes: t > 0.05 ? [
{ t, snapshot: zoomOut, easing: 'cubic-in' },
] : undefined
});
}
focusSphere(sphere: Sphere3D, options?: Partial<CameraFocusOptions> & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) {
const { canvas3d } = this.plugin;
if (!canvas3d) return;
@@ -235,7 +243,7 @@ export class CameraManager {
const snapshot = this.getFocusSphereSnapshot(sphere, options);
if (!snapshot) return;
canvas3d.requestCameraReset({ durationMs: options?.durationMs ?? DefaultCameraFocusOptions.durationMs, snapshot });
this.focusSnapshot(snapshot, options);
}
/** Focus on a set of plugin state object cells (if `options.targets` is non-empty) or on the whole scene (if `options.targets` is empty). */
@@ -246,7 +254,7 @@ export class CameraManager {
targets: options.targets?.map(t => ({ ...t, extraRadius: t.extraRadius ?? DefaultCameraFocusOptions.extraRadius })),
minRadius: options.minRadius ?? DefaultCameraFocusOptions.minRadius,
});
this.plugin.canvas3d.requestCameraReset({ snapshot, durationMs: options.durationMs ?? DefaultCameraFocusOptions.durationMs });
this.focusSnapshot(snapshot, options);
}
/** Align PCA axes of `structures` (default: all loaded structures) to the screen axes. */