tarinai/js/perf_profiler.js
2026-07-18 13:06:02 +09:00

421 lines
14 KiB
JavaScript

"use strict";
(function (global) {
const nowMs = () => (global.performance?.now ? global.performance.now() : Date.now());
const debugEnabled = (() => {
try {
const params = new URLSearchParams(global.location?.search || "");
return params.get("debug") === "1" || params.get("debug") === "true" || params.get("perf") === "1";
} catch (_) {
return false;
}
})();
const buckets = new Map();
const stack = [];
const metrics = new Map();
let frameOpen = null;
let frameCount = 0;
let lastSmoothAt = 0;
const storageKey = "tarinai_visual_settings_v2";
const legacyStorageKey = "tarinai_visual_settings_v1";
const allowed = {
effects: new Set(["off", "high"]),
shadows: new Set(["off", "high"]),
details: new Set(["auto", "low", "high"]),
};
const defaults = Object.freeze({ effects: "high", shadows: "high", details: "auto" });
const PROFILE_FULL = Object.freeze({
id: "full",
renderTier: "high",
dprScale: 1,
simulationThrottled: false,
creatureCadenceScale: 0,
creatureBackgroundBudget: 1000000000,
pressureCreatureFullBudget: 1000000000,
pressureCreatureMotionBudget: 1000000000,
pressureObstacleContactBudget: 1000000000,
collisionSourceBudget: 1000000000,
collisionImpactBudget: 1000000000,
itemCadenceScale: 0,
scheduledItemBudget: 1000000000,
aiBudget: 1000000000,
envBudget: 1000000000,
effectCap: 1000000000,
effectUpdateBudget: 1000000000,
effectDrawBudget: 160,
fireInteractionBudget: 1000000000,
activeFireLimit: 1000000000,
physicsBudget: Object.freeze({
maxItems: 1000000000,
maxPairs: 1000000000,
maxLinks: 1000000000,
postMaxPairs: 1000000000,
maxSubsteps: 8,
focusThreshold: 1000000000,
maxSubstepPairs: 1000000000,
maxSubstepFenceItems: 1000000000,
}),
poisonContactBudget: Object.freeze({ maxPoison: 1000000000, maxTargets: 1000000000, skip: false }),
});
const PROFILE_BALANCED = Object.freeze({
id: "balanced",
renderTier: "mid",
dprScale: 0.86,
simulationThrottled: true,
creatureCadenceScale: 1,
creatureBackgroundBudget: 36,
pressureCreatureFullBudget: 36,
pressureCreatureMotionBudget: 58,
pressureObstacleContactBudget: 14,
collisionSourceBudget: 36,
collisionImpactBudget: 20,
itemCadenceScale: 1,
scheduledItemBudget: 30,
aiBudget: 10,
envBudget: 5,
effectCap: 220,
effectUpdateBudget: 84,
effectDrawBudget: 96,
fireInteractionBudget: 14,
activeFireLimit: 60,
physicsBudget: Object.freeze({
maxItems: 180,
maxPairs: 170,
maxLinks: 120,
postMaxPairs: 48,
maxSubsteps: 3,
focusThreshold: 38,
maxSubstepPairs: 96,
maxSubstepFenceItems: 24,
}),
poisonContactBudget: Object.freeze({ maxPoison: 12, maxTargets: 42, skip: false }),
});
const PROFILE_AGGRESSIVE = Object.freeze({
id: "aggressive",
renderTier: "low",
dprScale: 0.72,
simulationThrottled: true,
creatureCadenceScale: 1.55,
creatureBackgroundBudget: 16,
pressureCreatureFullBudget: 18,
pressureCreatureMotionBudget: 30,
pressureObstacleContactBudget: 7,
collisionSourceBudget: 18,
collisionImpactBudget: 10,
itemCadenceScale: 1.55,
scheduledItemBudget: 14,
aiBudget: 6,
envBudget: 3,
effectCap: 140,
effectUpdateBudget: 48,
effectDrawBudget: 36,
fireInteractionBudget: 8,
activeFireLimit: 40,
physicsBudget: Object.freeze({
maxItems: 100,
maxPairs: 82,
maxLinks: 52,
postMaxPairs: 20,
maxSubsteps: 1,
focusThreshold: 28,
maxSubstepPairs: 54,
maxSubstepFenceItems: 14,
}),
poisonContactBudget: Object.freeze({ maxPoison: 8, maxTargets: 28, skip: false }),
});
const profiles = Object.freeze({ full: PROFILE_FULL, balanced: PROFILE_BALANCED, aggressive: PROFILE_AGGRESSIVE });
let visualSettings = loadVisualSettings();
let resizeRequested = false;
let autoProfileId = "full";
let fpsAccumSeconds = 0;
let fpsAccumFrames = 0;
let smoothedFps = 0;
let pendingProfileId = "";
let pendingProfileSeconds = 0;
function bucketFor(label) {
let b = buckets.get(label);
if (!b) {
b = { label, ms: 0, selfMs: 0, avg: 0, selfAvg: 0, max: 0, calls: 0, last: 0, lastSelf: 0 };
buckets.set(label, b);
}
return b;
}
function begin(label) {
if (!debugEnabled) return null;
const frame = { label: String(label || "unknown"), start: nowMs(), childMs: 0 };
stack.push(frame);
return () => {
const top = stack.pop();
const active = top === frame ? frame : top || frame;
if (top !== frame) {
const idx = stack.lastIndexOf(frame);
if (idx >= 0) stack.splice(idx, 1);
}
const elapsed = Math.max(0, nowMs() - active.start);
const selfElapsed = Math.max(0, elapsed - (active.childMs || 0));
if (stack.length) stack[stack.length - 1].childMs += elapsed;
const b = bucketFor(active.label);
b.ms += elapsed;
b.selfMs += selfElapsed;
b.calls += 1;
b.last = elapsed;
b.lastSelf = selfElapsed;
if (elapsed > b.max) b.max = elapsed;
return elapsed;
};
}
function beginFrame() {
frameOpen = nowMs();
if (debugEnabled) stack.length = 0;
return frameOpen;
}
function smoothBuckets() {
if (!debugEnabled) return;
for (const b of buckets.values()) {
b.avg = b.avg ? b.avg * 0.82 + b.ms * 0.18 : b.ms;
b.selfAvg = b.selfAvg ? b.selfAvg * 0.82 + b.selfMs * 0.18 : b.selfMs;
b.ms = 0;
b.selfMs = 0;
b.calls = 0;
}
}
function normalizeVisualSetting(key, value) {
const raw = String(value || "");
if (key === "effects" || key === "shadows") {
if (raw === "off") return "off";
if (raw === "on" || raw === "low" || raw === "mid" || raw === "high") return "high";
}
if (key === "details") {
if (raw === "auto" || raw === "low" || raw === "high") return raw;
if (raw === "mid") return "auto";
}
return defaults[key];
}
function sanitizeVisualSettings(value) {
const src = value && typeof value === "object" ? value : {};
const next = { ...defaults };
for (const key of Object.keys(defaults)) {
const normalized = normalizeVisualSetting(key, src[key]);
if (allowed[key]?.has(normalized)) next[key] = normalized;
}
return next;
}
function loadVisualSettings() {
try {
const current = global.localStorage?.getItem(storageKey);
if (current) return sanitizeVisualSettings(JSON.parse(current));
const legacy = global.localStorage?.getItem(legacyStorageKey);
if (!legacy) return { ...defaults };
const migrated = sanitizeVisualSettings(JSON.parse(legacy));
// Old builds had no automatic mode. Preserve explicit effect/shadow choices,
// but use the new automatic drawing mode as the migration default.
migrated.details = "auto";
return migrated;
} catch (_) {
return { ...defaults };
}
}
function saveVisualSettings() {
try {
global.localStorage?.setItem(storageKey, JSON.stringify(visualSettings));
} catch (_) {}
}
function activeProfile() {
if (visualSettings.details === "high") return PROFILE_FULL;
if (visualSettings.details === "low") return PROFILE_AGGRESSIVE;
return profiles[autoProfileId] || PROFILE_BALANCED;
}
function chooseAutoProfile(fps) {
if (!Number.isFinite(fps) || fps <= 0) return autoProfileId;
// Wide enter/exit bands prevent profile flapping around a single FPS line.
if (autoProfileId === "full") return fps < 48 ? "balanced" : "full";
if (autoProfileId === "aggressive") return fps > 45 ? "balanced" : "aggressive";
if (fps < 30) return "aggressive";
if (fps > 56) return "full";
return "balanced";
}
function transitionHoldSeconds(fromId, toId) {
const rank = { full: 2, balanced: 1, aggressive: 0 };
return (rank[toId] ?? 1) < (rank[fromId] ?? 1) ? 5 : 15;
}
function applyAutoProfile(nextId) {
if (!profiles[nextId] || nextId === autoProfileId) return false;
const before = activeProfile();
autoProfileId = nextId;
const after = activeProfile();
if (before.dprScale !== after.dprScale || before.renderTier !== after.renderTier) resizeRequested = true;
if (after.id === "aggressive") global.TarinaiAssetsMemory?.trim?.("aggressive");
try {
global.dispatchEvent?.(new CustomEvent("tarinai-performance-profile", { detail: { profile: after.id, fps: smoothedFps } }));
} catch (_) {}
return true;
}
function observeFrameDelta(rawDt) {
const dt = Number(rawDt);
if (!Number.isFinite(dt) || dt <= 0 || dt > 0.25 || global.document?.hidden) return;
fpsAccumSeconds += dt;
fpsAccumFrames += 1;
if (fpsAccumSeconds < 0.75) return;
const sampleSeconds = fpsAccumSeconds;
const sampledFps = fpsAccumFrames / Math.max(0.001, sampleSeconds);
smoothedFps = smoothedFps > 0 ? smoothedFps * 0.65 + sampledFps * 0.35 : sampledFps;
global.__tarinaiFps = Math.round(smoothedFps);
fpsAccumSeconds = 0;
fpsAccumFrames = 0;
if (visualSettings.details !== "auto") return;
const desired = chooseAutoProfile(smoothedFps);
if (desired === autoProfileId) {
pendingProfileId = "";
pendingProfileSeconds = 0;
return;
}
if (pendingProfileId !== desired) {
pendingProfileId = desired;
pendingProfileSeconds = sampleSeconds;
return;
}
pendingProfileSeconds += sampleSeconds;
if (pendingProfileSeconds >= transitionHoldSeconds(autoProfileId, desired)) {
applyAutoProfile(desired);
pendingProfileId = "";
pendingProfileSeconds = 0;
}
}
function endFrame() {
frameCount += 1;
if (frameOpen != null && debugEnabled) {
const elapsed = Math.max(0, nowMs() - frameOpen);
const b = bucketFor("frame.total");
b.ms += elapsed;
b.selfMs += Math.max(0, elapsed - stack.reduce((sum, f) => sum + (f.childMs || 0), 0));
b.calls += 1;
b.last = elapsed;
b.lastSelf = b.last;
if (elapsed > b.max) b.max = elapsed;
}
frameOpen = null;
if (debugEnabled) stack.length = 0;
const t = nowMs();
if (t - lastSmoothAt >= 750) {
lastSmoothAt = t;
smoothBuckets();
}
}
function visualLevel(key, fallback = "high") {
if (key === "details") return activeProfile().renderTier;
if (key === "effects") {
if (visualSettings.effects === "off") return "off";
return activeProfile().renderTier;
}
if (key === "shadows") return shadowQualityTier();
return visualSettings[key] || fallback;
}
function visualSettingsValue() {
return { ...visualSettings };
}
function setVisualSetting(key, value) {
if (!Object.prototype.hasOwnProperty.call(defaults, key)) return false;
const nextValue = normalizeVisualSetting(key, value);
if (!allowed[key]?.has(nextValue)) return false;
if (visualSettings[key] === nextValue) return true;
const before = activeProfile();
visualSettings = { ...visualSettings, [key]: nextValue };
saveVisualSettings();
const after = activeProfile();
if (key === "details" || before.dprScale !== after.dprScale || before.renderTier !== after.renderTier) resizeRequested = true;
if (after.id === "aggressive") global.TarinaiAssetsMemory?.trim?.("aggressive");
return true;
}
function renderQualityTier() { return activeProfile().renderTier; }
function shadowQualityTier() {
if (visualSettings.shadows === "off") return "off";
return activeProfile().renderTier;
}
function dprScaleValue() { return activeProfile().dprScale; }
function performanceProfile() { return activeProfile(); }
function diagnosticsEnabled() { return debugEnabled; }
function simulationOptimizationTier() { return activeProfile().id; }
function consumeResizeRequest() {
const v = resizeRequested;
resizeRequested = false;
return v;
}
function setMetric(name, value) {
if (!debugEnabled) return;
metrics.set(String(name || "metric"), value);
}
function snapshot() {
const frameAvg = buckets.get("frame.total")?.avg || 0;
const entries = [...buckets.values()].map(b => ({
label: b.label,
avg: Number((b.avg || 0).toFixed(2)),
selfAvg: Number((b.selfAvg || 0).toFixed(2)),
max: Number((b.max || 0).toFixed(2)),
last: Number((b.last || 0).toFixed(2)),
lastSelf: Number((b.lastSelf || 0).toFixed(2)),
calls: b.calls || 0,
pct: frameAvg > 0 ? Number((((b.avg || 0) / frameAvg) * 100).toFixed(0)) : 0,
selfPct: frameAvg > 0 ? Number((((b.selfAvg || 0) / frameAvg) * 100).toFixed(0)) : 0,
}));
const topInclusive = entries.slice().sort((a, b) => b.avg - a.avg).slice(0, 12);
const topSelf = entries.slice().sort((a, b) => b.selfAvg - a.selfAvg).slice(0, 12);
const profile = activeProfile();
return {
enabled: debugEnabled,
tier: profile.renderTier,
profile: profile.id,
autoProfile: autoProfileId,
fps: Number(smoothedFps.toFixed(1)),
visual: visualSettingsValue(),
dprScale: dprScaleValue(),
frameCount,
frameAvg: Number(frameAvg.toFixed(2)),
entries: topInclusive,
topInclusive,
topSelf,
metrics: Object.fromEntries(metrics.entries()),
};
}
global.TarinaiPerf = Object.freeze({
begin,
beginFrame,
endFrame,
observeFrameDelta,
renderQualityTier,
shadowQualityTier,
visualLevel,
visualSettings: visualSettingsValue,
setVisualSetting,
dprScale: dprScaleValue,
performanceProfile,
diagnosticsEnabled,
simulationOptimizationTier,
consumeResizeRequest,
setMetric,
snapshot,
});
})(typeof window !== "undefined" ? window : globalThis);