Files
2026-10-06 22:25:34 -04:00

250 lines
14 KiB
TypeScript
Raw Permalink 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 { 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<typeof resolveRunewakerNativeLighting> = 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<string, { value: unknown }> }).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 } });