Files
healer-man/src/scene/hallOfSurvivorsVisual.ts
T
2026-09-27 18:46:30 -04:00

131 lines
5.8 KiB
TypeScript

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 <common>", "#include <common>\nvarying vec3 vHallWorldPosition;\nvarying vec3 vHallWorldNormal;")
.replace("#include <worldpos_vertex>", `#include <worldpos_vertex>
vHallWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
vHallWorldNormal = normalize(mat3(modelMatrix) * normal);`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", "#include <common>\nvarying vec3 vHallWorldPosition;\nvarying vec3 vHallWorldNormal;")
.replace("#include <map_fragment>", `#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);
}