const lessPrecise = (num: number, precision = 5) => parseFloat(num.toPrecision(precision)); const getAvgFrameTime = (times: number[]) => lessPrecise(times.reduce((a, b) => a + b) / times.length); export class Debug { public static DEBUG_LOG_TIMES = true; private static TIMES_AGGR: Record = {}; private static TIMES_AVG: Record< string, { t: number; times: number[]; avg: number | null } > = {}; private static LAST_DEBUG_LOG_CALL = 0; private static DEBUG_LOG_INTERVAL_ID: null | number = null; private static LAST_FRAME_TIMESTAMP = 0; private static FRAME_COUNT = 0; private static ANIMATION_FRAME_ID: null | number = null; private static scheduleAnimationFrame = () => { if (Debug.DEBUG_LOG_INTERVAL_ID !== null) { Debug.ANIMATION_FRAME_ID = requestAnimationFrame((timestamp) => { if (Debug.LAST_FRAME_TIMESTAMP !== timestamp) { Debug.LAST_FRAME_TIMESTAMP = timestamp; Debug.FRAME_COUNT++; } if (Debug.DEBUG_LOG_INTERVAL_ID !== null) { Debug.scheduleAnimationFrame(); } }); } }; private static setupInterval = () => { if (Debug.DEBUG_LOG_INTERVAL_ID === null) { console.info("%c(starting perf recording)", "color: lime"); Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000); Debug.scheduleAnimationFrame(); } Debug.LAST_DEBUG_LOG_CALL = Date.now(); }; private static debugLogger = () => { if (Debug.DEBUG_LOG_TIMES) { for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) { if (times.length) { console.info( name, lessPrecise(times.reduce((a, b) => a + b)), times.sort((a, b) => a - b).map((x) => lessPrecise(x)), ); Debug.TIMES_AGGR[name] = { t, times: [] }; } } for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) { if (times.length) { // const avgFrameTime = getAvgFrameTime(times); const totalTime = times.reduce((a, b) => a + b); const avgFrameTime = lessPrecise(totalTime / Debug.FRAME_COUNT); console.info( name, `- ${times.length} calls - ${avgFrameTime}ms/frame across ${ Debug.FRAME_COUNT } frames (${lessPrecise( (avgFrameTime / 16.67) * 100, 1, )}% of frame budget)`, ); Debug.TIMES_AVG[name] = { t, times: [], avg: avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime, }; } } } Debug.FRAME_COUNT = 0; // Check for stop condition after logging if ( Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 && Debug.DEBUG_LOG_INTERVAL_ID !== null ) { console.info("%c(stopping perf recording)", "color: red"); window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID); window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!); Debug.ANIMATION_FRAME_ID = null; Debug.FRAME_COUNT = 0; Debug.LAST_FRAME_TIMESTAMP = 0; Debug.DEBUG_LOG_INTERVAL_ID = null; Debug.TIMES_AGGR = {}; Debug.TIMES_AVG = {}; } }; public static logTime = (time?: number, name = "default") => { Debug.setupInterval(); const now = performance.now(); const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || { t: 0, times: [], }); if (t) { times.push(time != null ? time : now - t); } Debug.TIMES_AGGR[name].t = now; }; public static logTimeAverage = (time?: number, name = "default") => { Debug.setupInterval(); const now = performance.now(); const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || { t: 0, times: [], }); if (t) { times.push(time != null ? time : now - t); } Debug.TIMES_AVG[name].t = now; }; private static logWrapper = (type: "logTime" | "logTimeAverage") => (fn: (...args: T) => R, name = "default") => { return (...args: T) => { const t0 = performance.now(); const ret = fn(...args); Debug[type](performance.now() - t0, name); return ret; }; }; public static logTimeWrap = Debug.logWrapper("logTime"); public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage"); public static perfWrap = ( fn: (...args: T) => R, name = "default", ) => { return (...args: T) => { // eslint-disable-next-line no-console console.time(name); const ret = fn(...args); // eslint-disable-next-line no-console console.timeEnd(name); return ret; }; }; private static CHANGED_CACHE: Record> = {}; public static logChanged(name: string, obj: Record) { const prev = Debug.CHANGED_CACHE[name]; Debug.CHANGED_CACHE[name] = obj; if (!prev) { return; } const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]); const changed: Record = {}; for (const key of allKeys) { const prevVal = prev[key]; const nextVal = obj[key]; if (!deepEqual(prevVal, nextVal)) { changed[key] = { prev: prevVal, next: nextVal }; } } if (Object.keys(changed).length > 0) { console.info(`[${name}] changed:`, changed); } } } function deepEqual(a: unknown, b: unknown): boolean { if (Object.is(a, b)) { return true; } if ( a === null || b === null || typeof a !== "object" || typeof b !== "object" ) { return false; } if (Array.isArray(a) !== Array.isArray(b)) { return false; } const keysA = Object.keys(a as Record); const keysB = Object.keys(b as Record); if (keysA.length !== keysB.length) { return false; } for (const key of keysA) { if ( !deepEqual( (a as Record)[key], (b as Record)[key], ) ) { return false; } } return true; }