153 lines
8.7 KiB
TypeScript
153 lines
8.7 KiB
TypeScript
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 })) } });
|