416 lines
13 KiB
JavaScript
416 lines
13 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 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,
|
|
// High quality is still intentionally generous, but no subsystem is
|
|
// allowed to explode to effectively-unbounded work in a single frame.
|
|
creatureBackgroundBudget: 180,
|
|
pressureCreatureFullBudget: 240,
|
|
pressureCreatureMotionBudget: 360,
|
|
pressureObstacleContactBudget: 96,
|
|
collisionSourceBudget: 220,
|
|
collisionImpactBudget: 160,
|
|
itemCadenceScale: 0,
|
|
scheduledItemBudget: 180,
|
|
aiBudget: 96,
|
|
envBudget: 32,
|
|
effectCap: 520,
|
|
effectUpdateBudget: 280,
|
|
effectDrawBudget: 160,
|
|
fireInteractionBudget: 96,
|
|
activeFireLimit: 260,
|
|
physicsBudget: Object.freeze({
|
|
maxItems: 560,
|
|
maxPairs: 960,
|
|
maxLinks: 520,
|
|
postMaxPairs: 300,
|
|
maxSubsteps: 6,
|
|
focusThreshold: 220,
|
|
maxSubstepPairs: 620,
|
|
maxSubstepFenceItems: 220,
|
|
}),
|
|
poisonContactBudget: Object.freeze({ maxPoison: 72, maxTargets: 320, 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";
|
|
const FPS_UPDATE_INTERVAL_SECONDS = 0.75;
|
|
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);
|
|
return current ? sanitizeVisualSettings(JSON.parse(current)) : { ...defaults };
|
|
} 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 < FPS_UPDATE_INTERVAL_SECONDS) return;
|
|
const sampleSeconds = fpsAccumSeconds;
|
|
const sampledFps = fpsAccumFrames / Math.max(0.001, sampleSeconds);
|
|
smoothedFps = 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);
|