import { BufferAttribute, Mesh, MeshStandardMaterial, Object3D, Uint32BufferAttribute, Vector3, } from "three"; const HALL_SLICE_ID = "bite-sized-hall-of-survivors-pursuer"; const WALL_ATLAS_MATERIAL = "runewaker_material_60"; const CEILING_ATLAS_MATERIAL = "runewaker_material_44"; const LIGHT_BEAM_MATERIAL = "runewaker_material_154"; const a = new Vector3(); const b = new Vector3(); const c = new Vector3(); const ab = new Vector3(); const ac = new Vector3(); const center = new Vector3(); function readWorldVertex(mesh: Mesh, positions: BufferAttribute, index: number, into: Vector3): Vector3 { return into.set(positions.getX(index), positions.getY(index), positions.getZ(index)) .applyMatrix4(mesh.matrixWorld); } function isCeilingGlare(): boolean { // Pale decorative canopy faces hang down over the twin-statue sight line. // This is off the playable balcony and stair route at z ~ 40-55. return center.x > -190 && center.x < -150 && center.z > 10 && center.z < 28 && center.y > -10 && center.y < 3; } function needsStoneProjection(area: number, normalY: number): boolean { if (center.x < -231 || center.x > -120 || center.z < 0 || center.z > 70 || center.y < -19 || center.y > 10) return false; const horizontal = Math.abs(normalY) > 0.78; const wall = Math.abs(normalY) < 0.25; // Large atlas polygons have too few texels for their world-space size and // produce the long, muddy bands visible on the floor and surrounding walls. return (horizontal && area > 2 && (center.y < -16 || center.y > -1)) || (wall && area > 2); } function stoneProjectionMaterial(source: MeshStandardMaterial): MeshStandardMaterial { const material = source.clone(); material.name = `${source.name}-hall-stone-projection`; material.onBeforeCompile = (shader) => { shader.vertexShader = shader.vertexShader .replace("#include ", "#include \nvarying vec3 vHallWorldPosition;\nvarying vec3 vHallWorldNormal;") .replace("#include ", `#include vHallWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz; vHallWorldNormal = normalize(mat3(modelMatrix) * normal);`); shader.fragmentShader = shader.fragmentShader .replace("#include ", "#include \nvarying vec3 vHallWorldPosition;\nvarying vec3 vHallWorldNormal;") .replace("#include ", `#ifdef USE_MAP vec3 hallNormal = abs(normalize(vHallWorldNormal)); vec2 hallWallPlane = hallNormal.x > hallNormal.z ? vec2(vHallWorldPosition.z, vHallWorldPosition.y) : vec2(vHallWorldPosition.x, vHallWorldPosition.y); vec2 hallWallRepeat = 1.0 - abs(fract(hallWallPlane / vec2(9.0, 7.0)) * 2.0 - 1.0); vec2 hallFloorRepeat = 1.0 - abs(fract(vHallWorldPosition.xz / 9.0) * 2.0 - 1.0); // Both patches come from the existing Hall atlas: stone blocks for // walls and worn square slabs for the walking surfaces. vec2 hallWallUv = vec2(0.46, 0.65) + hallWallRepeat * vec2(0.42, 0.32); vec2 hallFloorUv = vec2(0.47, 0.41) + hallFloorRepeat * vec2(0.415, 0.20); vec2 hallUv = hallNormal.y > 0.78 ? hallFloorUv : hallWallUv; diffuseColor *= texture2D(map, hallUv); #endif`); }; material.customProgramCacheKey = () => "hall-stone-projection-v1"; material.needsUpdate = true; return material; } /** Repairs only the three-pack Hall route, leaving the full source dungeon alone. */ export function repairHallOfSurvivorsVisual(root: Object3D, dungeonId: string): void { if (dungeonId !== HALL_SLICE_ID) return; root.updateMatrixWorld(true); const overlays: { source: Mesh; repair: Mesh }[] = []; root.traverse((node) => { const mesh = node as Mesh; if (!mesh.isMesh || !mesh.geometry.index || Array.isArray(mesh.material)) return; const name = mesh.material.name; if (name !== WALL_ATLAS_MATERIAL && name !== CEILING_ATLAS_MATERIAL && name !== LIGHT_BEAM_MATERIAL) return; if (name === LIGHT_BEAM_MATERIAL) { // This imported fake volumetric light is a large opaque-looking white // mesh from the statue room. Lighting comes from separate actual lights. mesh.visible = false; return; } const positions = mesh.geometry.getAttribute("position") as BufferAttribute | undefined; if (!positions) return; const indices = mesh.geometry.index; const kept: number[] = []; const projected: number[] = []; for (let offset = 0; offset < indices.count; offset += 3) { const ia = indices.getX(offset); const ib = indices.getX(offset + 1); const ic = indices.getX(offset + 2); readWorldVertex(mesh, positions, ia, a); readWorldVertex(mesh, positions, ib, b); readWorldVertex(mesh, positions, ic, c); center.copy(a).add(b).add(c).divideScalar(3); ab.subVectors(b, a); ac.subVectors(c, a); const cross = ab.cross(ac); const twiceArea = cross.length(); if (name === CEILING_ATLAS_MATERIAL && isCeilingGlare()) continue; const destination = name === WALL_ATLAS_MATERIAL && needsStoneProjection(twiceArea / 2, twiceArea > 0 ? cross.y / twiceArea : 0) ? projected : kept; destination.push(ia, ib, ic); } if (kept.length === indices.count) return; if (projected.length) { const repair = mesh.clone(false); repair.geometry = mesh.geometry.clone(); repair.geometry.setIndex(new Uint32BufferAttribute(projected, 1)); repair.material = stoneProjectionMaterial(mesh.material as MeshStandardMaterial); repair.name = `${mesh.name}-stone-repair`; overlays.push({ source: mesh, repair }); } mesh.geometry = mesh.geometry.clone(); mesh.geometry.setIndex(new Uint32BufferAttribute(kept, 1)); }); for (const { source, repair } of overlays) source.parent?.add(repair); }