Files
healer-man/scripts/dungeon-pipeline/environment-preview.ts
T
2026-10-07 13:05:04 -04:00

215 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import compressedCampaign from "../../src/game/generated/dungeonCampaignCatalog.compressed";
import { gunzipSync, strFromU8 } from "fflate";
import { WAILING_CAVERNS_DEFINITION } from "../../src/game/dungeons/wailingCaverns";
import { createBiteSizedStockadesDefinition } from "../../src/game/dungeons/biteSizedStockades";
import { withWowDungeonEnvironment } from "../../src/game/wowDungeonEnvironment";
import type { DungeonDefinition } from "../../src/game/dungeonTypes";
import { resolveDungeonAssets } from "../../src/game/dungeonAssets";
import { prepareDungeonMaterial } from "../../src/scene/productionDungeonMaterials";
import { applyDungeonMeshGraphics } from "../../src/scene/graphicsMaterialQuality";
import { setDungeonShadowLightPose } from "../../src/scene/dungeonShadowLightRuntime";
import type { ShadowQuality } from "../../src/game/graphicsSettings";
import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "../../src/scene/staticSceneInstancing";
import { createStaticVisibilityCells, registerStaticVisibility } from "../../src/scene/staticSceneVisibility";
import { materialTextures } from "../../src/scene/threeResourceDisposal";
import { configureWowTerrainLoader } from "../../src/scene/wowTerrainMaterial";
// Isolated asset QA uses shipping assets and the production material/light helpers.
// It intentionally does not claim gameplay, collision or encounter certification.
const params = new URLSearchParams(location.search);
const id = params.get("dungeon") ?? "bite-sized-stormwind-stockade";
// Only environment data is needed. Avoid importing combat/encounter adapters
// into this isolated renderer; the registry uses this same environment adapter.
const campaign = JSON.parse(strFromU8(gunzipSync(Uint8Array.from(atob(compressedCampaign), (char) => char.charCodeAt(0))))) as { definitions: DungeonDefinition[] };
const definitions = campaign.definitions.filter((entry) => entry.expansion !== "runewaker")
.map((entry) => withWowDungeonEnvironment(entry.id === "wailing-caverns" ? WAILING_CAVERNS_DEFINITION : entry));
const stockade = definitions.find((entry) => entry.id === "stormwind-stockade")!;
definitions.push(createBiteSizedStockadesDefinition(stockade, "wow-environment-review-2026-10-06"));
function findDefinition(): DungeonDefinition {
const entry = definitions.find((candidate) => candidate.id === id);
if (!entry) throw new Error(`Unknown WoW dungeon: ${id}`);
return entry;
}
const definition = findDefinition();
if (definition.expansion === "runewaker") throw new Error(`Expected WoW dungeon: ${id}`);
const p = definition.presentation;
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(1);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(p.fog.color);
scene.fog = new THREE.Fog(p.fog.color, p.fog.near, p.fog.far);
scene.add(new THREE.AmbientLight(p.ambientLight.color, p.ambientLight.intensity));
scene.add(new THREE.HemisphereLight(p.hemisphereLight.skyColor, p.hemisphereLight.groundColor, p.hemisphereLight.intensity));
const light = new THREE.DirectionalLight(p.directionalLight.color, p.directionalLight.intensity);
light.shadow.bias = -0.00025;
scene.add(light, light.target);
for (const entry of p.pointLights ?? []) {
const point = new THREE.PointLight(entry.color, entry.intensity, entry.distance, 2);
point.position.fromArray(entry.position);
scene.add(point);
}
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2000);
const controls = new OrbitControls(camera, renderer.domElement);
const loader = new GLTFLoader();
const decoder = new KTX2Loader().setTranscoderPath("/basis/").detectSupport(renderer);
loader.setKTX2Loader(decoder).setMeshoptDecoder(MeshoptDecoder);
configureWowTerrainLoader(loader);
const root = new THREE.Group();
root.position.fromArray(definition.transform.position);
root.rotation.fromArray([...definition.transform.rotation, "XYZ"]);
root.scale.setScalar(definition.transform.scale);
scene.add(root);
const assets = resolveDungeonAssets(definition);
if (assets.visual.length !== definition.assets.visual.length) throw new Error(`Missing shipping visual for ${id}`);
const visualOverride = params.get("visualOverride");
const runtimePreparation = params.get("runtimePreparation") === "1";
const preparationReports: { batches: number; instances: number; removedMeshes: number; visibilityCells: number }[] = [];
if (visualOverride && (assets.visual.length !== 1
|| !/^\/artifacts\/dungeon-pipeline\/[a-zA-Z0-9_./-]+\.glb$/.test(visualOverride)
|| visualOverride.includes(".."))) {
throw new Error("A review override must be a single GLB in artifacts/dungeon-pipeline.");
}
for (const url of assets.visual) {
const reviewUrl = visualOverride ?? (id === "shadowfang-keep" && params.get("stage") === "before-shadowfang"
? "/artifacts/playtest/wow-environment-quality/before-shadowfang/visual.glb"
: id === "shadowfang-keep" && params.get("stage") === "repacked-shadowfang"
? "/artifacts/dungeon-pipeline/shadowfang-keep-visual.reoptimized-repacked.glb"
: id === "shadowfang-keep" && params.get("stage") === "selective-shadowfang"
? "/artifacts/dungeon-pipeline/shadowfang-keep-selective-error-0012.glb" : url);
const gltf = await loader.loadAsync(reviewUrl);
const preparedMaterials = new Map<string, THREE.Material>();
const prepare = (material: THREE.Material) => {
if (runtimePreparation && preparedMaterials.has(material.uuid)) return preparedMaterials.get(material.uuid)!;
const next = prepareDungeonMaterial(material, p.materials, definition.environmentMode);
preparedMaterials.set(material.uuid, next);
return next;
};
gltf.scene.traverse((node) => {
if (!(node instanceof THREE.Mesh)) return;
node.material = Array.isArray(node.material)
? node.material.map(prepare) : prepare(node.material);
if (runtimePreparation) {
node.castShadow = false;
node.receiveShadow = true;
}
if (runtimePreparation && node instanceof THREE.InstancedMesh) {
node.computeBoundingBox();
node.computeBoundingSphere();
}
});
if (runtimePreparation) {
const instancing = instanceRepeatedStaticMeshes(gltf.scene, 3, undefined, definition.id === "scholomance");
const cells = createStaticVisibilityCells(gltf.scene);
cacheStaticMatrices(gltf.scene);
registerStaticVisibility(scene, cells);
preparationReports.push({ ...instancing, visibilityCells: cells.length });
}
root.add(gltf.scene);
}
let quality: ShadowQuality = "standard";
function setQuality(value: ShadowQuality) {
quality = value;
const extent = quality === "high" ? 26 : 5;
light.castShadow = quality !== "off";
light.shadow.map?.dispose();
light.shadow.map = null;
light.shadow.mapSize.set(quality === "high" ? 2048 : 1024, quality === "high" ? 2048 : 1024);
Object.assign(light.shadow.camera, { left: -extent, right: extent, top: extent, bottom: -extent, near: 0.5, far: 90 });
light.shadow.camera.updateProjectionMatrix();
applyDungeonMeshGraphics(root, "environment", quality, quality === "high" ? 16 : 1, renderer.capabilities.getMaxAnisotropy());
setDungeonShadowLightPose(light, new THREE.Vector3(...definition.entrance.footPosition), new THREE.Vector3(...p.directionalLight.offset));
updateStatus();
}
const viewpoints = [
{ name: "entrance", position: definition.entrance.footPosition, forward: definition.entrance.forward },
...definition.bosses.map((boss) => ({ name: boss.name, position: boss.position, forward: definition.entrance.forward })),
];
let selectedView = 0;
function updateStatus() {
document.querySelector("#status")!.textContent = `${definition.title} · ${viewpoints[selectedView].name} · ${quality} shadows / ${quality === "high" ? "16×" : "1×"} filtering`;
}
function view(index: number, yaw = 0) {
selectedView = Math.max(0, Math.min(index, viewpoints.length - 1));
const entry = viewpoints[selectedView];
const eye = new THREE.Vector3(...entry.position).add(new THREE.Vector3(0, 2.2, 0));
const forward = new THREE.Vector3(...entry.forward).normalize().applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw);
camera.position.copy(eye);
controls.target.copy(eye).add(forward.multiplyScalar(8)).add(new THREE.Vector3(0, -1.2, 0));
controls.update();
updateStatus();
}
function diagnostics() {
let meshes = 0, casters = 0;
const textures = new Map<string, { name: string; width: number; height: number; anisotropy: number }>();
root.traverse((node) => {
if (!(node instanceof THREE.Mesh)) return;
meshes++;
if (node.castShadow) casters++;
for (const material of Array.isArray(node.material) ? node.material : [node.material]) {
for (const texture of materialTextures(material)) {
textures.set(texture.uuid, { name: texture.name, width: texture.image?.width,
height: texture.image?.height, anisotropy: texture.anisotropy });
}
}
});
return { meshes, casters, textures: [...textures.values()], runtimePreparation, preparationReports, drawCalls: renderer.info.render.calls,
renderedTriangles: renderer.info.render.triangles, geometries: renderer.info.memory.geometries,
lightTarget: light.target.position.toArray(), camera: camera.position.toArray(), maxAnisotropy: renderer.capabilities.getMaxAnisotropy() };
}
function setCamera(eye: [number, number, number], target: [number, number, number]) {
camera.position.fromArray(eye);
controls.target.fromArray(target);
controls.update();
}
function groundSamples() {
root.updateMatrixWorld(true);
const ray = new THREE.Raycaster();
return viewpoints.map((entry) => {
const origin = new THREE.Vector3(...entry.position).add(new THREE.Vector3(0, 20, 0));
ray.set(origin, new THREE.Vector3(0, -1, 0));
ray.far = 80;
const seen = new Set<string>();
const hits = ray.intersectObject(root, true).flatMap((hit) => {
const mesh = hit.object as THREE.Mesh;
const material = (Array.isArray(mesh.material)
? mesh.material[hit.face?.materialIndex ?? 0] : mesh.material) as THREE.MeshStandardMaterial;
const key = `${mesh.uuid}:${hit.instanceId}:${Math.round(hit.point.y * 1000)}`;
if (seen.has(key)) return [];
seen.add(key);
return [{ mesh: mesh.name, material: material.name, textured: materialTextures(material).length > 0,
height: hit.point.y, heightAboveFoot: hit.point.y - entry.position[1], point: hit.point.toArray() }];
}).slice(0, 8);
return { name: entry.name, position: entry.position, hits };
});
}
view(0);
setQuality("standard");
let frames = 0;
let frameStart = performance.now();
function resetFrameSample() {
frames = 0;
frameStart = performance.now();
}
function frameSample() {
const elapsedMs = performance.now() - frameStart;
return { frames, elapsedMs, fps: elapsedMs > 0 ? frames * 1000 / elapsedMs : null };
}
renderer.setAnimationLoop(() => {
controls.update();
if (quality === "high") setDungeonShadowLightPose(light, camera.position, new THREE.Vector3(...p.directionalLight.offset));
renderer.render(scene, camera);
frames++;
});
Object.assign(window, { wowEnvironmentReview: { ready: true, id, viewpoints, view, setCamera, setQuality, diagnostics, groundSamples, resetFrameSample, frameSample, presentation: p,
catalog: definitions.filter((entry) => !entry.id.includes("bite-sized")).map((entry) => ({ id: entry.id, title: entry.title })) } });