Files
healer-man/scripts/dungeon-pipeline/environment-preview.ts
T
2026-10-06 22:25:34 -04:00

153 lines
8.7 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";
// 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);
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}`);
for (const url of assets.visual) {
const reviewUrl = 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);
gltf.scene.traverse((node) => {
if (!(node instanceof THREE.Mesh)) return;
node.material = Array.isArray(node.material)
? node.material.map((material) => prepareDungeonMaterial(material, p.materials, definition.environmentMode))
: prepareDungeonMaterial(node.material, p.materials, definition.environmentMode);
});
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]) {
const map = (material as THREE.MeshStandardMaterial).map;
if (map) textures.set(map.uuid, { name: map.name, width: map.image?.width, height: map.image?.height, anisotropy: map.anisotropy });
}
});
return { meshes, casters, textures: [...textures.values()], 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() };
}
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, setQuality, diagnostics, resetFrameSample, frameSample, presentation: p,
catalog: definitions.filter((entry) => !entry.id.includes("bite-sized")).map((entry) => ({ id: entry.id, title: entry.title })) } });