updated some performance stuff
This commit is contained in:
+198
-126
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user