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(); 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(); 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(); 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 })) } });