mirror of
https://github.com/molstar/molstar.git
synced 2026-06-04 13:30:24 +08:00
transition easing, refactoring
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user