diff --git a/src/App.ts b/src/App.ts index 10214e4..cfaaee8 100644 --- a/src/App.ts +++ b/src/App.ts @@ -66,6 +66,7 @@ import { createQuakeCameraFeedbackFlow, quakeCameraForwardDirection as forwardDirection, type QuakeCameraFeedbackFlow, + type QuakeRenderCameraOriginPolicy, } from "./runtime/app/cameraFeedbackFlow"; import { createQuakeCameraViewFlow, @@ -171,6 +172,7 @@ import { import { createQuakeShootablesController, type QuakeShootablesDebugStats, + type QuakeShootablesPlayerClearanceOptions, } from "./runtime/shootables"; import type { QuakeRenderBundleFrameSetMotionMaterialOptions } from "./runtime/renderBundleMesh"; import { createCssQuakeSaveSession } from "./runtime/app/saveSession"; @@ -371,6 +373,234 @@ function quakeDebugMonsterMotionMaterialPolicy( return policy; } +function quakeDebugMonsterPlayerClearancePolicy( + params: URLSearchParams, +): QuakeShootablesPlayerClearanceOptions | null { + if (!import.meta.env.DEV) return null; + const mode = params.get("debugMonsterClearance")?.trim().toLowerCase(); + if (!mode || mode === "0" || mode === "false" || mode === "off") return null; + const classnames = quakeDebugMonsterPlayerClearanceClassnames(params, mode); + if (!classnames.length) return null; + const extraUnits = quakeUrlNumberParam(params, "debugMonsterClearanceUnits", 0, 256) ?? 64; + return { + enemyClassnames: classnames, + extraRadius: extraUnits * QUAKE_COLLISION_UNIT_SCALE, + useBossAwakeBounds: mode !== "model" && mode !== "boss-model", + }; +} + +function quakeDebugMonsterPlayerClearanceClassnames(params: URLSearchParams, mode: string): string[] { + const explicit = params.get("debugMonsterClearanceClassnames") ?? + params.get("debugMonsterClearanceClasses") ?? + ""; + const explicitClassnames = explicit + .split(",") + .map((value) => value.trim()) + .filter((value) => /^monster_[a-z0-9_]+$/i.test(value)); + if (explicitClassnames.length) return [...new Set(explicitClassnames)]; + if (mode === "boss" || mode.startsWith("boss-")) return ["monster_boss"]; + if (mode === "large" || mode === "hull2") return ["monster_ogre", "monster_demon1", "monster_shambler"]; + const aliases: Record = { + demon: "monster_demon1", + dog: "monster_dog", + knight: "monster_knight", + ogre: "monster_ogre", + shambler: "monster_shambler", + soldier: "monster_army", + wizard: "monster_wizard", + zombie: "monster_zombie", + }; + if (aliases[mode]) return [aliases[mode]]; + return /^monster_[a-z0-9_]+$/i.test(mode) ? [mode] : []; +} + +function quakeDebugMonsterCameraStandoffPolicy( + params: URLSearchParams, +): QuakeRenderCameraOriginPolicy | null { + if (!import.meta.env.DEV) return null; + const mode = params.get("debugMonsterCameraStandoff")?.trim().toLowerCase(); + if (!mode || mode === "0" || mode === "false" || mode === "off") return null; + const classnames = quakeDebugMonsterClassnamesForMode( + params, + mode, + "debugMonsterCameraStandoffClassnames", + "debugMonsterCameraStandoffClasses", + ); + if (!classnames.length) return null; + const extraUnits = quakeUrlNumberParam(params, "debugMonsterCameraStandoffUnits", 0, 256) ?? 64; + window.__cssQuakeDebugDomMetadata = true; + return (origin, rotX, rotY) => quakeDebugMonsterCameraStandoffOrigin( + origin, + rotX, + rotY, + classnames, + extraUnits, + ); +} + +function quakeDebugMonsterClassnamesForMode( + params: URLSearchParams, + mode: string, + explicitClassnamesKey: string, + explicitClassesKey: string, +): string[] { + const explicit = params.get(explicitClassnamesKey) ?? params.get(explicitClassesKey) ?? ""; + const explicitClassnames = explicit + .split(",") + .map((value) => value.trim()) + .filter((value) => /^monster_[a-z0-9_]+$/i.test(value)); + if (explicitClassnames.length) return [...new Set(explicitClassnames)]; + if (mode === "boss" || mode.startsWith("boss-")) return ["monster_boss"]; + if (mode === "large" || mode === "hull2") return ["monster_ogre", "monster_demon1", "monster_shambler"]; + const aliases: Record = { + demon: "monster_demon1", + dog: "monster_dog", + knight: "monster_knight", + ogre: "monster_ogre", + shambler: "monster_shambler", + soldier: "monster_army", + wizard: "monster_wizard", + zombie: "monster_zombie", + }; + if (aliases[mode]) return [aliases[mode]]; + return /^monster_[a-z0-9_]+$/i.test(mode) ? [mode] : []; +} + +function quakeDebugMonsterCameraStandoffOrigin( + origin: Vec3, + rotX: number, + rotY: number, + classnames: readonly string[], + extraUnits: number, +): Vec3 { + const candidate = quakeDebugMonsterCameraStandoffCandidate(origin, rotX, rotY, classnames, extraUnits); + if (!candidate) return origin; + if (!quakeDebugMonsterCameraStandoffCandidateValid(origin, candidate.origin)) { + markQuakeTrace("camera-standoff-blocked", { + class: candidate.classname, + distance: candidate.distance, + entity: candidate.entityIndex, + target: candidate.targetDistance, + }); + return origin; + } + markQuakeTrace("camera-standoff-apply", { + class: candidate.classname, + distance: candidate.distance, + dx: candidate.origin[0] - origin[0], + dy: candidate.origin[1] - origin[1], + entity: candidate.entityIndex, + target: candidate.targetDistance, + }); + return candidate.origin; +} + +function quakeDebugMonsterCameraStandoffCandidate( + origin: Vec3, + rotX: number, + rotY: number, + classnames: readonly string[], + extraUnits: number, +): { + classname: string; + distance: number; + entityIndex: number | null; + origin: Vec3; + targetDistance: number; +} | null { + let best: { + classname: string; + distance: number; + entityIndex: number | null; + origin: Vec3; + pressure: number; + targetDistance: number; + } | null = null; + const classSet = new Set(classnames); + for (const element of document.querySelectorAll(".polycss-mesh.shootable.enemy")) { + if ( + element.classList.contains("quake-shootable-prewarmed") || + element.classList.contains("quake-frame-hidden") + ) { + continue; + } + const classname = element.dataset.classname ?? ""; + if (!classSet.has(classname)) continue; + const enemyOrigin = quakeDebugMonsterCameraStandoffMeshOrigin(element); + if (!enemyOrigin) continue; + const targetDistance = quakeDebugMonsterCameraStandoffDistance(classname, extraUnits); + const dx = origin[0] - enemyOrigin[0]; + const dy = origin[1] - enemyOrigin[1]; + const distance = Math.hypot(dx, dy); + const pressure = targetDistance - distance; + if (pressure <= 0.001) continue; + const away = distance > 0.0001 + ? [dx / distance, dy / distance] + : quakeDebugMonsterCameraStandoffFallbackAway(rotX, rotY); + const candidate: Vec3 = [ + enemyOrigin[0] + away[0] * targetDistance, + enemyOrigin[1] + away[1] * targetDistance, + origin[2], + ]; + if (!best || pressure > best.pressure) { + best = { + classname, + distance, + entityIndex: quakeDebugMonsterCameraStandoffEntityIndex(element), + origin: candidate, + pressure, + targetDistance, + }; + } + } + return best; +} + +function quakeDebugMonsterCameraStandoffMeshOrigin(element: HTMLElement): Vec3 | null { + const x = Number(element.dataset.originX); + const y = Number(element.dataset.originY); + const z = Number(element.dataset.originZ); + return Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) ? [x, y, z] : null; +} + +function quakeDebugMonsterCameraStandoffEntityIndex(element: HTMLElement): number | null { + const entityIndex = Number(element.dataset.entityIndex); + return Number.isFinite(entityIndex) ? entityIndex : null; +} + +function quakeDebugMonsterCameraStandoffDistance(classname: string, extraUnits: number): number { + return (quakeDebugMonsterCameraStandoffBaseUnits(classname) + extraUnits) * QUAKE_COLLISION_UNIT_SCALE; +} + +function quakeDebugMonsterCameraStandoffBaseUnits(classname: string): number { + if (classname === "monster_boss") return 144; + if ( + classname === "monster_ogre" || + classname === "monster_demon1" || + classname === "monster_shambler" || + classname === "monster_dog" + ) { + return 48; + } + return 32; +} + +function quakeDebugMonsterCameraStandoffFallbackAway(rotX: number, rotY: number): [number, number] { + const forward = forwardDirection(rotX, rotY); + const length = Math.hypot(forward[0], forward[1]); + return length > 0.0001 ? [-forward[0] / length, -forward[1] / length] : [1, 0]; +} + +function quakeDebugMonsterCameraStandoffCandidateValid(origin: Vec3, candidate: Vec3): boolean { + if (!currentCollisionWorld) return false; + if (currentCollisionWorld.contentsAt?.(candidate) === QUAKE_CONTENTS_SOLID) return false; + const trace = currentCollisionWorld.traceUse?.(origin, candidate); + if (trace && trace.fraction < 0.999) return false; + const originLeaf = world.leafIndexAt(origin); + const candidateLeaf = world.leafIndexAt(candidate); + return originLeaf === null || candidateLeaf === null || originLeaf === candidateLeaf; +} + function createQuakeMultiplayerLocalClientId(): string { const override = new URLSearchParams(window.location.search).get("clientId")?.trim(); if (override) return `client-${override.replace(/[^a-z0-9_-]/gi, "").slice(0, 32) || "debug"}`; @@ -457,6 +687,7 @@ if (versionLabel) versionLabel.textContent = cssQuakeVersionLabel; const QUAKE_JUMP_VELOCITY = 270 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_GRAVITY = 800 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_CONTENTS_SOLID = -2; const QUAKE_MOBILE_MOVE_SPEED = 5.4 * QUAKE_RENDER_SUPERSAMPLE; const QUAKE_DEBUG_FLY_SPEED = 10.8 * QUAKE_RENDER_SUPERSAMPLE; const QUAKE_DEBUG_FLY_FAST_MULTIPLIER = 3; @@ -467,6 +698,8 @@ const QUAKE_MONSTER_RUNTIME_ENABLED = true; const QUAKE_MONSTER_MOUNT_VIEW_DOT_MIN = -0.1; const quakeStartupUrlParams = new URLSearchParams(window.location.search); const quakeDebugMonsterMotionMaterial = quakeDebugMonsterMotionMaterialPolicy(quakeStartupUrlParams); +const quakeDebugMonsterPlayerClearance = quakeDebugMonsterPlayerClearancePolicy(quakeStartupUrlParams); +const quakeDebugMonsterCameraStandoff = quakeDebugMonsterCameraStandoffPolicy(quakeStartupUrlParams); const quakeAssetCatalog = createQuakeAssetCatalogFlow({ levelList, mountBitmapText: mountQuakeBitmapText, @@ -1329,6 +1562,7 @@ quakeCameraFeedback = createQuakeCameraFeedbackFlow({ hasCurrentScene: () => currentResult !== null, isDisposed: () => quakeAppDisposed, queueCrosshairTargetSync: queueQuakeCrosshairTargetSync, + renderOriginPolicy: quakeDebugMonsterCameraStandoff, scene, viewmodel: { playFireAnimation: (animation) => viewmodel.playFireAnimation(animation), @@ -1413,6 +1647,7 @@ const shootables = createQuakeShootablesController({ enemyAttacksEnabled: () => !quakeAttacksDisabled, enemyMotionMaterial: quakeDebugMonsterMotionMaterial, enemyRandomSalt: createQuakeRuntimeRandomSalt, + playerClearance: quakeDebugMonsterPlayerClearance, dropBackpack: (drop) => { const origin = drop.sourceEntity.origin ?? { x: 0, y: 0, z: 0 }; const entity: QuakeEntity = { @@ -3826,6 +4061,7 @@ function installQuakeAppDebugHooks(): void { loadMap: loadQuakeMap, mapExists: quakeAssetCatalog.mapExists, pointToPoly: quakeCameraView.pointToPoly, + renderOrigin: quakeCameraView.currentRenderOrigin, setCollisionBypassUntil: (until) => { quakeDebugCollisionBypassUntil = until; }, diff --git a/src/prepare/assets.mjs b/src/prepare/assets.mjs index ca2c0cc..9a0189c 100644 --- a/src/prepare/assets.mjs +++ b/src/prepare/assets.mjs @@ -146,7 +146,7 @@ const quakeTriangleAtlasBasis = process.env.QUAKE_TRIANGLE_ATLAS_BASIS === "1"; const quakeDeterministicWorldAtlas = process.env.QUAKE_DETERMINISTIC_WORLD_ATLAS !== "0"; const quakeDeferDeterministicWorldAtlasWrites = process.env.QUAKE_DEFER_DETERMINISTIC_WORLD_ATLAS_WRITES !== "0"; const quakeDeterministicWorldAtlasImagePolicy = - process.env.QUAKE_DETERMINISTIC_WORLD_ATLAS_IMAGE_POLICY?.trim().toLowerCase() ?? "smart"; + process.env.QUAKE_DETERMINISTIC_WORLD_ATLAS_IMAGE_POLICY?.trim().toLowerCase() ?? "atlas"; const quakeAliasRebakeMerge = process.env.QUAKE_ALIAS_REBAKE_MERGE === "1"; const quakeAliasRebakeMergeAffineEpsilon = Number.parseFloat( process.env.QUAKE_ALIAS_REBAKE_MERGE_AFFINE_EPSILON ?? "", @@ -783,7 +783,7 @@ try { `Deterministic world atlas for ${mapName}: ` + `${deterministicAtlasStats.replacedLeaves} replaced, ` + `${deterministicAtlasStats.skippedLeaves} skipped, ` + - `${deterministicAtlasStats.pageCount ?? 0} png pages, ` + + `${deterministicAtlasStats.pageCount ?? 0} ${deterministicAtlasStats.pageFormat ?? "png"} pages, ` + `${deterministicAtlasStats.leafImageCount ?? 0} leaf images, ` + `${formatBytes(deterministicAtlasStats.pageBytes ?? 0)}`, ); diff --git a/src/prepare/deterministicAtlas.mjs b/src/prepare/deterministicAtlas.mjs index 7a5462a..1aed1e5 100644 --- a/src/prepare/deterministicAtlas.mjs +++ b/src/prepare/deterministicAtlas.mjs @@ -31,8 +31,31 @@ const BSP_LUMP_MODELS = 14; const BSP_LUMP_COUNT = 15; const BSP_HEADER_SIZE = 4 + BSP_LUMP_COUNT * 8; const QUAKE_PLAYER_MINS_Z = -24; -const QUAKE_DETERMINISTIC_ATLAS_PAGE_SIZE = 4096; const QUAKE_DETERMINISTIC_ATLAS_PAGE_PADDING = 1; +const QUAKE_DETERMINISTIC_ATLAS_PAGE_SIZE = normalizeDeterministicAtlasPageSize( + process.env.QUAKE_DETERMINISTIC_ATLAS_PAGE_SIZE, +); +const QUAKE_DETERMINISTIC_ATLAS_PAGE_FORMAT = normalizeDeterministicAtlasPageFormat( + process.env.QUAKE_DETERMINISTIC_ATLAS_PAGE_FORMAT ?? process.env.QUAKE_DETERMINISTIC_ATLAS_IMAGE_FORMAT, +); +const QUAKE_DETERMINISTIC_ATLAS_AVIF_QUALITY = normalizeDeterministicAtlasEncoderInt( + process.env.QUAKE_DETERMINISTIC_ATLAS_AVIF_QUALITY ?? process.env.QUAKE_RENDER_BUNDLE_AVIF_QUALITY, + 92, + 1, + 100, +); +const QUAKE_DETERMINISTIC_ATLAS_AVIF_EFFORT = normalizeDeterministicAtlasEncoderInt( + process.env.QUAKE_DETERMINISTIC_ATLAS_AVIF_EFFORT ?? process.env.QUAKE_RENDER_BUNDLE_AVIF_EFFORT, + 4, + 0, + 9, +); +const QUAKE_DETERMINISTIC_ATLAS_WEBP_QUALITY = normalizeDeterministicAtlasEncoderInt( + process.env.QUAKE_DETERMINISTIC_ATLAS_WEBP_QUALITY, + 90, + 1, + 100, +); // Quake renders sky through a separate projected tile path, not direct BSP texture scale. // Keep the coarser sky UV scale, but sample from the full prepared sky texture. const QUAKE_DETERMINISTIC_SKY_UV_SCALE = 0.25; @@ -90,6 +113,24 @@ function normalizeDeterministicAtlasLightSampling(value) { ); } +function normalizeDeterministicAtlasPageSize(value) { + const parsed = Number.parseInt(String(value ?? "").trim(), 10); + if (!Number.isFinite(parsed)) return 2048; + return Math.max(512, Math.min(8192, parsed)); +} + +function normalizeDeterministicAtlasPageFormat(value) { + const normalized = String(value ?? "").trim().toLowerCase(); + if (normalized === "png" || normalized === "webp") return normalized; + return "avif"; +} + +function normalizeDeterministicAtlasEncoderInt(value, fallback, min, max) { + const parsed = Number.parseInt(String(value ?? "").trim(), 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, parsed)); +} + function createDeterministicAtlasTiming(name) { return QUAKE_DETERMINISTIC_ATLAS_TIMING ? { @@ -142,17 +183,17 @@ function deterministicAtlasTimingCount(timing, label) { function logDeterministicAtlasTiming(timing, stats) { if (!timing) return; addDeterministicAtlasTimingDuration(timing, "total", performance.now() - timing.startedAt); - const pngEncodeMs = - deterministicAtlasTimingDuration(timing, "png.encode.atlasPage") + - deterministicAtlasTimingDuration(timing, "png.encode.leafImage") + - deterministicAtlasTimingDuration(timing, "png.encode.runtimeTexture"); - const pngEncodeWriteMs = - deterministicAtlasTimingDuration(timing, "png.encodeWrite.leafImage") + - deterministicAtlasTimingDuration(timing, "png.encodeWrite.runtimeTexture"); - const pngWriteMs = - deterministicAtlasTimingDuration(timing, "png.write.atlasPage") + - deterministicAtlasTimingDuration(timing, "png.write.leafImage") + - deterministicAtlasTimingDuration(timing, "png.write.runtimeTexture"); + const imageEncodeMs = + deterministicAtlasTimingDuration(timing, "image.encode.atlasPage") + + deterministicAtlasTimingDuration(timing, "image.encode.leafImage") + + deterministicAtlasTimingDuration(timing, "image.encode.runtimeTexture"); + const imageEncodeWriteMs = + deterministicAtlasTimingDuration(timing, "image.encodeWrite.leafImage") + + deterministicAtlasTimingDuration(timing, "image.encodeWrite.runtimeTexture"); + const imageWriteMs = + deterministicAtlasTimingDuration(timing, "image.write.atlasPage") + + deterministicAtlasTimingDuration(timing, "image.write.leafImage") + + deterministicAtlasTimingDuration(timing, "image.write.runtimeTexture"); console.log( `Deterministic atlas timing for ${timing.name}: ` + `total=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "total"))}, ` + @@ -160,10 +201,10 @@ function logDeterministicAtlasTiming(timing, stats) { `nativePack=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "native.batch.pack"))}, ` + `nativeCall=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "native.batch.call"))}, ` + `policyPack=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "policy.pack"))}, ` + - `pageCompose=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "png.compose.atlasPage"))}, ` + - `pngEncode=${formatDeterministicAtlasTimingMs(pngEncodeMs)}, ` + - `pngEncodeWrite=${formatDeterministicAtlasTimingMs(pngEncodeWriteMs)}, ` + - `pngWrite=${formatDeterministicAtlasTimingMs(pngWriteMs)}, ` + + `pageCompose=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "image.compose.atlasPage"))}, ` + + `imageEncode=${formatDeterministicAtlasTimingMs(imageEncodeMs)}, ` + + `imageEncodeWrite=${formatDeterministicAtlasTimingMs(imageEncodeWriteMs)}, ` + + `imageWrite=${formatDeterministicAtlasTimingMs(imageWriteMs)}, ` + `rewrite=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "mesh.rewrite"))}, ` + `compact=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "mesh.compactAssets"))}, ` + `oldAssetScan=${formatDeterministicAtlasTimingMs(deterministicAtlasTimingDuration(timing, "oldAssetScan"))}, ` + @@ -297,7 +338,7 @@ export async function replaceQuakeRenderBundleWorldAtlas({ const filename = deterministicAtlasPageFilename(index); const outputPath = path.join(outputDir, filename); const image = await renderDeterministicAtlasPage(page, timing); - await withDeterministicAtlasTiming(timing, "png.write.atlasPage", () => writeFile(outputPath, image)); + await withDeterministicAtlasTiming(timing, "image.write.atlasPage", () => writeFile(outputPath, image)); pageBytes += image.byteLength; pageAssetUrls.push(`${publicPath}/${filename}`); } @@ -363,6 +404,8 @@ export async function replaceQuakeRenderBundleWorldAtlas({ leafImageBytes, runtimeTextureImageBytes, pageCount: pages.length, + pageSize: QUAKE_DETERMINISTIC_ATLAS_PAGE_SIZE, + pageFormat: QUAKE_DETERMINISTIC_ATLAS_PAGE_FORMAT, atlasTileCount: atlasTiles.length, leafImageCount: leafImageTiles.length, runtimeTextureImageCount, @@ -2247,7 +2290,7 @@ function packDeterministicAtlasTiles(tiles) { } async function renderDeterministicAtlasPage(page, timing = null) { - const pageRgba = withDeterministicAtlasTimingSync(timing, "png.compose.atlasPage", () => { + const pageRgba = withDeterministicAtlasTimingSync(timing, "image.compose.atlasPage", () => { const rgba = Buffer.alloc(page.width * page.height * 4); for (const tile of page.tiles) { const sourceStride = tile.width * 4; @@ -2260,10 +2303,31 @@ async function renderDeterministicAtlasPage(page, timing = null) { } return rgba; }); - return withDeterministicAtlasTiming(timing, "png.encode.atlasPage", () => - sharp(pageRgba, { raw: { width: page.width, height: page.height, channels: 4 } }) - .png() - .toBuffer()); + return renderDeterministicAtlasPageImage(page.width, page.height, pageRgba, timing); +} + +function renderDeterministicAtlasPageImage(width, height, rgba, timing = null) { + return withDeterministicAtlasTiming(timing, "image.encode.atlasPage", () => + encodeDeterministicAtlasPageImage( + sharp(rgba, { raw: { width, height, channels: 4 } }), + ).toBuffer()); +} + +function encodeDeterministicAtlasPageImage(image) { + if (QUAKE_DETERMINISTIC_ATLAS_PAGE_FORMAT === "avif") { + return image.avif({ + quality: QUAKE_DETERMINISTIC_ATLAS_AVIF_QUALITY, + effort: QUAKE_DETERMINISTIC_ATLAS_AVIF_EFFORT, + chromaSubsampling: "4:4:4", + }); + } + if (QUAKE_DETERMINISTIC_ATLAS_PAGE_FORMAT === "webp") { + return image.webp({ + quality: QUAKE_DETERMINISTIC_ATLAS_WEBP_QUALITY, + smartSubsample: true, + }); + } + return image.png(); } function writeDeterministicLeafImage(tile, outputPath, timing = null) { @@ -2278,14 +2342,14 @@ function writeDeterministicLeafImage(tile, outputPath, timing = null) { } function renderDeterministicRgbaImage(width, height, rgba, timing = null, label = "png.encode.leafImage") { - return withDeterministicAtlasTiming(timing, label, () => + return withDeterministicAtlasTiming(timing, normalizeDeterministicAtlasTimingLabel(label), () => sharp(rgba, { raw: { width, height, channels: 4 } }) .png() .toBuffer()); } async function writeDeterministicRgbaImage(width, height, rgba, outputPath, timing = null, label = "png.encodeWrite.leafImage") { - const info = await withDeterministicAtlasTiming(timing, label, () => + const info = await withDeterministicAtlasTiming(timing, normalizeDeterministicAtlasTimingLabel(label), () => sharp(rgba, { raw: { width, height, channels: 4 } }) .png() .toFile(outputPath)); @@ -2294,8 +2358,12 @@ async function writeDeterministicRgbaImage(width, height, rgba, outputPath, timi return file.size; } +function normalizeDeterministicAtlasTimingLabel(label) { + return String(label).replace(/^png\./, "image."); +} + function deterministicAtlasPageFilename(index) { - return `a${index}.png`; + return `a${index}.${QUAKE_DETERMINISTIC_ATLAS_PAGE_FORMAT}`; } function deterministicLeafImageFilename(leafIndex, kind = "") { diff --git a/src/runtime/app/cameraFeedbackFlow.ts b/src/runtime/app/cameraFeedbackFlow.ts index bcb2159..f27adb6 100644 --- a/src/runtime/app/cameraFeedbackFlow.ts +++ b/src/runtime/app/cameraFeedbackFlow.ts @@ -19,6 +19,8 @@ const QUAKE_DAMAGE_VIEW_PITCH_MAX_DEG = 2; export type QuakeCameraOriginSyncMode = "move" | "reset" | "smooth-step"; +export type QuakeRenderCameraOriginPolicy = (origin: Vec3, rotX: number, rotY: number) => Vec3; + interface QuakeCameraFeedbackScene { camera: { perspectiveStyle: string; @@ -51,6 +53,7 @@ export interface QuakeCameraFeedbackFlowOptions { hasCurrentScene(): boolean; isDisposed(): boolean; queueCrosshairTargetSync(): void; + renderOriginPolicy?: QuakeRenderCameraOriginPolicy | null; scene: QuakeCameraFeedbackScene; viewmodel: QuakeCameraFeedbackViewmodel; } @@ -70,6 +73,7 @@ export function createQuakeCameraFeedbackFlow( options: QuakeCameraFeedbackFlowOptions, ): QuakeCameraFeedbackFlow { let cameraRenderOrigin: Vec3 = [0, 0, 1.72]; + let cameraAppliedRenderOrigin: Vec3 = [0, 0, 1.72]; let cameraStepSmoothFrame = 0; let cameraStepSmoothAt = 0; let weaponViewPunchFrame = 0; @@ -78,7 +82,7 @@ export function createQuakeCameraFeedbackFlow( let weaponViewPunchBaseRotX: number | null = null; function currentRenderOrigin(): Vec3 { - return cameraRenderOrigin; + return cameraAppliedRenderOrigin; } function syncOrigin(origin: Vec3, mode: QuakeCameraOriginSyncMode): void { @@ -166,18 +170,21 @@ export function createQuakeCameraFeedbackFlow( cancelStepSmoothingFrame(); cameraStepSmoothAt = 0; cameraRenderOrigin = [origin[0], origin[1], origin[2]]; + cameraAppliedRenderOrigin = [origin[0], origin[1], origin[2]]; } function applyAt(origin: Vec3, rotX: number, rotY: number): void { + const renderOrigin = options.renderOriginPolicy?.(origin, rotX, rotY) ?? origin; + cameraAppliedRenderOrigin = [renderOrigin[0], renderOrigin[1], renderOrigin[2]]; const forward = quakeCameraForwardDirection(rotX, rotY); const distance = lookOffset(); options.scene.camera.update({ rotX, rotY, target: [ - origin[0] + forward[0] * distance, - origin[1] + forward[1] * distance, - origin[2] + forward[2] * distance, + renderOrigin[0] + forward[0] * distance, + renderOrigin[1] + forward[1] * distance, + renderOrigin[2] + forward[2] * distance, ], }); options.scene.applyCamera(); diff --git a/src/runtime/app/debugApi.ts b/src/runtime/app/debugApi.ts index d7d18ed..4c3932e 100644 --- a/src/runtime/app/debugApi.ts +++ b/src/runtime/app/debugApi.ts @@ -28,6 +28,7 @@ export interface QuakeAppDebugApiOptions { loadMap(mapName: string): Promise; mapExists(mapName: string): boolean; pointToPoly(point: { x: number; y: number; z: number }): Vec3; + renderOrigin(): Vec3; setCollisionBypassUntil(until: number): void; setMultiplayerInputPaused(paused: boolean): boolean; syncHud(): void; @@ -54,6 +55,7 @@ function createQuakeAppDebugRuntime({ loadMap, mapExists, pointToPoly, + renderOrigin, setCollisionBypassUntil, setMultiplayerInputPaused, syncHud, @@ -127,6 +129,7 @@ function createQuakeAppDebugRuntime({ playerEyeHeight: () => runtime.controllers.player().eyeHeight(), playerMoveDebug: () => runtime.controllers.player().debugMovement(), pointToPoly, + renderOrigin, projectileImpact: (weapon, entityIndex, origin, directDamage) => runtime.controllers.weapons.debugProjectileImpact(weapon, entityIndex, origin, directDamage), projectileTraceCapture: () => runtime.controllers.weapons.debugProjectileCapture(), @@ -141,6 +144,7 @@ function createQuakeAppDebugRuntime({ syncCrosshairTarget, syncGameplay, syncMultiplayerPose, + syncPlayerCollision: () => runtime.controllers.player().syncCollision(), syncPickupsVisibility: (origin) => runtime.controllers.pickups().syncVisibility(origin), syncSceneCameraAt, syncShootablesVisibility: (origin, force) => runtime.controllers.shootables.syncVisibility(origin, force), diff --git a/src/runtime/debug/churnStats.ts b/src/runtime/debug/churnStats.ts index feb735f..405f889 100644 --- a/src/runtime/debug/churnStats.ts +++ b/src/runtime/debug/churnStats.ts @@ -24,16 +24,55 @@ export interface QuakeWorldSemanticResidencyStats { totalRemovedLeaves: number; } +export interface QuakeWorldResidencyTransitionStats { + prevLeafIndex: number | null; + nextLeafIndex: number | null; + prevVisibleFaceGroupKey: string | null; + nextVisibleFaceGroupKey: string | null; + transitionKey: string; + transitionCacheHit: boolean; + transitionCacheSize: number; + planningMs: number; + mutationJsMs: number; + totalMs: number; + scannedFaceLeafCount: number; + visibleFaceCount: number | null; + addCount: number; + removeCount: number; + deferCount: number; + immediateAddCount: number; + frontierAddCount: number; + farAddCount: number; + mountedLeafCountBefore: number; + mountedLeafCountAfter: number; + mountedLeafPeak: number; + residencyQueueImmediateSize: number; + residencyQueueFrontierSize: number; + residencyQueueFarSize: number; +} + export interface QuakeWorldVisibilityChurnStats { syncCount: number; + transitionCount: number; changedSyncCount: number; skippedSyncCount: number; forceSyncCount: number; noHandleSyncCount: number; noPvsSyncCount: number; totalSyncMs: number; + totalTransitionPlanningMs: number; + totalTransitionMutationJsMs: number; + totalTransitionMs: number; maxSyncMs: number; + maxTransitionTotalMs: number; lastSyncMs: number; + lastTransitionPlanningMs: number; + lastTransitionMutationJsMs: number; + lastTransitionTotalMs: number; + lastTransitionScannedFaceLeafCount: number; + lastTransitionAddCount: number; + lastTransitionRemoveCount: number; + lastTransitionDeferCount: number; lastReason: QuakeWorldVisibilitySyncReason | null; lastPvsFaceCount: number | null; lastAddedLeaves: number; @@ -42,20 +81,33 @@ export interface QuakeWorldVisibilityChurnStats { totalAddedLeaves: number; totalRemovedLeaves: number; totalChangedLeaves: number; + lastTransition?: QuakeWorldResidencyTransitionStats; semanticResidency?: QuakeWorldSemanticResidencyStats; } export function createQuakeWorldVisibilityChurnStats(): QuakeWorldVisibilityChurnStats { return { syncCount: 0, + transitionCount: 0, changedSyncCount: 0, skippedSyncCount: 0, forceSyncCount: 0, noHandleSyncCount: 0, noPvsSyncCount: 0, totalSyncMs: 0, + totalTransitionPlanningMs: 0, + totalTransitionMutationJsMs: 0, + totalTransitionMs: 0, maxSyncMs: 0, + maxTransitionTotalMs: 0, lastSyncMs: 0, + lastTransitionPlanningMs: 0, + lastTransitionMutationJsMs: 0, + lastTransitionTotalMs: 0, + lastTransitionScannedFaceLeafCount: 0, + lastTransitionAddCount: 0, + lastTransitionRemoveCount: 0, + lastTransitionDeferCount: 0, lastReason: null, lastPvsFaceCount: null, lastAddedLeaves: 0, @@ -104,6 +156,25 @@ export function recordQuakeWorldVisibilitySync( stats.totalChangedLeaves += changedLeaves; } +export function recordQuakeWorldResidencyTransition( + stats: QuakeWorldVisibilityChurnStats, + transition: QuakeWorldResidencyTransitionStats, +): void { + stats.transitionCount++; + stats.totalTransitionPlanningMs += transition.planningMs; + stats.totalTransitionMutationJsMs += transition.mutationJsMs; + stats.totalTransitionMs += transition.totalMs; + stats.maxTransitionTotalMs = Math.max(stats.maxTransitionTotalMs, transition.totalMs); + stats.lastTransitionPlanningMs = transition.planningMs; + stats.lastTransitionMutationJsMs = transition.mutationJsMs; + stats.lastTransitionTotalMs = transition.totalMs; + stats.lastTransitionScannedFaceLeafCount = transition.scannedFaceLeafCount; + stats.lastTransitionAddCount = transition.addCount; + stats.lastTransitionRemoveCount = transition.removeCount; + stats.lastTransitionDeferCount = transition.deferCount; + stats.lastTransition = { ...transition }; +} + type QuakeShootablesVisibilitySyncReason = "force" | "same-selection" | "selection-change"; export interface QuakeShootablesVisibilityChurnStats { diff --git a/src/runtime/debug/quakeDebug.ts b/src/runtime/debug/quakeDebug.ts index 158a8a4..1544458 100644 --- a/src/runtime/debug/quakeDebug.ts +++ b/src/runtime/debug/quakeDebug.ts @@ -106,6 +106,7 @@ export interface QuakeDebugHooks { options?: QuakeDebugPoseOptions, ): boolean; stats(): Record; + syncCollision(): boolean; syncMultiplayerPose(): boolean; touchEntity(entityIndex: number): boolean; viewUrl(): string; @@ -181,6 +182,7 @@ export interface QuakeDebugRuntime { playerEyeHeight(): number; playerMoveDebug(): Record; pointToPoly(point: { x: number; y: number; z: number }): Vec3; + renderOrigin(): Vec3; projectileImpact( weapon: QuakeWeaponId, entityIndex: number, @@ -197,6 +199,7 @@ export interface QuakeDebugRuntime { triggersStats(): Record; syncCrosshairTarget(): void; syncGameplay(origin: [number, number, number]): void; + syncPlayerCollision(): void; syncMultiplayerPose(): boolean; syncPickupsVisibility(origin: [number, number, number]): void; syncSceneCameraAt(origin: [number, number, number], rotX: number, rotY: number): void; @@ -265,6 +268,7 @@ export function installQuakeDebugHooks(enabled: boolean, runtime: QuakeDebugRunt setViewpos: (x, y, z, pitch, yaw, rollOrOptions, options) => setQuakeDebugViewpos(runtime, x, y, z, pitch, yaw, rollOrOptions, options), stats: () => buildQuakeDebugStats(runtime), + syncCollision: () => syncQuakeDebugCollision(runtime), syncMultiplayerPose: () => syncQuakeDebugMultiplayerPose(runtime), touchEntity: (entityIndex) => touchQuakeDebugEntity(runtime, entityIndex), viewUrl: () => runtime.viewUrl(), @@ -294,6 +298,14 @@ function syncQuakeDebugMultiplayerPose(runtime: QuakeDebugRuntime): boolean { return runtime.syncMultiplayerPose(); } +function syncQuakeDebugCollision(runtime: QuakeDebugRuntime): boolean { + if (runtime.isLoading() || !runtime.hasCurrentScene()) return false; + runtime.setCollisionBypassUntil(0); + runtime.hideMainMenu(); + runtime.syncPlayerCollision(); + return true; +} + function setQuakeDebugMultiplayerInputPaused(runtime: QuakeDebugRuntime, paused: boolean): boolean { if (runtime.isLoading() || !runtime.hasCurrentScene()) return false; runtime.hideMainMenu(); @@ -615,16 +627,23 @@ function setQuakeDebugPose( runtime.setCollisionBypassUntil(collisionBypassMs > 0 ? performance.now() + collisionBypassMs : 0); runtime.hideMainMenu(); if (!originChanged && !rotationChanged) { - if (options.gameplay) runtime.syncGameplay(nextOrigin); - else if (options.stableViewmodel) runtime.syncViewmodel({ stable: true }); + if (options.gameplay) { + runtime.syncGameplay(nextOrigin); + runtime.syncSceneCameraAt(nextOrigin, rotX, rotY); + runtime.syncViewmodel({ stable: options.stableViewmodel }); + runtime.syncCrosshairTarget(); + } else if (options.stableViewmodel) runtime.syncViewmodel({ stable: true }); return true; } if (originChanged) runtime.controls.setOrigin(nextOrigin); - runtime.syncSceneCameraAt(nextOrigin, rotX, rotY); if (options.gameplay) { runtime.syncGameplay(nextOrigin); + runtime.syncSceneCameraAt(nextOrigin, rotX, rotY); + runtime.syncViewmodel({ stable: options.stableViewmodel }); + runtime.syncCrosshairTarget(); return true; } + runtime.syncSceneCameraAt(nextOrigin, rotX, rotY); runtime.syncShootablesVisibility(nextOrigin, originChanged); if (originChanged) { runtime.syncPickupsVisibility(nextOrigin); @@ -738,6 +757,7 @@ function buildQuakeDebugStats(runtime: QuakeDebugRuntime): Record { const state = recordingStateSummary(sample); const memory = asRecord(asRecord(sample.snapshot.performance).memory); + const worldTransition = asRecord(sample.snapshot.world.visibilityChurn.lastTransition); return { mapName: state.mapName, leafIndex: state.leafIndex, @@ -862,6 +863,16 @@ function frameHitchContext(sample: QuakeDebugRecordingSample): Record | null; fireTarget(targetname: string, sourceEntityIndex?: number): void; playSound?(soundPath: string, options?: QuakeShootableSoundOptions): boolean; } +export interface QuakeShootablesPlayerClearanceOptions { + enemyClassnames?: readonly string[]; + extraRadius: number; + useBossAwakeBounds?: boolean; +} + export interface QuakePlayerQuakecRandomDetails { damage?: number; functionName: string; @@ -477,6 +484,7 @@ export function createQuakeShootablesController({ pointToPoly, shouldSpawn, pixelate, + playerClearance = null, schedulePresentationResync, visibleLeavesAt, fireTarget, @@ -3766,7 +3774,13 @@ export function createQuakeShootablesController({ handle: PolyMeshHandle | null, reason: string, ): boolean { - if (!enemyMotionMaterial || !shootable.enemy || shootable.dead || !shootable.visible || handle !== shootable.handle) { + if ( + !enemyMotionMaterial || + !shootable.enemy || + shootable.dead || + !shootable.visible || + handle !== shootable.handle + ) { return false; } return markQuakeRenderBundleFrameSetHandleMotionMaterial(handle, reason); @@ -3830,14 +3844,15 @@ export function createQuakeShootablesController({ eyeHeight: number, shootable: QuakeShootableState, ): boolean { - const bounds = shootableCollisionWorldBounds(shootable); + const envelope = shootablePlayerCollisionEnvelope(shootable); + const bounds = envelope.bounds; const playerMinZ = origin[2] - eyeHeight; const playerMaxZ = playerMinZ + PLAYER_HEIGHT; if (playerMaxZ <= bounds.min[2] || playerMinZ >= bounds.max[2]) return false; - return origin[0] >= bounds.min[0] - PLAYER_RADIUS && - origin[0] <= bounds.max[0] + PLAYER_RADIUS && - origin[1] >= bounds.min[1] - PLAYER_RADIUS && - origin[1] <= bounds.max[1] + PLAYER_RADIUS; + return origin[0] >= bounds.min[0] - envelope.playerRadius && + origin[0] <= bounds.max[0] + envelope.playerRadius && + origin[1] >= bounds.min[1] - envelope.playerRadius && + origin[1] <= bounds.max[1] + envelope.playerRadius; } function pushPlayerOutOfShootable( @@ -3846,11 +3861,12 @@ export function createQuakeShootablesController({ shootable: QuakeShootableState, validateOrigin?: QuakeShootableCollisionOriginValidator, ): [number, number, number] { - const bounds = shootableCollisionWorldBounds(shootable); - const minX = bounds.min[0] - PLAYER_RADIUS - QUAKE_SHOOTABLE_COLLISION_EPSILON; - const maxX = bounds.max[0] + PLAYER_RADIUS + QUAKE_SHOOTABLE_COLLISION_EPSILON; - const minY = bounds.min[1] - PLAYER_RADIUS - QUAKE_SHOOTABLE_COLLISION_EPSILON; - const maxY = bounds.max[1] + PLAYER_RADIUS + QUAKE_SHOOTABLE_COLLISION_EPSILON; + const envelope = shootablePlayerCollisionEnvelope(shootable); + const bounds = envelope.bounds; + const minX = bounds.min[0] - envelope.playerRadius - QUAKE_SHOOTABLE_COLLISION_EPSILON; + const maxX = bounds.max[0] + envelope.playerRadius + QUAKE_SHOOTABLE_COLLISION_EPSILON; + const minY = bounds.min[1] - envelope.playerRadius - QUAKE_SHOOTABLE_COLLISION_EPSILON; + const maxY = bounds.max[1] + envelope.playerRadius + QUAKE_SHOOTABLE_COLLISION_EPSILON; const candidates: [number, number, number][] = []; const addCandidate = (candidate: [number, number, number]): void => { if (candidates.some((existing) => distanceSq3(existing, candidate) <= COLLISION_EPSILON)) return; @@ -3871,7 +3887,80 @@ export function createQuakeShootablesController({ distances.sort((a, b) => a.value - b.value); for (const distance of distances) addCandidate(distance.origin); if (!validateOrigin) return candidates[0] ?? origin; - return candidates.find(validateOrigin) ?? candidates[0] ?? origin; + const validated = candidates.find(validateOrigin); + if (validated) { + if (envelope.debugActive) { + markShootableTrace("player-clearance-push", shootable, { + candidateCount: candidates.length, + extraRadius: envelope.extraRadius, + playerRadius: envelope.playerRadius, + sourceBossBounds: envelope.sourceBossBounds, + x: validated[0], + y: validated[1], + z: validated[2], + }); + } + return validated; + } + if (envelope.debugActive) { + markShootableTrace("player-clearance-blocked", shootable, { + candidateCount: candidates.length, + extraRadius: envelope.extraRadius, + playerRadius: envelope.playerRadius, + sourceBossBounds: envelope.sourceBossBounds, + x: origin[0], + y: origin[1], + z: origin[2], + }); + } + return envelope.debugActive ? origin : candidates[0] ?? origin; + } + + function shootablePlayerCollisionEnvelope(shootable: QuakeShootableState): { + bounds: QuakeBounds; + debugActive: boolean; + extraRadius: number; + playerRadius: number; + sourceBossBounds: boolean; + } { + const clearanceActive = playerClearanceMatchesEntity(shootable.entity); + const extraRadius = clearanceActive ? Math.max(0, playerClearance?.extraRadius ?? 0) : 0; + const sourceBossBounds = clearanceActive && + playerClearance?.useBossAwakeBounds !== false && + quakeBossScriptedLifecycle(shootable.entity.classname) !== null; + const bounds = sourceBossBounds + ? shootableBossAwakeCollisionWorldBounds(shootable) ?? shootableCollisionWorldBounds(shootable) + : shootableCollisionWorldBounds(shootable); + return { + bounds, + debugActive: clearanceActive, + extraRadius, + playerRadius: PLAYER_RADIUS + extraRadius, + sourceBossBounds, + }; + } + + function playerClearanceMatchesEntity(entity: QuakeEntity): boolean { + if (!playerClearance) return false; + const classnames = playerClearance.enemyClassnames; + return !classnames?.length || classnames.includes(entity.classname); + } + + function shootableBossAwakeCollisionWorldBounds(shootable: QuakeShootableState): QuakeBounds | null { + const bounds = quakeBossScriptedLifecycle(shootable.entity.classname)?.awake.bounds; + if (!bounds) return null; + return { + min: [ + shootable.origin[0] + bounds.min[0] * QUAKE_COLLISION_UNIT_SCALE, + shootable.origin[1] + bounds.min[1] * QUAKE_COLLISION_UNIT_SCALE, + shootable.origin[2] + bounds.min[2] * QUAKE_COLLISION_UNIT_SCALE, + ], + max: [ + shootable.origin[0] + bounds.max[0] * QUAKE_COLLISION_UNIT_SCALE, + shootable.origin[1] + bounds.max[1] * QUAKE_COLLISION_UNIT_SCALE, + shootable.origin[2] + bounds.max[2] * QUAKE_COLLISION_UNIT_SCALE, + ], + }; } function shootableBounds(shootable: QuakeShootableState): { min: Vec3; max: Vec3 } { diff --git a/src/runtime/world.ts b/src/runtime/world.ts index 94c2682..4626bbd 100644 --- a/src/runtime/world.ts +++ b/src/runtime/world.ts @@ -14,7 +14,9 @@ import type { } from "../types/quake"; import { createQuakeWorldVisibilityChurnStats, + recordQuakeWorldResidencyTransition, recordQuakeWorldVisibilitySync, + type QuakeWorldResidencyTransitionStats, type QuakeWorldSemanticResidencyStats, type QuakeWorldVisibilityChurnStats, } from "./debug/churnStats"; @@ -91,6 +93,37 @@ interface QuakeSemanticResidencyPlan { farLeaves: QuakeFaceLeaf[]; } +interface QuakeResidencyQueueApplyResult { + addedLeaves: number; + appliedLeaves: number; + mutationJsMs: number; +} + +interface QuakeResidencyTransitionRecordDetails { + addCount?: number; + deferCount?: number; + farAddCount?: number; + force: boolean; + frontierAddCount?: number; + immediateAddCount?: number; + mountedLeafCountAfter: number; + mountedLeafCountBefore: number; + mutationJsMs?: number; + nextLeafIndex: number | null; + nextVisibleFaceKey: string | null; + planningMs?: number; + prevLeafIndex: number | null; + prevVisibleFaceKey: string | null; + reason: string; + removeCount?: number; + residencyQueueFarSize?: number; + residencyQueueFrontierSize?: number; + residencyQueueImmediateSize?: number; + scannedFaceLeafCount?: number; + startedAt: number; + visibleFaceCount?: number | null; +} + export interface QuakeWorldControllerOptions { applyMoverLeafTransform: (leaf: QuakeFaceLeaf) => void; getOrigin: () => [number, number, number]; @@ -152,6 +185,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) let modelLeaves = new Map(); let quakeLeaves: QuakeFaceLeaf[] = []; let visibleFaceKey = ""; + let visibleLeafIndex: number | null = null; const preloadedButtonImages = new Set(); let presentationResyncTasks = new Set(); let visibilityChurn = createQuakeWorldVisibilityChurnStats(); @@ -176,6 +210,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) modelLeaves = new Map(); quakeLeaves = []; visibleFaceKey = ""; + visibleLeafIndex = null; preloadedButtonImages.clear(); visibilityChurn = createQuakeWorldVisibilityChurnStats(); semanticResidencyDesiredLeaves = new Set(); @@ -261,12 +296,17 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) } const origin = options.getOrigin(); options.syncPickupsVisibility(origin); + const nextLeafIndexValue = currentVisibility?.leafIndexAt(origin); + const nextLeafIndex = Number.isInteger(nextLeafIndexValue) ? nextLeafIndexValue : null; + const prevLeafIndex = visibleLeafIndex; + const prevVisibleFaceKey = visibleFaceKey || null; const visibleFaceGroup = currentVisibility?.visibleFaceGroupAt(origin) ?? null; const visibleFaces = visibleFaceGroup?.faces ?? null; if (!visibleFaces) { let addedLeaves = 0; let removedLeaves = 0; if (visibleFaceKey === "all") { + visibleLeafIndex = nextLeafIndex; recordQuakeWorldVisibilitySync(visibilityChurn, "same-key", startedAt, { force }); if (force) { markQuakeTrace("world-visibility", { @@ -287,6 +327,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) if (change < 0) removedLeaves++; } visibleFaceKey = "all"; + visibleLeafIndex = nextLeafIndex; } recordQuakeWorldVisibilitySync(visibilityChurn, "no-pvs", startedAt, { force, addedLeaves, removedLeaves }); if (force || addedLeaves > 0 || removedLeaves > 0) { @@ -304,18 +345,47 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) const nextKey = visibleFaceGroup?.key ?? faceSetKey(visibleFaces); const semanticResidencyOptions = getQuakeSemanticResidencyOptions(); if (semanticResidencyOptions.enabled && - syncSemanticResidencyVisibility(visibleFaces, nextKey, origin, force, startedAt, semanticResidencyOptions)) { + syncSemanticResidencyVisibility( + visibleFaces, + nextKey, + origin, + force, + startedAt, + semanticResidencyOptions, + { + nextLeafIndex, + prevLeafIndex, + prevVisibleFaceKey, + }, + )) { return; } if (nextKey === visibleFaceKey) { + const leafChanged = nextLeafIndex !== visibleLeafIndex; + visibleLeafIndex = nextLeafIndex; recordQuakeWorldVisibilitySync(visibilityChurn, "same-key", startedAt, { force, pvsFaceCount: visibleFaces.size, }); - if (force) { + if (leafChanged || force) { + recordResidencyTransition({ + force, + mountedLeafCountBefore: countMountedQuakeLeaves(), + mountedLeafCountAfter: countMountedQuakeLeaves(), + nextLeafIndex, + nextVisibleFaceKey: nextKey, + prevLeafIndex, + prevVisibleFaceKey, + reason: force ? "force-same-key" : "same-key-leaf-transition", + startedAt, + visibleFaceCount: visibleFaces.size, + }); markQuakeTrace("world-visibility", { - reason: "force-same-key", + reason: force ? "force-same-key" : "same-key-leaf-transition", force, + leafChanged, + prevLeafIndex, + nextLeafIndex, pvsFaces: visibleFaces.size, addedLeaves: 0, removedLeaves: 0, @@ -323,30 +393,63 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) } return; } + const mountedLeafCountBefore = countMountedQuakeLeaves(); + const planningStartedAt = performance.now(); + const leafMountRequests: Array<[QuakeFaceLeaf, boolean]> = []; + let scannedFaceLeafCount = 0; + for (const [faceIndex, leaves] of faceLeaves) { + const visible = visibleFaces.has(faceIndex); + scannedFaceLeafCount += leaves.length; + for (const leaf of leaves) leafMountRequests.push([leaf, visible]); + } + const planningMs = performance.now() - planningStartedAt; visibleFaceKey = nextKey; + visibleLeafIndex = nextLeafIndex; const now = performance.now(); let addedLeaves = 0; let removedLeaves = 0; - for (const [faceIndex, leaves] of faceLeaves) { - const visible = visibleFaces.has(faceIndex); - for (const leaf of leaves) { - const change = setQuakeLeafMounted(leaf, visible, now); - if (change > 0) addedLeaves++; - if (change < 0) removedLeaves++; - } + const mutationStartedAt = performance.now(); + for (const [leaf, visible] of leafMountRequests) { + const change = setQuakeLeafMounted(leaf, visible, now); + if (change > 0) addedLeaves++; + if (change < 0) removedLeaves++; } + const mutationJsMs = performance.now() - mutationStartedAt; + const mountedLeafCountAfter = countMountedQuakeLeaves(); recordQuakeWorldVisibilitySync(visibilityChurn, force ? "force" : "leaf-change", startedAt, { force, pvsFaceCount: visibleFaces.size, addedLeaves, removedLeaves, }); + recordResidencyTransition({ + addCount: addedLeaves, + force, + mountedLeafCountAfter, + mountedLeafCountBefore, + mutationJsMs, + nextLeafIndex, + nextVisibleFaceKey: nextKey, + planningMs, + prevLeafIndex, + prevVisibleFaceKey, + reason: force ? "force" : "leaf-change", + scannedFaceLeafCount, + startedAt, + visibleFaceCount: visibleFaces.size, + removeCount: removedLeaves, + }); markQuakeTrace("world-visibility", { reason: force ? "force" : "leaf-change", force, + prevLeafIndex, + nextLeafIndex, pvsFaces: visibleFaces.size, addedLeaves, removedLeaves, + planningMs, + mutationJsMs, + scannedFaceLeafCount, }); }; @@ -357,6 +460,11 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) force: boolean, startedAt: number, residencyOptions: QuakeSemanticResidencyOptions, + transitionContext: { + nextLeafIndex: number | null; + prevLeafIndex: number | null; + prevVisibleFaceKey: string | null; + }, ): boolean => { const metadata = semanticResidencyMetadataForCurrentVisibility(); if (!metadata) { @@ -374,28 +482,53 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) } if (nextKey === visibleFaceKey) { + const leafChanged = transitionContext.nextLeafIndex !== visibleLeafIndex; + visibleLeafIndex = transitionContext.nextLeafIndex; const now = performance.now(); - const queuedAddedLeaves = force + const mountedLeafCountBefore = countMountedQuakeLeaves(); + const queueResult = force ? applySemanticResidencyQueue(now, residencyOptions.budget) - : 0; + : emptyResidencyQueueApplyResult(); + const mountedLeafCountAfter = countMountedQuakeLeaves(); if (semanticResidencyQueue.length > 0) scheduleSemanticResidencyQueue(residencyOptions); updateSemanticResidencyStats(residencyOptions, { metadataAvailable: true, - queuedAddedLeaves, + queuedAddedLeaves: queueResult.addedLeaves, syncAddedLeaves: 0, removedLeaves: 0, }); recordQuakeWorldVisibilitySync(visibilityChurn, "same-key", startedAt, { force, pvsFaceCount: visibleFaces.size, - addedLeaves: queuedAddedLeaves, + addedLeaves: queueResult.addedLeaves, }); - if (force || queuedAddedLeaves > 0 || semanticResidencyQueue.length > 0) { + if (leafChanged || force || queueResult.addedLeaves > 0 || semanticResidencyQueue.length > 0) { + recordResidencyTransition({ + addCount: queueResult.addedLeaves, + deferCount: semanticResidencyQueue.length, + force, + mountedLeafCountBefore, + mountedLeafCountAfter, + mutationJsMs: queueResult.mutationJsMs, + nextLeafIndex: transitionContext.nextLeafIndex, + nextVisibleFaceKey: nextKey, + prevLeafIndex: transitionContext.prevLeafIndex, + prevVisibleFaceKey: transitionContext.prevVisibleFaceKey, + reason: force ? "semantic-residency-force-same-key" : "semantic-residency-same-key", + residencyQueueFarSize: semanticResidencyQueue.length, + startedAt, + visibleFaceCount: visibleFaces.size, + }); markQuakeTrace("world-visibility", { reason: "semantic-residency-same-key", force, + leafChanged, + prevLeafIndex: transitionContext.prevLeafIndex, + nextLeafIndex: transitionContext.nextLeafIndex, pvsFaces: visibleFaces.size, - queuedAddedLeaves, + queuedAddedLeaves: queueResult.addedLeaves, + queueAppliedLeaves: queueResult.appliedLeaves, + mutationJsMs: queueResult.mutationJsMs, pendingLeaves: semanticResidencyQueue.length, desiredMinusMounted: semanticResidencyStats.desiredMinusMounted, mountedMinusDesired: semanticResidencyStats.mountedMinusDesired, @@ -404,30 +537,44 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) return true; } - visibleFaceKey = nextKey; + const mountedLeafCountBefore = countMountedQuakeLeaves(); + const planningStartedAt = performance.now(); semanticResidencyGeneration++; cancelSemanticResidencyQueue(); const plan = buildSemanticResidencyPlan(visibleFaces, origin, metadata, residencyOptions); semanticResidencyDesiredLeaves = plan.desiredLeaves; + const removeLeaves: QuakeFaceLeaf[] = []; + for (const leaf of quakeLeaves) { + if (!leaf.mounted || plan.desiredLeaves.has(leaf)) continue; + removeLeaves.push(leaf); + } + const immediateLeaves = [...plan.immediateLeaves]; + const frontierLeaves = [...plan.frontierLeaves]; + semanticResidencyQueue = plan.farLeaves.filter((leaf) => !leaf.mounted); + semanticResidencyMaxQueuePending = Math.max(semanticResidencyMaxQueuePending, semanticResidencyQueue.length); + const planningMs = performance.now() - planningStartedAt; const now = performance.now(); let removedLeaves = 0; let syncAddedLeaves = 0; - for (const leaf of quakeLeaves) { - if (!leaf.mounted || plan.desiredLeaves.has(leaf)) continue; + const immediateAddRequests = countUnmountedLeaves(immediateLeaves); + const frontierAddRequests = countUnmountedLeaves(frontierLeaves); + const mutationStartedAt = performance.now(); + for (const leaf of removeLeaves) { if (setQuakeLeafMounted(leaf, false, now) < 0) removedLeaves++; } - for (const leaf of plan.immediateLeaves) { + for (const leaf of immediateLeaves) { if (setQuakeLeafMounted(leaf, true, now) > 0) syncAddedLeaves++; } - for (const leaf of plan.frontierLeaves) { + for (const leaf of frontierLeaves) { if (setQuakeLeafMounted(leaf, true, now) > 0) syncAddedLeaves++; } - semanticResidencyQueue = plan.farLeaves.filter((leaf) => !leaf.mounted); - semanticResidencyMaxQueuePending = Math.max(semanticResidencyMaxQueuePending, semanticResidencyQueue.length); - const queuedAddedLeaves = applySemanticResidencyQueue(now, residencyOptions.budget); + const queueResult = applySemanticResidencyQueue(now, residencyOptions.budget); + const mutationJsMs = performance.now() - mutationStartedAt; if (semanticResidencyQueue.length > 0) scheduleSemanticResidencyQueue(residencyOptions); + visibleFaceKey = nextKey; + visibleLeafIndex = transitionContext.nextLeafIndex; updateSemanticResidencyStats(residencyOptions, { metadataAvailable: true, @@ -436,28 +583,55 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) frontierLeaves: plan.frontierLeaves.size, farLeaves: plan.farLeaves.length, syncAddedLeaves, - queuedAddedLeaves, + queuedAddedLeaves: queueResult.addedLeaves, removedLeaves, }); recordQuakeWorldVisibilitySync(visibilityChurn, force ? "force" : "leaf-change", startedAt, { force, pvsFaceCount: visibleFaces.size, - addedLeaves: syncAddedLeaves + queuedAddedLeaves, + addedLeaves: syncAddedLeaves + queueResult.addedLeaves, removedLeaves, }); + const mountedLeafCountAfter = countMountedQuakeLeaves(); + recordResidencyTransition({ + addCount: syncAddedLeaves + queueResult.addedLeaves, + deferCount: semanticResidencyQueue.length, + farAddCount: queueResult.addedLeaves, + force, + frontierAddCount: frontierAddRequests, + immediateAddCount: immediateAddRequests, + mountedLeafCountAfter, + mountedLeafCountBefore, + mutationJsMs, + nextLeafIndex: transitionContext.nextLeafIndex, + nextVisibleFaceKey: nextKey, + planningMs, + prevLeafIndex: transitionContext.prevLeafIndex, + prevVisibleFaceKey: transitionContext.prevVisibleFaceKey, + reason: "semantic-residency", + removeCount: removedLeaves, + residencyQueueFarSize: semanticResidencyQueue.length, + scannedFaceLeafCount: faceLeavesEntryCount(), + startedAt, + visibleFaceCount: visibleFaces.size, + }); markQuakeTrace("world-visibility", { reason: "semantic-residency", force, pvsFaces: visibleFaces.size, currentLeafIndex: plan.currentLeafIndex, + prevLeafIndex: transitionContext.prevLeafIndex, + nextLeafIndex: transitionContext.nextLeafIndex, desiredLeaves: plan.desiredLeaves.size, immediateLeaves: plan.immediateLeaves.size, frontierLeaves: plan.frontierLeaves.size, farLeaves: plan.farLeaves.length, syncAddedLeaves, - queuedAddedLeaves, + queuedAddedLeaves: queueResult.addedLeaves, removedLeaves, pendingLeaves: semanticResidencyQueue.length, + planningMs, + mutationJsMs, desiredMinusMounted: semanticResidencyStats.desiredMinusMounted, mountedMinusDesired: semanticResidencyStats.mountedMinusDesired, }); @@ -570,17 +744,19 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) semanticResidencyQueueFrame = window.requestAnimationFrame(() => { semanticResidencyQueueFrame = null; if (generation !== semanticResidencyGeneration) return; - const addedLeaves = applySemanticResidencyQueue(performance.now(), residencyOptions.budget); + const queueResult = applySemanticResidencyQueue(performance.now(), residencyOptions.budget); updateSemanticResidencyStats(residencyOptions, { metadataAvailable: true, - queuedAddedLeaves: addedLeaves, + queuedAddedLeaves: queueResult.addedLeaves, syncAddedLeaves: 0, removedLeaves: 0, }); - if (addedLeaves > 0 || semanticResidencyQueue.length > 0) { + if (queueResult.addedLeaves > 0 || semanticResidencyQueue.length > 0) { markQuakeTrace("world-visibility", { reason: "semantic-residency-queue", - queuedAddedLeaves: addedLeaves, + queuedAddedLeaves: queueResult.addedLeaves, + queueAppliedLeaves: queueResult.appliedLeaves, + mutationJsMs: queueResult.mutationJsMs, pendingLeaves: semanticResidencyQueue.length, desiredMinusMounted: semanticResidencyStats.desiredMinusMounted, mountedMinusDesired: semanticResidencyStats.mountedMinusDesired, @@ -590,16 +766,118 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) }); }; - const applySemanticResidencyQueue = (now: number, budget: number): number => { + const applySemanticResidencyQueue = (now: number, budget: number): QuakeResidencyQueueApplyResult => { let addedLeaves = 0; let applied = 0; + const mutationStartedAt = performance.now(); while (applied < budget && semanticResidencyQueue.length > 0) { const leaf = semanticResidencyQueue.shift(); if (!leaf || !semanticResidencyDesiredLeaves.has(leaf) || leaf.mounted) continue; applied++; if (setQuakeLeafMounted(leaf, true, now) > 0) addedLeaves++; } - return addedLeaves; + return { + addedLeaves, + appliedLeaves: applied, + mutationJsMs: performance.now() - mutationStartedAt, + }; + }; + + const emptyResidencyQueueApplyResult = (): QuakeResidencyQueueApplyResult => ({ + addedLeaves: 0, + appliedLeaves: 0, + mutationJsMs: 0, + }); + + const countMountedQuakeLeaves = (): number => { + let mountedLeaves = 0; + for (const leaf of quakeLeaves) { + if (leaf.mounted && leaf.element.isConnected) mountedLeaves++; + } + return mountedLeaves; + }; + + const countUnmountedLeaves = (leaves: Iterable): number => { + let count = 0; + for (const leaf of leaves) { + if (!leaf.mounted) count++; + } + return count; + }; + + const faceLeavesEntryCount = (): number => { + let count = 0; + for (const leaves of faceLeaves.values()) count += leaves.length; + return count; + }; + + const recordResidencyTransition = (details: QuakeResidencyTransitionRecordDetails): void => { + const prevVisibleFaceGroupKey = quakeVisibleFaceKeyToken(details.prevVisibleFaceKey); + const nextVisibleFaceGroupKey = quakeVisibleFaceKeyToken(details.nextVisibleFaceKey); + const transitionKey = quakeResidencyTransitionKey( + details.prevLeafIndex, + details.nextLeafIndex, + prevVisibleFaceGroupKey, + nextVisibleFaceGroupKey, + ); + const planningMs = details.planningMs ?? 0; + const mutationJsMs = details.mutationJsMs ?? 0; + const addCount = details.addCount ?? 0; + const removeCount = details.removeCount ?? 0; + const deferCount = details.deferCount ?? 0; + const immediateAddCount = details.immediateAddCount ?? 0; + const frontierAddCount = details.frontierAddCount ?? 0; + const farAddCount = details.farAddCount ?? 0; + const mountedLeafPeak = Math.max(details.mountedLeafCountBefore, details.mountedLeafCountAfter); + const transition: QuakeWorldResidencyTransitionStats = { + prevLeafIndex: details.prevLeafIndex, + nextLeafIndex: details.nextLeafIndex, + prevVisibleFaceGroupKey, + nextVisibleFaceGroupKey, + transitionKey, + transitionCacheHit: false, + transitionCacheSize: 0, + planningMs, + mutationJsMs, + totalMs: performance.now() - details.startedAt, + scannedFaceLeafCount: details.scannedFaceLeafCount ?? 0, + visibleFaceCount: details.visibleFaceCount ?? null, + addCount, + removeCount, + deferCount, + immediateAddCount, + frontierAddCount, + farAddCount, + mountedLeafCountBefore: details.mountedLeafCountBefore, + mountedLeafCountAfter: details.mountedLeafCountAfter, + mountedLeafPeak, + residencyQueueImmediateSize: details.residencyQueueImmediateSize ?? immediateAddCount, + residencyQueueFrontierSize: details.residencyQueueFrontierSize ?? frontierAddCount, + residencyQueueFarSize: details.residencyQueueFarSize ?? deferCount, + }; + recordQuakeWorldResidencyTransition(visibilityChurn, transition); + markQuakeTrace("world-residency-transition", { + reason: details.reason, + force: details.force, + prevLeafIndex: details.prevLeafIndex, + nextLeafIndex: details.nextLeafIndex, + transitionKey, + cacheHit: false, + planningMs, + mutationJsMs, + totalMs: transition.totalMs, + scannedFaceLeafCount: transition.scannedFaceLeafCount, + visibleFaceCount: transition.visibleFaceCount, + addCount, + removeCount, + deferCount, + immediateAddCount, + frontierAddCount, + farAddCount, + mountedLeafCountBefore: transition.mountedLeafCountBefore, + mountedLeafCountAfter: transition.mountedLeafCountAfter, + mountedLeafPeak, + }); }; const cancelSemanticResidencyQueue = (): void => { @@ -1396,3 +1674,32 @@ function lightstyleKeyframes(frameCount: number): string { function faceSetKey(faces: Set): string { return [...faces].sort((a, b) => a - b).join(","); } + +function quakeVisibleFaceKeyToken(key: string | null): string | null { + if (!key) return null; + if (key.length <= 80) return key; + return `${key.length}:${quakeStringHash(key)}`; +} + +function quakeResidencyTransitionKey( + prevLeafIndex: number | null, + nextLeafIndex: number | null, + prevVisibleFaceGroupKey: string | null, + nextVisibleFaceGroupKey: string | null, +): string { + return [ + prevLeafIndex ?? "none", + nextLeafIndex ?? "none", + prevVisibleFaceGroupKey ?? "none", + nextVisibleFaceGroupKey ?? "none", + ].join("|"); +} + +function quakeStringHash(value: string): string { + let hash = 2166136261; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +}