tempus
Version:
one rAF to rule them all
368 lines (365 loc) • 10.6 kB
JavaScript
// packages/core/src/clock.ts
var Clock = class {
_elapsed = 0;
_currentTime = 0;
_startTime = void 0;
_lastTime = void 0;
_isPlaying = false;
_deltaTime = 0;
play() {
if (this._isPlaying) return;
this._currentTime = 0;
this._startTime = void 0;
this._isPlaying = true;
}
pause() {
if (!this._isPlaying) return;
this._deltaTime = 0;
this._isPlaying = false;
}
reset() {
this._elapsed = 0;
this._deltaTime = 0;
this._currentTime = 0;
this._lastTime = void 0;
this._isPlaying = false;
}
update(browserTime) {
if (!this._isPlaying) return;
if (!this._startTime) {
this._startTime = browserTime;
}
if (this._lastTime === void 0) {
this._lastTime = this._startTime;
this._currentTime = 0;
this._deltaTime = 0;
} else {
this._lastTime = this._currentTime;
this._currentTime = browserTime - this._startTime;
this._deltaTime = this._currentTime - this._lastTime;
this._elapsed += this._deltaTime;
}
}
get time() {
return this._elapsed;
}
get isPlaying() {
return this._isPlaying;
}
get deltaTime() {
return this._deltaTime;
}
};
// packages/core/src/uid.ts
var index = 0;
function getUID() {
return index++;
}
// package.json
var version = "1.0.0-dev.18";
// packages/core/src/tempus.ts
var isClient = typeof window !== "undefined";
var originalRAF = isClient && window.requestAnimationFrame.bind(
window
);
var originalCancelRAF = isClient && window.cancelAnimationFrame.bind(
window
);
if (isClient) {
;
window.tempusVersion = version;
}
function stopwatch(callback) {
const now = performance.now();
callback();
return performance.now() - now;
}
var SAMPLE_WINDOW = 60;
function pushSample(samples, duration) {
samples.push(duration);
if (samples.length > SAMPLE_WINDOW) samples.shift();
}
var PATCH_STALE_FRAMES = 120;
var Framerate = class {
callbacks = [];
fps;
time = 0;
lastTickDate = 0;
framesCount = 0;
constructor(fps = Number.POSITIVE_INFINITY) {
this.fps = fps;
}
get isRelativeFps() {
return typeof this.fps === "string" && this.fps.endsWith("%");
}
get maxFramesCount() {
if (!this.isRelativeFps) return 1;
return Math.max(1, Math.round(100 / Number(this.fps.replace("%", ""))));
}
get executionTime() {
if (this.isRelativeFps) return 0;
return 1e3 / this.fps;
}
dispatch(state) {
for (let i = 0; i < this.callbacks.length; i++) {
const duration = stopwatch(() => {
this.callbacks[i]?.callback(state);
});
pushSample(this.callbacks[i].samples, duration);
}
}
raf(state) {
this.time += state.deltaTime;
if (this.isRelativeFps) {
if (this.framesCount === 0) {
const frameDelta = state.deltaTime;
state.deltaTime = state.time - this.lastTickDate;
this.lastTickDate = state.time;
this.dispatch(state);
state.deltaTime = frameDelta;
}
this.framesCount++;
this.framesCount %= this.maxFramesCount;
} else {
if (this.fps === Number.POSITIVE_INFINITY) {
this.dispatch(state);
} else if (this.time >= this.executionTime) {
this.time = this.time % this.executionTime;
const frameDelta = state.deltaTime;
state.deltaTime = state.time - this.lastTickDate;
this.lastTickDate = state.time;
this.dispatch(state);
state.deltaTime = frameDelta;
}
}
}
add({
callback,
order,
label
}) {
if (typeof callback !== "function") {
console.warn("Tempus.add: callback is not a function");
return;
}
const uid = getUID();
this.callbacks.push({ callback, order, uid, label, samples: [] });
this.callbacks.sort((a, b) => a.order - b.order);
return () => this.remove(uid);
}
remove(uid) {
this.callbacks = this.callbacks.filter(({ uid: u }) => uid !== u);
}
};
var TempusImpl = class {
framerates = {};
clock = new Clock();
fps;
usage = 0;
rafId;
frameCount = 0;
// Frame rate the per-callback `budget` is measured against. At 60fps the
// budget is ~16.67ms; callbacks call `state.budget()` to see how much of it
// is left and decide whether to do expensive work this frame.
targetFps = 60;
frameStartTime = performance.now();
get frameBudget() {
return 1e3 / this.targetFps;
}
// The single object handed to every callback each tick. Reused (not
// reallocated) to avoid per-frame GC; `budget()` is a live method (built in
// the constructor) so it always reflects the time left at the moment called.
state;
// patch() state
patched = false;
rafQueue = /* @__PURE__ */ new Map();
rafHandleId = 0;
patchedRAF;
patchedCancelRAF;
flushUnsubscribe;
// Per-absorbed-callback timing, keyed by the callback identity so a
// self-rescheduling loop keeps one stable row across frames. The map and
// array hold the SAME entry objects — the array stays enumerable for the
// profiler view, the map gives O(1) identity lookup.
patchMeta = /* @__PURE__ */ new WeakMap();
patchEntries = [];
patchAnonCount = 0;
constructor() {
const impl = this;
this.state = {
time: 0,
deltaTime: 0,
frame: 0,
budget() {
return impl.frameBudget - (performance.now() - impl.frameStartTime);
}
};
if (!isClient) return;
this.play();
}
restart() {
if (this.rafId) {
originalCancelRAF(this.rafId);
}
this.frameCount = 0;
for (const framerate of Object.values(this.framerates)) {
framerate.framesCount = 0;
framerate.time = 0;
framerate.lastTickDate = performance.now();
}
this.clock.reset();
this.play();
}
play() {
if (!isClient || this.clock.isPlaying) return;
this.clock.play();
this.rafId = originalRAF(this.raf);
}
pause() {
if (!isClient || !this.rafId || !this.clock.isPlaying) return;
originalCancelRAF(this.rafId);
this.rafId = void 0;
this.clock.pause();
}
get isPlaying() {
return this.clock.isPlaying;
}
add(callback, {
order,
priority,
fps = Number.POSITIVE_INFINITY,
label = ""
} = {}) {
if (!isClient) return;
const resolvedOrder = order ?? priority ?? 0;
if (typeof fps === "number" || typeof fps === "string" && fps.endsWith("%")) {
if (!this.framerates[fps]) this.framerates[fps] = new Framerate(fps);
return this.framerates[fps].add({ callback, order: resolvedOrder, label });
}
console.warn('Tempus.add: fps is not a number or a string ending with "%"');
}
raf = (browserElapsed) => {
if (!isClient) return;
this.clock.update(browserElapsed);
const elapsed = this.clock.time;
const deltaTime = this.clock.deltaTime;
this.fps = 1e3 / deltaTime;
this.frameStartTime = performance.now();
this.state.time = elapsed;
this.state.deltaTime = deltaTime;
this.state.frame = this.frameCount;
const duration = stopwatch(() => {
for (const framerate of Object.values(this.framerates)) {
framerate.raf(this.state);
}
});
if (deltaTime) {
this.usage = duration / deltaTime;
}
this.frameCount++;
this.rafId = originalRAF(this.raf);
};
patch() {
if (!isClient || this.patched) return;
this.patched = true;
this.flushUnsubscribe = this.add(
() => {
if (this.rafQueue.size === 0) return;
const batch = Array.from(this.rafQueue.values());
this.rafQueue.clear();
const now = performance.now();
const frame = this.frameCount;
for (const callback of batch) {
let meta = this.patchMeta.get(callback);
if (!meta) {
meta = {
callback,
label: callback.name || `anonymous#${++this.patchAnonCount}`,
samples: [],
lastFrame: frame
};
this.patchMeta.set(callback, meta);
this.patchEntries.push(meta);
}
const duration = stopwatch(() => {
try {
callback(now);
} catch (error) {
console.error("Tempus.patch: rAF callback threw", error);
}
});
pushSample(meta.samples, duration);
meta.lastFrame = frame;
}
this.prunePatchEntries(frame);
},
{ label: "tempus" }
);
this.patchedRAF = (callback) => {
const id = ++this.rafHandleId;
this.rafQueue.set(id, callback);
return id;
};
window.requestAnimationFrame = this.patchedRAF;
this.patchedCancelRAF = (handle) => {
this.rafQueue.delete(handle);
};
window.cancelAnimationFrame = this.patchedCancelRAF;
}
unpatch() {
if (!isClient || !this.patched) return;
if (window.requestAnimationFrame === this.patchedRAF) {
window.requestAnimationFrame = originalRAF;
}
if (window.cancelAnimationFrame === this.patchedCancelRAF) {
window.cancelAnimationFrame = originalCancelRAF;
}
this.flushUnsubscribe?.();
this.flushUnsubscribe = void 0;
this.rafQueue.clear();
this.patchedRAF = void 0;
this.patchedCancelRAF = void 0;
this.patchMeta = /* @__PURE__ */ new WeakMap();
this.patchEntries = [];
this.patchAnonCount = 0;
this.patched = false;
}
// Drop rows for loops that stopped rescheduling, so they fall off the
// profiler view instead of lingering as stale 0ms entries.
prunePatchEntries(frame) {
for (let i = this.patchEntries.length - 1; i >= 0; i--) {
const entry = this.patchEntries[i];
if (frame - entry.lastFrame > PATCH_STALE_FRAMES) {
this.patchMeta.delete(entry.callback);
this.patchEntries.splice(i, 1);
}
}
}
// Unified timing snapshot for tempus/profiler: every Tempus.add() callback and
// every loop absorbed by Tempus.patch(), normalized to one shape. Samples
// are copied so consumers can't mutate live state.
inspect() {
const added = Object.values(this.framerates).flatMap(
(framerate) => framerate.callbacks.map((callback) => ({
label: callback.label,
samples: callback.samples.slice(),
order: callback.order,
fps: framerate.fps,
source: "add"
}))
);
const patched = this.patchEntries.map((entry) => ({
label: entry.label,
samples: entry.samples.slice(),
order: 0,
fps: Number.POSITIVE_INFINITY,
source: "patch"
}));
return [...added, ...patched];
}
};
var Tempus = new TempusImpl();
export {
Tempus as default
};
//# sourceMappingURL=tempus.mjs.map