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 { FORSAKEN_ABBEY_DEFINITION } from "../../src/game/dungeons/forsakenAbbey"; import { RUNEWAKER_DUNGEON_DEFINITIONS } from "../../src/game/generated/runewakerDungeonDefinitions.generated"; import { prepareDungeonMaterial } from "../../src/scene/productionDungeonMaterials"; import { applyDungeonMeshGraphics } from "../../src/scene/graphicsMaterialQuality"; import { repairHallOfSurvivorsVisual } from "../../src/scene/hallOfSurvivorsVisual"; import { withRunewakerDungeonEnvironment } from "../../src/game/runewakerDungeonEnvironment"; import type { DungeonDefinition } from "../../src/game/dungeonTypes"; import { configureRunewakerTerrainLoader } from "../../src/scene/runewakerTerrainMaterial"; import { configureRunewakerSurfaceDetailLoader } from "../../src/scene/runewakerSurfaceDetailMaterial"; import { configureRunewakerWaterLoader } from "../../src/scene/runewakerWaterMaterial"; import { resolveRunewakerNativeLighting } from "../../src/game/runewakerNativeLighting"; import { applyRunewakerNativeLighting } from "../../src/scene/runewakerNativeLightingRuntime"; const params = new URLSearchParams(location.search); const id = params.get("dungeon") ?? "forsaken-abbey"; const definitions: readonly DungeonDefinition[] = [FORSAKEN_ABBEY_DEFINITION, ...RUNEWAKER_DUNGEON_DEFINITIONS]; const original = definitions.find((entry) => entry.id === id); if (!original) throw new Error(`Unknown RoM dungeon ${id}`); const definition = params.has("before") ? original : withRunewakerDungeonEnvironment(original); 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); const ambient = new THREE.AmbientLight(p.ambientLight.color, p.ambientLight.intensity); const hemisphere = new THREE.HemisphereLight(p.hemisphereLight.skyColor, p.hemisphereLight.groundColor, p.hemisphereLight.intensity); scene.add(ambient, hemisphere); const light = new THREE.DirectionalLight(p.directionalLight.color, p.directionalLight.intensity); light.castShadow = true; light.shadow.mapSize.set(2048, 2048); Object.assign(light.shadow.camera, { left: -26, right: 26, top: 26, bottom: -26, near: 0.5, far: 90 }); light.shadow.bias = -0.00025; scene.add(light, light.target); const sourceAmbient = new THREE.AmbientLight(); const sourceDirections = [new THREE.DirectionalLight(), new THREE.DirectionalLight(), new THREE.DirectionalLight()]; const sourceLights = new THREE.Group(); sourceLights.add(sourceAmbient, ...sourceDirections); sourceLights.visible = false; sourceDirections[0].castShadow = true; sourceDirections[0].shadow.mapSize.set(2048, 2048); Object.assign(sourceDirections[0].shadow.camera, { left: -26, right: 26, top: 26, bottom: -26, near: 0.5, far: 90 }); sourceDirections[0].shadow.bias = -0.00025; scene.add(sourceLights, ...sourceDirections.map(source => source.target)); let sourceLightingEnabled = !params.has("before") && !params.has("profile-only"); let sourceLightingPoint = definition.entrance.footPosition; let resolvedSourceLighting: ReturnType = null; function updateSourceLighting() { resolvedSourceLighting = sourceLightingEnabled ? resolveRunewakerNativeLighting(definition, sourceLightingPoint, { cameraFarDistance: camera.far }) : null; sourceLights.visible = resolvedSourceLighting !== null; ambient.visible = hemisphere.visible = light.visible = resolvedSourceLighting === null; if (resolvedSourceLighting) applyRunewakerNativeLighting(scene, sourceAmbient, sourceDirections, resolvedSourceLighting); else { scene.background = new THREE.Color(p.fog.color); scene.fog = new THREE.Fog(p.fog.color, p.fog.near, p.fog.far); delete scene.userData.runewakerNativeLighting; } } 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(); configureRunewakerTerrainLoader(loader); configureRunewakerSurfaceDetailLoader(loader); configureRunewakerWaterLoader(loader); 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); for (const asset of definition.assets.visual) { const assetUrl = id === "the-origin" && params.get("stage") === "origin-water-cliffs" ? "/artifacts/playtest/rom-continuation/origin-full-ground/the-origin-full-native-water-cliffs-optimized.glb" : id === "the-origin" && params.get("stage") === "origin-full-native-water" ? "/artifacts/playtest/rom-continuation/origin-full-ground/the-origin-full-native-water-optimized.glb" : id === "the-origin" && params.get("stage") === "origin-full-ground" ? "/artifacts/playtest/rom-continuation/origin-full-ground/the-origin-full-native-optimized.glb" : params.get("stage") === "converted" ? `/runewaker-export-work/${id}/converted/${id}-visual.glb` : id === "sand-vortex" && params.get("stage") === "cliff-before" ? "/artifacts/rom-environment-quality-audit/sand-vortex-cliffs/before-cliff-projection.glb" : `/src/assets/game/dungeons/${id}/${asset.fileName}`; const gltf = await loader.loadAsync(assetUrl); 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); }); if (!params.has("unrepaired")) repairHallOfSurvivorsVisual(gltf.scene, id); applyDungeonMeshGraphics(gltf.scene, "environment", "high", 16, renderer.capabilities.getMaxAnisotropy()); root.add(gltf.scene); } 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 })), ]; function view(index: number, yaw = 0) { const entry = viewpoints[Math.min(index, viewpoints.length - 1)]; sourceLightingPoint = entry.position; updateSourceLighting(); 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(); document.querySelector("#status")!.textContent = `${definition.title} · ${entry.name} · ${params.has("before") ? "previous lighting" : "revised lighting"} · 16× filtering / High shadows`; } view(Number(params.get("view") ?? 0)); let frames = 0; let frameStart = performance.now(); renderer.setAnimationLoop(() => { controls.update(); light.target.position.copy(camera.position); if (resolvedSourceLighting) { sourceDirections.forEach((source, index) => { source.target.position.copy(camera.position); source.position.copy(camera.position).addScaledVector(new THREE.Vector3(...resolvedSourceLighting!.directionalLights[index].toLightDirection), 36); source.target.updateMatrixWorld(); }); } light.position.copy(camera.position).add(new THREE.Vector3(...p.directionalLight.offset)); renderer.render(scene, camera); frames += 1; }); function lighting(before: boolean) { sourceLightingEnabled = !before; updateSourceLighting(); const selected = before ? original!.presentation : definition.presentation; ambient.color.set(selected.ambientLight.color); ambient.intensity = selected.ambientLight.intensity; hemisphere.color.set(selected.hemisphereLight.skyColor); hemisphere.groundColor.set(selected.hemisphereLight.groundColor); hemisphere.intensity = selected.hemisphereLight.intensity; light.color.set(selected.directionalLight.color); light.intensity = selected.directionalLight.intensity; scene.background = new THREE.Color(selected.fog.color); scene.fog = new THREE.Fog(selected.fog.color, selected.fog.near, selected.fog.far); if (resolvedSourceLighting) applyRunewakerNativeLighting(scene, sourceAmbient, sourceDirections, resolvedSourceLighting); const status = document.querySelector("#status")!; status.textContent = status.textContent!.replace(/previous lighting|revised lighting/, before ? "previous lighting" : "revised lighting"); } function meshDiagnostics() { const meshes: unknown[] = []; root.updateMatrixWorld(true); root.traverse((node) => { if (!(node instanceof THREE.Mesh)) return; const bounds = new THREE.Box3().setFromObject(node); meshes.push({ name: node.name, material: Array.isArray(node.material) ? node.material.map((entry) => entry.name) : node.material.name, instanced: (node as THREE.InstancedMesh).isInstancedMesh ?? false, min: bounds.min.toArray(), max: bounds.max.toArray() }); }); return meshes; } function floorProbe() { root.updateMatrixWorld(true); const ray = new THREE.Raycaster(); const position = new THREE.Vector3(...definition.entrance.footPosition); const results: unknown[] = []; for (const forward of [0, 3, 8, 15, 25]) { const origin = position.clone().addScaledVector(new THREE.Vector3(...definition.entrance.forward), forward).add(new THREE.Vector3(0, 5, 0)); ray.set(origin, new THREE.Vector3(0, -1, 0)); const hit = ray.intersectObject(root, true)[0]; if (!hit?.face) continue; const mesh = hit.object as THREE.Mesh; const uv = mesh.geometry.getAttribute("uv"); const material = Array.isArray(mesh.material) ? mesh.material[hit.face.materialIndex] : mesh.material; const map = (material as THREE.MeshStandardMaterial).map; results.push({ forward, point: hit.point.toArray(), mesh: mesh.name, material: material.name, texture: map?.name, face: hit.face, uv: hit.uv?.toArray(), triangleUvs: uv ? [hit.face.a, hit.face.b, hit.face.c].map((i) => [uv.getX(i), uv.getY(i)]) : null }); } return results; } function resetFrameSample() { frames = 0; frameStart = performance.now(); } function frameSample() { const elapsedMs = performance.now() - frameStart; return { frames, elapsedMs, fps: elapsedMs > 0 ? frames * 1000 / elapsedMs : null }; } function inspectAt(position: [number, number, number], target: [number, number, number]) { sourceLightingPoint = target; updateSourceLighting(); camera.position.fromArray(position); controls.target.fromArray(target); controls.update(); camera.updateMatrixWorld(true); document.querySelector("#status")!.textContent = `${definition.title} · mapping hotspot review · revised lighting`; } function raycastAt(x: number, y: number, limit = 6) { // Diagnostic scans may change several poses between animation frames. // Raycaster must use the selected pose, not the preceding rendered matrix. camera.updateMatrixWorld(true); const ray = new THREE.Raycaster(); ray.setFromCamera(new THREE.Vector2(x / innerWidth * 2 - 1, 1 - y / innerHeight * 2), camera); return ray.intersectObject(root, true).slice(0, limit).map(hit => { const mesh = hit.object as THREE.Mesh; const material = Array.isArray(mesh.material) ? mesh.material[hit.face?.materialIndex ?? 0] : mesh.material; return { node: mesh.name, instanceId: hit.instanceId ?? null, material: material.name, texture: (material as THREE.MeshStandardMaterial).map?.name ?? null, nativeSourceModel: material.userData.nativeSourceModel ?? null, nativeMaterialIndex: material.userData.nativeMaterialIndex ?? null, alphaTest: material.alphaTest, transparent: material.transparent, point: hit.point.toArray(), uv: hit.uv?.toArray() ?? null, distance: hit.distance }; }); } function waterDiagnostics() { const water: unknown[] = []; root.traverse(node => { if (!(node instanceof THREE.Mesh)) return; for (const material of Array.isArray(node.material) ? node.material : [node.material]) { const uniforms = (material as THREE.Material & { uniforms?: Record }).uniforms; const frame = uniforms?.romWaterNormalFrame?.value; if (!(frame instanceof THREE.Texture)) continue; const bounds = new THREE.Box3().setFromObject(node); water.push({ node: node.name, min: bounds.min.toArray(), max: bounds.max.toArray(), elapsed: uniforms?.romWaterElapsedSeconds.value, frame: frame.name, uuid: frame.uuid, fullModelEnabled: material.userData.runewakerWater?.fullModelEnabled ?? false, sourceSplitPlaneHeight: node.userData.runewakerWaterSourceSplitPlaneHeight ?? null, screenEffectsReady: uniforms?.romWaterScreenEffectsReady?.value ?? 0, reflection: (uniforms?.romWaterReflection?.value as THREE.Texture | null)?.uuid ?? null, refraction: (uniforms?.romWaterRefraction?.value as THREE.Texture | null)?.uuid ?? null }); } }); return water; } Object.assign(window, { romEnvironmentReview: { ready: true, id, viewpoints, view, inspectAt, raycastAt, lighting, floorProbe, meshDiagnostics, waterDiagnostics, resetFrameSample, frameSample, presentation: p, sourceLighting: () => resolvedSourceLighting, enableSourceLighting: (enabled: boolean) => { sourceLightingEnabled = enabled; updateSourceLighting(); }, drawCalls: () => renderer.info.render.calls } });