@lightningjs/renderer
Version:
Lightning 3 Renderer
378 lines (338 loc) • 11 kB
text/typescript
/*
* If not stated otherwise in this file or this component's LICENSE file the
* following copyright and licenses apply:
*
* Copyright 2023 Comcast Cable Communications Management, LLC.
*
* Licensed under the Apache License, Version 2.0 (the License);
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type CoreNode, type CoreNodeAnimateProps } from '../CoreNode.js';
import { getTimingFunction, type TimingFunction } from '../utils.js';
import { mergeColorProgress } from '../../utils.js';
import { EventEmitter } from '../../common/EventEmitter.js';
export interface AnimationSettings {
duration: number;
delay: number;
easing: string | TimingFunction;
loop: boolean;
repeat: number;
stopMethod: 'reverse' | 'reset' | false;
}
type PropGroup = {
keys: string[];
starts: number[];
targets: number[];
isColor: boolean[];
length: number;
};
type PropValuesMap = {
props: PropGroup | null;
shaderProps: PropGroup | null;
};
let animationIdCounter = 0;
export class CoreAnimation extends EventEmitter {
public id: number = 0;
public duration!: number;
public easing!: string | TimingFunction;
public loop!: boolean;
public repeat!: number;
public stopMethod!: 'reverse' | 'reset' | false;
// Cached at init() time -- avoids re-computation in the per-frame hot path
private hasEasing = false;
// Reciprocal of duration -- stored so update() multiplies instead of divides
private invDuration = 0;
private progress = 0;
private delayFor = 0;
private delay = 0;
private timingFunction!: TimingFunction;
private node!: CoreNode;
// Index into AnimationManager.activeAnimations -- kept in sync on every
// register/swap-remove so unregisterAnimation() is O(1) with no indexOf scan.
public activeIndex = -1;
// Persistent PropGroup instances -- reused across pool recycles to avoid
// allocating new arrays each time. length tracks how many entries are active.
private propsGroup: PropGroup = {
keys: [],
starts: [],
targets: [],
isColor: [],
length: 0,
};
private shaderPropsGroup: PropGroup = {
keys: [],
starts: [],
targets: [],
isColor: [],
length: 0,
};
// Fixed set of event names this animation emits -- used for zero-alloc clearListeners()
static readonly EVENTS = [
'finished',
'animating',
'tick',
'destroyed',
] as const;
propValuesMap: PropValuesMap = { props: null, shaderProps: null };
constructor() {
super();
}
/**
* Initialize (or reinitialize) this animation with new parameters.
* Called both on first use and when recycled from the pool.
*/
init(
node: CoreNode,
props: Partial<CoreNodeAnimateProps>,
settings: Partial<AnimationSettings>,
): void {
this.id = ++animationIdCounter;
this.node = node;
this.progress = 0;
this.activeIndex = -1;
this.propValuesMap.props = null;
this.propValuesMap.shaderProps = null;
// Clear any stale listeners from the previous use. With the arr.length > 0
// guard in clearListeners(), this is a near-zero cost read of 4 array lengths
// when unregisterAnimation() has already emptied them (the common case).
this.clearListeners(CoreAnimation.EVENTS);
// Reset persistent group lengths (reuse existing arrays, no new allocations)
this.propsGroup.length = 0;
this.shaderPropsGroup.length = 0;
for (const key in props) {
if (key !== 'shaderProps') {
if (this.propValuesMap['props'] === null) {
this.propValuesMap['props'] = this.propsGroup;
}
const group = this.propsGroup;
const i = group.length++;
group.keys[i] = key;
group.starts[i] =
node[key as keyof Omit<CoreNodeAnimateProps, 'shaderProps'>] || 0;
group.targets[i] = props[
key as keyof Omit<CoreNodeAnimateProps, 'shaderProps'>
] as number;
group.isColor[i] = key.indexOf('color') !== -1;
} else if (key === 'shaderProps' && node.shader !== null) {
this.propValuesMap['shaderProps'] = this.shaderPropsGroup;
const group = this.shaderPropsGroup;
for (const key in props.shaderProps) {
let start = node.shader.props![key];
if (Array.isArray(start) === true) {
start = start[0];
}
const i = group.length++;
group.keys[i] = key;
group.starts[i] = start;
group.targets[i] = props.shaderProps[key] as number;
group.isColor[i] = key.indexOf('color') !== -1;
}
}
}
const easing = settings.easing || 'linear';
const delay = settings.delay ?? 0;
const duration = settings.duration ?? 0;
this.duration = duration;
// Pre-compute reciprocal to replace per-frame division with multiplication
this.invDuration = duration > 0 ? 1 / duration : 0;
this.delay = delay;
this.easing = easing;
this.loop = settings.loop ?? false;
this.repeat = settings.repeat ?? 0;
this.stopMethod = settings.stopMethod ?? false;
this.timingFunction =
typeof easing === 'string' ? getTimingFunction(easing) : easing;
// Explicit bool -- avoids string comparison on every updateValue() call
this.hasEasing = easing !== 'linear';
this.delayFor = delay;
}
reset() {
this.progress = 0;
this.delayFor = this.delay || 0;
// Write start values directly rather than calling update(0), which would
// run the full update pipeline (dirty marking, event emissions) for no gain.
// Identical visible behaviour -- node properties snap to start values.
const propsGroup = this.propValuesMap.props;
const shaderGroup = this.propValuesMap.shaderProps;
if (propsGroup !== null) {
this.restoreValues(
this.node as unknown as Record<string, number>,
propsGroup,
);
}
if (shaderGroup !== null) {
this.restoreValues(
this.node.shader!.props as Record<string, number>,
shaderGroup,
);
}
}
private restoreValues(target: Record<string, number>, group: PropGroup) {
const keys = group.keys;
const starts = group.starts;
const length = group.length;
for (let i = 0; i < length; i++) {
target[keys[i]!] = starts[i]!;
}
}
restore() {
// reset() already writes start values back to node properties
this.reset();
}
private reverseValues(group: PropGroup) {
const starts = group.starts;
const targets = group.targets;
const length = group.length;
for (let i = 0; i < length; i++) {
const tmp = starts[i]!;
starts[i] = targets[i]!;
targets[i] = tmp;
}
}
reverse() {
this.progress = 0;
if (this.propValuesMap['props'] !== null) {
this.reverseValues(this.propValuesMap['props']);
}
if (this.propValuesMap['shaderProps'] !== null) {
this.reverseValues(this.propValuesMap['shaderProps']);
}
// restore stop method if we are not looping
if (this.loop === false) {
this.stopMethod = false;
}
}
/**
* Interpolate a single property value given the current progress.
* progress is passed as a parameter so callers can cache it in a local,
* avoiding repeated this.progress reads (which box floats in V8).
*/
updateValue(
isColor: boolean,
propValue: number,
startValue: number,
progress: number,
): number {
if (progress === 1) {
return propValue;
}
if (progress === 0) {
return startValue;
}
if (isColor === true) {
if (startValue === propValue) {
return startValue;
}
if (this.hasEasing === true) {
const p = this.timingFunction(progress) || progress;
return mergeColorProgress(startValue, propValue, p);
}
return mergeColorProgress(startValue, propValue, progress);
}
if (this.hasEasing === true) {
// Inlined applyEasing: this.timingFunction(p) * (e - s) + s
return (
this.timingFunction(progress) * (propValue - startValue) + startValue
);
}
return startValue + (propValue - startValue) * progress;
}
private updateValues(
target: Record<string, number>,
group: PropGroup,
progress: number,
) {
const keys = group.keys;
const starts = group.starts;
const targets = group.targets;
const isColor = group.isColor;
const length = group.length;
for (let i = 0; i < length; i++) {
target[keys[i]!] = this.updateValue(
isColor[i]!,
targets[i]!,
starts[i]!,
progress,
);
}
}
update(dt: number) {
const { duration, loop, stopMethod } = this;
const { delayFor } = this;
if (this.node.destroyed) {
this.emit('destroyed');
return;
}
if (duration === 0 && delayFor === 0) {
this.emit('finished');
return;
}
if (this.delayFor > 0) {
this.delayFor -= dt;
if (this.delayFor >= 0) {
// Either no or more delay left. Exit.
return;
} else {
// We went beyond the delay time, add it back to dt so we can continue
// with the animation.
dt = -this.delayFor;
this.delayFor = 0;
}
}
if (duration === 0) {
this.emit('finished');
return;
}
// Read progress once into a local -- avoids repeated this.progress reads
// which cause V8 to box the float value on each access to the object property.
let progress = this.progress;
if (progress === 0) {
this.emit('animating');
}
// Multiply by pre-computed reciprocal -- avoids per-frame float division
progress += dt * this.invDuration;
if (progress > 1) {
progress = loop === true ? 0 : 1;
this.delayFor = this.delay;
if (stopMethod !== false) {
this.progress = progress;
this.emit('finished');
return;
}
}
// Write back once
this.progress = progress;
// Extract to locals to avoid repeated property lookups in the hot path
const propsGroup = this.propValuesMap.props;
const shaderGroup = this.propValuesMap.shaderProps;
if (propsGroup !== null) {
this.updateValues(
this.node as unknown as Record<string, number>,
propsGroup,
progress,
);
}
if (shaderGroup !== null) {
this.updateValues(
this.node.shader!.props as Record<string, number>,
shaderGroup,
progress,
);
}
if (progress < 1) {
this.emit('tick');
}
if (progress === 1) {
this.emit('finished');
}
}
}