updated some performance stuff

This commit is contained in:
Warren H
2026-07-17 15:39:31 -04:00
parent 10ff124466
commit 638aef22b2
7 changed files with 660 additions and 130 deletions
+198 -126
View File
@@ -74,7 +74,7 @@ import {
import { resolveCharacterEquipment } from "../game/characterEquipment";
import { HEALER_CLASS_ORDER } from "../game/healers";
import { useGameStore } from "../game/store";
import type { BossId, HealerClassId, MemberId, PulseKind } from "../game/types";
import type { BossId, GamePhase, HealerClassId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { HealerClassAccessory } from "./HealerClassAccessory";
import { ModularCharacterBody } from "./ModularCharacterBody";
@@ -110,6 +110,20 @@ import {
aetherShipColorIndex,
} from "./aetherAssaultVisuals";
import { clampToBossArenaWithPortals } from "../game/rpgRoguelike/playSpace";
import {
activePlayfieldKind,
consumeSimulationSteps,
FRAME_INTERVAL_JITTER_MS,
GAMEPLAY_FRAME_INTERVAL_MS,
isOutcomePhase,
outcomeElapsedAfterPhaseChange,
SIMULATION_STEP_SECONDS,
selectSceneRenderMode,
startSceneFrameLoop,
summarizeFramePerformance,
type OutcomePhase,
type SceneRenderMode,
} from "./sceneFramePolicy";
const PARTY_MODEL_LEGACY_URLS: Record<MemberId, string> = {
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
@@ -947,87 +961,97 @@ function PartyCharacterModel({
);
}
const GAMEPLAY_FRAME_INTERVAL_MS = 1000 / 60;
const BACKGROUND_FRAME_INTERVAL_MS = 1000 / 30;
const FRAME_INTERVAL_JITTER_MS = 1.5;
const SIMULATION_STEP_SECONDS = 0.1;
const MAX_SIMULATION_STEPS_PER_FRAME = 3;
const MIN_RENDER_DPR = 1;
const MAX_RENDER_DPR = 1.25;
/**
* Owns the only gameplay RAF. R3F stays manually advanced, while simulation remains
* in the store and is advanced at its existing fixed 10 Hz cadence.
* Owns manual rendering only while gameplay or a finite outcome animation is active.
* Static scenes use R3F demand rendering; suspended scenes render nothing.
*/
function SceneFrameScheduler({ dpr, onDprChange }: { dpr: number; onDprChange: (next: number) => void }) {
const { advance } = useThree();
const gameplayActive = useGameStore((state) => state.phase === "combat" && !state.paused);
const frameId = useRef<number | null>(null);
const lastRenderedAt = useRef<number | null>(null);
function SceneFrameScheduler({
dpr,
mode,
outcomePhase,
onDprChange,
onOutcomeComplete,
}: {
dpr: number;
mode: SceneRenderMode;
outcomePhase: OutcomePhase | null;
onDprChange: (next: number) => void;
onOutcomeComplete: () => void;
}) {
const { advance, invalidate } = useThree();
const simulationAccumulator = useRef(0);
const manualTimeSeconds = useRef(0);
const outcomeElapsedSeconds = useRef(0);
const previousOutcomePhase = useRef<OutcomePhase | null>(null);
const slowFrameMs = useRef(0);
const stableFrameMs = useRef(0);
const dprRef = useRef(dpr);
dprRef.current = dpr;
useEffect(() => {
const schedule = (now: number) => {
const interval = gameplayActive ? GAMEPLAY_FRAME_INTERVAL_MS : BACKGROUND_FRAME_INTERVAL_MS;
const previous = lastRenderedAt.current;
if (previous === null) {
lastRenderedAt.current = now;
advance(now / 1000, true);
} else {
const elapsedMs = now - previous;
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= interval) {
lastRenderedAt.current = now;
const elapsedSeconds = Math.min(elapsedMs / 1000, 0.25);
if (gameplayActive) {
simulationAccumulator.current += elapsedSeconds;
let steps = 0;
while (simulationAccumulator.current >= SIMULATION_STEP_SECONDS && steps < MAX_SIMULATION_STEPS_PER_FRAME) {
const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
simulationAccumulator.current -= SIMULATION_STEP_SECONDS;
steps += 1;
}
if (elapsedMs > 20) {
slowFrameMs.current += elapsedMs;
stableFrameMs.current = 0;
if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
slowFrameMs.current = 0;
}
} else {
slowFrameMs.current = 0;
stableFrameMs.current += elapsedMs;
if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
stableFrameMs.current = 0;
}
}
} else {
simulationAccumulator.current = 0;
slowFrameMs.current = 0;
stableFrameMs.current = 0;
}
advance(now / 1000, true);
}
}
frameId.current = requestAnimationFrame(schedule);
};
frameId.current = requestAnimationFrame(schedule);
return () => {
if (frameId.current !== null) cancelAnimationFrame(frameId.current);
frameId.current = null;
lastRenderedAt.current = null;
outcomeElapsedSeconds.current = outcomeElapsedAfterPhaseChange(
previousOutcomePhase.current,
outcomePhase,
outcomeElapsedSeconds.current,
);
previousOutcomePhase.current = outcomePhase;
if (mode !== "active") {
simulationAccumulator.current = 0;
};
}, [advance, gameplayActive, onDprChange]);
slowFrameMs.current = 0;
stableFrameMs.current = 0;
}
if (mode === "static") {
manualTimeSeconds.current = 0;
invalidate();
return;
}
if (mode === "suspended") return;
return startSceneFrameLoop({
mode,
initialManualTimeSeconds: manualTimeSeconds.current,
initialOutcomeElapsedSeconds: outcomeElapsedSeconds.current,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
onFrame: (sample) => {
manualTimeSeconds.current = sample.manualTimeSeconds;
if (mode === "active") {
const stepResult = consumeSimulationSteps(simulationAccumulator.current, sample.elapsedSeconds);
simulationAccumulator.current = stepResult.remainderSeconds;
for (let step = 0; step < stepResult.steps; step += 1) {
const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
}
if (sample.elapsedMs > 20) {
slowFrameMs.current += sample.elapsedMs;
stableFrameMs.current = 0;
if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
slowFrameMs.current = 0;
}
} else if (sample.elapsedMs > 0) {
slowFrameMs.current = 0;
stableFrameMs.current += sample.elapsedMs;
if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
stableFrameMs.current = 0;
}
}
} else {
outcomeElapsedSeconds.current = sample.outcomeElapsedSeconds;
}
advance(sample.manualTimeSeconds, true);
},
onOutcomeComplete,
});
}, [advance, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]);
return null;
}
@@ -2543,17 +2567,46 @@ type PerformanceMemory = Performance & {
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: 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))];
}
function PerformanceProbe() {
function PerformanceProbe({ mode }: { mode: SceneRenderMode }) {
const { gl } = useThree();
const frameSamples = useRef<number[]>([]);
const observedFrames = useRef(0);
const longTaskCount = useRef(0);
const longTaskDuration = useRef(0);
const lastPublishAt = useRef(0);
const skipNextFrameSample = useRef(true);
const publishSnapshot = useCallback((currentMode: SceneRenderMode, samples: readonly number[]) => {
const memory = performance as PerformanceMemory;
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
let transferredBytes = 0;
let decodedBytes = 0;
for (const resource of resources) {
transferredBytes += resource.transferSize;
decodedBytes += resource.decodedBodySize;
}
document.documentElement.dataset.gamePerf = JSON.stringify({
frame: summarizeFramePerformance(currentMode, samples),
renderer: {
observedFrames: observedFrames.current,
calls: gl.info.render.calls,
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
pixelRatio: gl.getPixelRatio(),
renderWidth: gl.domElement.width,
renderHeight: gl.domElement.height,
},
simulation: simulationTickSnapshot(),
memory: memory.memory ? {
usedJSHeapSize: memory.memory.usedJSHeapSize,
totalJSHeapSize: memory.memory.totalJSHeapSize,
jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
} : null,
resources: { transferredBytes, decodedBytes, count: resources.length },
longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
});
}, [gl]);
useEffect(() => {
if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return;
@@ -2575,56 +2628,31 @@ function PerformanceProbe() {
};
}, []);
useEffect(() => {
frameSamples.current = [];
lastPublishAt.current = 0;
skipNextFrameSample.current = true;
publishSnapshot(mode, []);
}, [mode, publishSnapshot]);
useFrame(({ clock }, delta) => {
if (!PERFORMANCE_PROBE_ENABLED) return;
observedFrames.current += 1;
if (mode === "static" || mode === "suspended") {
publishSnapshot(mode, []);
return;
}
if (skipNextFrameSample.current) {
skipNextFrameSample.current = false;
lastPublishAt.current = clock.elapsedTime;
return;
}
const samples = frameSamples.current;
if (samples.length === 300) samples.shift();
samples.push(delta * 1000);
if (delta > 0) samples.push(delta * 1000);
if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
lastPublishAt.current = clock.elapsedTime;
const sorted = [...samples].sort((left, right) => left - right);
let total = 0;
let overBudget = 0;
for (const duration of samples) {
total += duration;
if (duration > 16.67) overBudget += 1;
}
const memory = performance as PerformanceMemory;
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
let transferredBytes = 0;
let decodedBytes = 0;
for (const resource of resources) {
transferredBytes += resource.transferSize;
decodedBytes += resource.decodedBodySize;
}
document.documentElement.dataset.gamePerf = JSON.stringify({
frame: {
targetFps: 60,
budgetMs: 16.67,
averageMs: total / samples.length,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
overBudget,
samples: samples.length,
},
renderer: {
calls: gl.info.render.calls,
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
pixelRatio: gl.getPixelRatio(),
renderWidth: gl.domElement.width,
renderHeight: gl.domElement.height,
},
simulation: simulationTickSnapshot(),
memory: memory.memory ? {
usedJSHeapSize: memory.memory.usedJSHeapSize,
totalJSHeapSize: memory.memory.totalJSHeapSize,
jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
} : null,
resources: { transferredBytes, decodedBytes, count: resources.length },
longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
});
publishSnapshot(mode, samples);
});
return null;
}
@@ -2705,29 +2733,73 @@ function CombatFx() {
return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />;
}
function ActiveModePlayfield() {
const activityMode = useGameStore((state) => state.activityMode);
const playfield = activePlayfieldKind(activityMode);
if (playfield === "hockey-healing") return <HockeyHealingPlayfield />;
if (playfield === "blockbreaker") return <BlockbreakerPlayfield />;
if (playfield === "aether-assault") return <AetherAssaultPlayfield />;
if (playfield === "hockey-healing-pvp") return <HockeyHealingPvpPlayfield />;
return null;
}
function useDocumentVisible() {
const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState !== "hidden");
useEffect(() => {
const updateVisibility = () => setVisible(document.visibilityState !== "hidden");
const suspend = () => setVisible(false);
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("pagehide", suspend);
window.addEventListener("pageshow", updateVisibility);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
window.removeEventListener("pagehide", suspend);
window.removeEventListener("pageshow", updateVisibility);
};
}, []);
return visible;
}
export function GameScene({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) {
const phase = useGameStore((state) => state.phase);
const paused = useGameStore((state) => state.paused);
const visible = useDocumentVisible();
const outcomePhaseActive = isOutcomePhase(phase);
const [completedOutcomePhase, setCompletedOutcomePhase] = useState<GamePhase | null>(null);
const outcomeTailComplete = completedOutcomePhase === phase;
const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1)));
const setRenderDpr = useCallback((next: number) => {
setDpr((current) => Math.abs(current - next) < 0.001 ? current : next);
}, []);
const completeOutcomeTail = useCallback(() => setCompletedOutcomePhase(phase), [phase]);
const renderMode = selectSceneRenderMode({ phase, paused, visible, outcomeTailComplete });
useEffect(() => {
if (!outcomePhaseActive && completedOutcomePhase !== null) setCompletedOutcomePhase(null);
}, [completedOutcomePhase, outcomePhaseActive]);
return (
<Canvas
frameloop="never"
frameloop={renderMode === "static" ? "demand" : "never"}
shadows="basic"
dpr={dpr}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
>
<GameAssetProvider>
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
<SceneFrameScheduler
dpr={dpr}
mode={renderMode}
outcomePhase={outcomePhaseActive ? phase : null}
onDprChange={setRenderDpr}
onOutcomeComplete={completeOutcomeTail}
/>
<BossRoom />
<HockeyHealingPlayfield />
<BlockbreakerPlayfield />
<AetherAssaultPlayfield />
<HockeyHealingPvpPlayfield />
<ActiveModePlayfield />
<EncounterActors playerAppearance={playerAppearance} />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe mode={renderMode} />}
</GameAssetProvider>
</Canvas>
);