updated some performance stuff
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import type { GamePhase, GameplayActivity } from "../game/types";
|
||||
|
||||
export type SceneRenderMode = "active" | "outcome" | "static" | "suspended";
|
||||
export type OutcomePhase = Extract<GamePhase, "victory" | "defeat" | "intermission">;
|
||||
|
||||
export const GAMEPLAY_FRAME_INTERVAL_MS = 1_000 / 60;
|
||||
export const OUTCOME_FRAME_INTERVAL_MS = 1_000 / 30;
|
||||
export const FRAME_INTERVAL_JITTER_MS = 1.5;
|
||||
export const SIMULATION_STEP_SECONDS = 0.1;
|
||||
export const MAX_SIMULATION_STEPS_PER_FRAME = 3;
|
||||
export const MAX_FRAME_DELTA_SECONDS = 0.25;
|
||||
export const OUTCOME_RENDER_TAIL_SECONDS = 7;
|
||||
|
||||
export interface SceneRenderState {
|
||||
phase: GamePhase;
|
||||
paused: boolean;
|
||||
visible: boolean;
|
||||
outcomeTailComplete: boolean;
|
||||
}
|
||||
|
||||
export function isOutcomePhase(phase: GamePhase): phase is OutcomePhase {
|
||||
return phase === "victory" || phase === "defeat" || phase === "intermission";
|
||||
}
|
||||
|
||||
export function outcomeElapsedAfterPhaseChange(
|
||||
previousPhase: OutcomePhase | null,
|
||||
nextPhase: OutcomePhase | null,
|
||||
elapsedSeconds: number,
|
||||
) {
|
||||
return previousPhase === nextPhase ? Math.max(0, elapsedSeconds) : 0;
|
||||
}
|
||||
|
||||
export function selectSceneRenderMode({
|
||||
phase,
|
||||
paused,
|
||||
visible,
|
||||
outcomeTailComplete,
|
||||
}: SceneRenderState): SceneRenderMode {
|
||||
if (!visible) return "suspended";
|
||||
if (phase === "combat" && !paused) return "active";
|
||||
if (isOutcomePhase(phase) && !outcomeTailComplete) return "outcome";
|
||||
return "static";
|
||||
}
|
||||
|
||||
export function sceneFrameIntervalMs(mode: SceneRenderMode) {
|
||||
if (mode === "active") return GAMEPLAY_FRAME_INTERVAL_MS;
|
||||
if (mode === "outcome") return OUTCOME_FRAME_INTERVAL_MS;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sceneTargetFps(mode: SceneRenderMode) {
|
||||
if (mode === "active") return 60;
|
||||
if (mode === "outcome") return 30;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface SimulationStepResult {
|
||||
steps: number;
|
||||
remainderSeconds: number;
|
||||
}
|
||||
|
||||
export function consumeSimulationSteps(
|
||||
accumulatorSeconds: number,
|
||||
elapsedSeconds: number,
|
||||
): SimulationStepResult {
|
||||
const boundedElapsed = Math.min(MAX_FRAME_DELTA_SECONDS, Math.max(0, elapsedSeconds));
|
||||
let remainderSeconds = Math.max(0, accumulatorSeconds) + boundedElapsed;
|
||||
const steps = Math.min(
|
||||
MAX_SIMULATION_STEPS_PER_FRAME,
|
||||
Math.floor((remainderSeconds + Number.EPSILON) / SIMULATION_STEP_SECONDS),
|
||||
);
|
||||
remainderSeconds -= steps * SIMULATION_STEP_SECONDS;
|
||||
return { steps, remainderSeconds };
|
||||
}
|
||||
|
||||
export interface SceneFrameSample {
|
||||
elapsedMs: number;
|
||||
elapsedSeconds: number;
|
||||
manualTimeSeconds: number;
|
||||
outcomeElapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface SceneFrameLoopOptions {
|
||||
mode: "active" | "outcome";
|
||||
initialManualTimeSeconds?: number;
|
||||
initialOutcomeElapsedSeconds?: number;
|
||||
requestFrame: (callback: FrameRequestCallback) => number;
|
||||
cancelFrame: (handle: number) => void;
|
||||
onFrame: (sample: SceneFrameSample) => void;
|
||||
onOutcomeComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the bounded manual R3F loop used by active gameplay and finite outcome
|
||||
* animation tails. Static and suspended modes intentionally have no manual loop.
|
||||
*/
|
||||
export function startSceneFrameLoop({
|
||||
mode,
|
||||
initialManualTimeSeconds = 0,
|
||||
initialOutcomeElapsedSeconds = 0,
|
||||
requestFrame,
|
||||
cancelFrame,
|
||||
onFrame,
|
||||
onOutcomeComplete,
|
||||
}: SceneFrameLoopOptions) {
|
||||
const intervalMs = sceneFrameIntervalMs(mode)!;
|
||||
let frameHandle: number | null = null;
|
||||
let stopped = false;
|
||||
let lastRenderedAt: number | null = null;
|
||||
let manualTimeSeconds = Math.max(0, initialManualTimeSeconds);
|
||||
let outcomeElapsedSeconds = mode === "outcome" ? Math.max(0, initialOutcomeElapsedSeconds) : 0;
|
||||
|
||||
const scheduleNext = () => {
|
||||
frameHandle = requestFrame(runFrame);
|
||||
};
|
||||
|
||||
const runFrame: FrameRequestCallback = (now) => {
|
||||
if (stopped) return;
|
||||
|
||||
const previous = lastRenderedAt;
|
||||
if (previous === null) {
|
||||
lastRenderedAt = now;
|
||||
onFrame({ elapsedMs: 0, elapsedSeconds: 0, manualTimeSeconds, outcomeElapsedSeconds });
|
||||
} else {
|
||||
const elapsedMs = now - previous;
|
||||
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= intervalMs) {
|
||||
lastRenderedAt = now;
|
||||
const wallElapsedSeconds = Math.max(0, elapsedMs / 1_000);
|
||||
const elapsedSeconds = mode === "active"
|
||||
? Math.min(MAX_FRAME_DELTA_SECONDS, wallElapsedSeconds)
|
||||
: wallElapsedSeconds;
|
||||
manualTimeSeconds += elapsedSeconds;
|
||||
if (mode === "outcome") outcomeElapsedSeconds += wallElapsedSeconds;
|
||||
onFrame({ elapsedMs, elapsedSeconds, manualTimeSeconds, outcomeElapsedSeconds });
|
||||
|
||||
if (mode === "outcome" && outcomeElapsedSeconds >= OUTCOME_RENDER_TAIL_SECONDS) {
|
||||
stopped = true;
|
||||
frameHandle = null;
|
||||
onOutcomeComplete?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
scheduleNext();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (frameHandle !== null) cancelFrame(frameHandle);
|
||||
frameHandle = null;
|
||||
};
|
||||
}
|
||||
|
||||
export type ActivePlayfieldKind = Exclude<GameplayActivity, "boss">;
|
||||
|
||||
export function activePlayfieldKind(activity: GameplayActivity): ActivePlayfieldKind | null {
|
||||
return activity === "boss" ? null : activity;
|
||||
}
|
||||
|
||||
export interface FramePerformanceSummary {
|
||||
mode: SceneRenderMode;
|
||||
targetFps: number;
|
||||
budgetMs: number | null;
|
||||
averageMs: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
overBudget: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
function percentile(sorted: readonly number[], ratio: number) {
|
||||
if (!sorted.length) return 0;
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
|
||||
}
|
||||
|
||||
export function summarizeFramePerformance(
|
||||
mode: SceneRenderMode,
|
||||
samples: readonly number[],
|
||||
): FramePerformanceSummary {
|
||||
const targetFps = sceneTargetFps(mode);
|
||||
const budgetMs = targetFps > 0 ? 1_000 / targetFps : null;
|
||||
const sorted = [...samples].sort((left, right) => left - right);
|
||||
let total = 0;
|
||||
let overBudget = 0;
|
||||
for (const duration of samples) {
|
||||
total += duration;
|
||||
if (mode === "active" && budgetMs !== null && duration > budgetMs + FRAME_INTERVAL_JITTER_MS) overBudget += 1;
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
targetFps,
|
||||
budgetMs,
|
||||
averageMs: samples.length ? total / samples.length : 0,
|
||||
p95Ms: percentile(sorted, 0.95),
|
||||
p99Ms: percentile(sorted, 0.99),
|
||||
overBudget,
|
||||
samples: samples.length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user