131 lines
5.8 KiB
TypeScript
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);
|
|
}
|