Files
healer-man/src/game/runewakerNativeLighting.ts
2026-10-06 22:25:34 -04:00

472 lines
18 KiB
TypeScript

import type { DungeonDefinition, DungeonTransform, Vector3Tuple } from "./dungeonTypes";
import { RUNEWAKER_NATIVE_LIGHTING_SOURCE } from "./generated/runewakerNativeLighting.generated";
type Vector = readonly [number, number, number];
type Quaternion = readonly [number, number, number, number];
interface NativeColor {
readonly argb: string;
/** Rune Engine's direct ARGB byte-to-float conversion; values are linear. */
readonly rgba: readonly [number, number, number, number];
}
interface NativeZoneSettings {
readonly timeGroup: number;
readonly weatherGroup: number;
readonly primary: NativeColor;
readonly secondary: NativeColor;
readonly ambient: NativeColor;
readonly specular: NativeColor;
readonly fogColor: NativeColor;
readonly fog: { readonly constant: number; readonly start: number; readonly end: number };
readonly standardAnglesRadians: Vector;
readonly specularAnglesRadians: Vector;
readonly nativeDirectionsLeftHandedYUp: {
readonly primary: Vector;
readonly secondary0: Vector;
readonly secondary1: Vector;
readonly specular: Vector;
};
readonly weatherSystem: string;
readonly skyGradientTexture: string;
readonly customSkyBox: string;
readonly cloudCover: number;
readonly cloudSharpness: number;
readonly cloudColors: readonly [NativeColor, NativeColor];
}
interface NativeZone {
readonly id: number;
readonly parentId: number;
readonly name: string;
readonly zoneType: number;
readonly settings: readonly NativeZoneSettings[];
}
interface NativeZoneTrigger {
readonly triggerType: number;
readonly zoneId: number;
readonly zoneType: number;
readonly priority: number;
readonly obb: {
readonly center: Vector;
readonly axes: readonly [Vector, Vector, Vector];
readonly halfExtents: Vector;
};
}
interface NativeDungeonLightingData {
readonly source: {
readonly wdb: string;
readonly wdbSha256: string;
readonly coordinateSystem: string;
readonly metersPerSourceUnit: number;
readonly primaryPlacement: { readonly scale: Vector; readonly rotation: Quaternion; readonly translation: Vector };
};
readonly zones: readonly NativeZone[];
readonly zoneTriggers: readonly NativeZoneTrigger[];
}
const SOURCE = RUNEWAKER_NATIVE_LIGHTING_SOURCE as unknown as {
readonly runtimeDefaults: { readonly timeGroup: number; readonly weatherGroup: number };
readonly dungeons: Readonly<Record<string, NativeDungeonLightingData>>;
};
export interface RunewakerLightingOptions {
readonly timeGroup?: number;
readonly weatherGroup?: number;
/** Three.js world-space camera far distance, in runtime units/meters. */
readonly cameraFarDistance?: number;
}
export interface ResolvedRunewakerDirectionalLight {
readonly role: "primary" | "secondary-0" | "secondary-1";
readonly colorArgb: string;
readonly colorLinear: Vector;
readonly intensity: number;
/** Adapted source `m_direction`, which points in the light-ray direction. */
readonly rayDirection: Vector;
/** Opposite of Rune's stored direction; Rune's SH code uses this convention. */
readonly toLightDirection: Vector;
}
export interface ResolvedRunewakerLighting {
readonly dungeonId: string;
readonly sourceWdb: string;
readonly sourceWdbSha256: string;
readonly sourceCoordinateSystem: string;
readonly metersPerSourceUnit: number;
readonly sourcePosition: Vector;
readonly triggerZone: {
readonly id: number;
readonly name: string;
readonly priority: number;
readonly verticalHitDistanceSourceUnits: number;
};
readonly settingsZone: { readonly id: number; readonly name: string };
readonly timeGroup: number;
readonly weatherGroup: number;
readonly ambient: { readonly colorArgb: string; readonly colorLinear: Vector; readonly intensity: 1 };
readonly directionalLights: readonly [
ResolvedRunewakerDirectionalLight,
ResolvedRunewakerDirectionalLight,
ResolvedRunewakerDirectionalLight,
];
readonly specular: { readonly colorArgb: string; readonly colorLinear: Vector; readonly direction: Vector };
readonly fog: {
readonly colorArgb: string;
readonly colorLinear: Vector;
readonly constant: number;
readonly startFactor: number;
readonly endFactor: number;
readonly startDistance?: number;
readonly endDistance?: number;
};
readonly sky: {
readonly weatherSystem: string;
readonly skyGradientTexture: string;
readonly customSkyBox: string;
readonly cloudCover: number;
readonly cloudSharpness: number;
readonly cloudColorsLinear: readonly [Vector, Vector];
};
}
interface ZoneSelection {
readonly trigger: NativeZoneTrigger;
readonly boundaryDistance: number;
readonly triggerZone: NativeZone;
readonly settingsZone: NativeZone;
readonly settings: NativeZoneSettings;
readonly timeGroup: number;
readonly weatherGroup: number;
}
function add(a: Vector, b: Vector): Vector {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}
function subtract(a: Vector, b: Vector): Vector {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function multiply(a: Vector, b: Vector): Vector {
return [a[0] * b[0], a[1] * b[1], a[2] * b[2]];
}
function divide(a: Vector, b: Vector): Vector | null {
if (b.some((component) => Math.abs(component) < 1e-12)) return null;
return [a[0] / b[0], a[1] / b[1], a[2] / b[2]];
}
function dot(a: Vector, b: Vector): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function normalize(value: Vector): Vector {
const length = Math.hypot(value[0], value[1], value[2]);
if (length < 1e-12) return [0, 0, 0];
return [value[0] / length, value[1] / length, value[2] / length];
}
function conjugate(q: Quaternion): Quaternion {
return [-q[0], -q[1], -q[2], q[3]];
}
function multiplyQuaternions(a: Quaternion, b: Quaternion): Quaternion {
const [ax, ay, az, aw] = a;
const [bx, by, bz, bw] = b;
return [
ax * bw + aw * bx + ay * bz - az * by,
ay * bw + aw * by + az * bx - ax * bz,
az * bw + aw * bz + ax * by - ay * bx,
aw * bw - ax * bx - ay * by - az * bz,
];
}
function rotateByQuaternion(value: Vector, quaternion: Quaternion): Vector {
const length = Math.hypot(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
if (length < 1e-12) return value;
const normalized: Quaternion = quaternion.map((component) => component / length) as unknown as Quaternion;
const qv: Quaternion = [value[0], value[1], value[2], 0];
const result = multiplyQuaternions(multiplyQuaternions(normalized, qv), conjugate(normalized));
return [result[0], result[1], result[2]];
}
function quaternionFromEulerXYZ(rotation: Vector3Tuple): Quaternion {
const [x, y, z] = rotation;
const c1 = Math.cos(x / 2);
const c2 = Math.cos(y / 2);
const c3 = Math.cos(z / 2);
const s1 = Math.sin(x / 2);
const s2 = Math.sin(y / 2);
const s3 = Math.sin(z / 2);
return [
s1 * c2 * c3 + c1 * s2 * s3,
c1 * s2 * c3 - s1 * c2 * s3,
c1 * c2 * s3 + s1 * s2 * c3,
c1 * c2 * c3 - s1 * s2 * s3,
];
}
function pointFromRuntimeWorldToDungeonLocal(transform: DungeonTransform, point: Vector): Vector | null {
if (Math.abs(transform.scale) < 1e-12) return null;
const inverseRotation = conjugate(quaternionFromEulerXYZ(transform.rotation));
return divide(rotateByQuaternion(subtract(point, transform.position), inverseRotation), [transform.scale, transform.scale, transform.scale]);
}
function pointFromDungeonLocalToSource(data: NativeDungeonLightingData, point: Vector): Vector | null {
const sourceLocal = divide([point[0], point[1], -point[2]], [
data.source.metersPerSourceUnit,
data.source.metersPerSourceUnit,
data.source.metersPerSourceUnit,
]);
if (!sourceLocal) return null;
const scaled = multiply(sourceLocal, data.source.primaryPlacement.scale);
return add(rotateByQuaternion(scaled, data.source.primaryPlacement.rotation), data.source.primaryPlacement.translation);
}
function sourceDirectionToRuntime(data: NativeDungeonLightingData, transform: DungeonTransform, direction: Vector): Vector | null {
const placementLocal = rotateByQuaternion(direction, conjugate(data.source.primaryPlacement.rotation));
const unscaled = divide(placementLocal, data.source.primaryPlacement.scale);
if (!unscaled) return null;
const localThree: Vector = [unscaled[0], unscaled[1], -unscaled[2]];
const runtimeDirection = rotateByQuaternion(
[localThree[0] * transform.scale, localThree[1] * transform.scale, localThree[2] * transform.scale],
quaternionFromEulerXYZ(transform.rotation),
);
return normalize(runtimeDirection);
}
function segmentObbEntry(point: Vector, delta: Vector, trigger: NativeZoneTrigger): number | null {
let entry = 0;
let exit = 1;
const relative = subtract(point, trigger.obb.center);
for (let axisIndex = 0; axisIndex < 3; axisIndex += 1) {
const axis = trigger.obb.axes[axisIndex];
const originProjection = dot(relative, axis);
const deltaProjection = dot(delta, axis);
const extent = trigger.obb.halfExtents[axisIndex];
if (Math.abs(deltaProjection) < 1e-12) {
if (originProjection < -extent || originProjection > extent) return null;
continue;
}
let t0 = (-extent - originProjection) / deltaProjection;
let t1 = (extent - originProjection) / deltaProjection;
if (t0 > t1) [t0, t1] = [t1, t0];
entry = Math.max(entry, t0);
exit = Math.min(exit, t1);
if (entry > exit) return null;
}
return entry;
}
function verticalTriggerDistance(point: Vector, trigger: NativeZoneTrigger): number | null {
const down = segmentObbEntry(point, [0, -10000, 0], trigger);
const up = segmentObbEntry(point, [0, 10000, 0], trigger);
if (down == null) return up;
if (up == null) return down;
return Math.min(down, up);
}
function isDescendant(zones: readonly NativeZone[], candidateId: number, ancestorId: number): boolean {
const byId = new Map(zones.map((zone) => [zone.id, zone]));
let zone = byId.get(candidateId);
while (zone && zone.parentId !== -1) {
if (zone.parentId === ancestorId) return true;
zone = byId.get(zone.parentId);
}
return false;
}
function selectSettings(zone: NativeZone, timeGroup: number, weatherGroup: number): { settings: NativeZoneSettings; timeGroup: number; weatherGroup: number } | null {
const exact = zone.settings.find((entry) => entry.timeGroup === timeGroup && entry.weatherGroup === weatherGroup);
if (exact) return { settings: exact, timeGroup: exact.timeGroup, weatherGroup: exact.weatherGroup };
const sameTime = zone.settings.find((entry) => entry.timeGroup === timeGroup);
if (sameTime) return { settings: sameTime, timeGroup: sameTime.timeGroup, weatherGroup: sameTime.weatherGroup };
const any = zone.settings[0];
return any ? { settings: any, timeGroup: any.timeGroup, weatherGroup: any.weatherGroup } : null;
}
function selectZoneSettings(
data: NativeDungeonLightingData,
sourcePosition: Vector,
requestedTimeGroup: number,
requestedWeatherGroup: number,
): ZoneSelection | null {
let selectedTrigger: NativeZoneTrigger | null = null;
let selectedDistance = Number.POSITIVE_INFINITY;
// The native selector initializes its signed priority to 0xFFFFFFFF (-1).
let selectedPriority = -1;
for (const trigger of data.zoneTriggers) {
const distance = verticalTriggerDistance(sourcePosition, trigger);
if (distance == null) continue;
const prefer = trigger.priority > selectedPriority
|| (trigger.priority === selectedPriority && distance < selectedDistance)
|| (trigger.priority === selectedPriority
&& distance === selectedDistance
&& selectedTrigger != null
&& isDescendant(data.zones, trigger.zoneId, selectedTrigger.zoneId));
if (prefer) {
selectedTrigger = trigger;
selectedDistance = distance;
selectedPriority = trigger.priority;
}
}
if (!selectedTrigger) return null;
const triggerZone = data.zones.find((zone) => zone.id === selectedTrigger!.zoneId);
if (!triggerZone) return null;
let settingsZone: NativeZone | undefined = triggerZone;
let chosen: ReturnType<typeof selectSettings> = null;
while (settingsZone) {
chosen = selectSettings(settingsZone, requestedTimeGroup, requestedWeatherGroup);
if (chosen) break;
settingsZone = data.zones.find((zone) => zone.id === settingsZone!.parentId);
}
if (!settingsZone || !chosen) return null;
return {
trigger: selectedTrigger,
boundaryDistance: selectedDistance,
triggerZone,
settingsZone,
settings: chosen.settings,
timeGroup: chosen.timeGroup,
weatherGroup: chosen.weatherGroup,
};
}
/** Convert a Three.js world point back to the source WDB world coordinates. */
export function runewakerRuntimePositionToSource(
definition: Pick<DungeonDefinition, "id" | "transform">,
runtimeWorldPosition: Vector3Tuple,
): Vector | null {
const data = SOURCE.dungeons[definition.id];
if (!data) return null;
const local = pointFromRuntimeWorldToDungeonLocal(definition.transform, runtimeWorldPosition);
return local ? pointFromDungeonLocalToSource(data, local) : null;
}
/** Convert a source WDB point to the current dungeon's Three.js world space. */
export function runewakerSourcePositionToRuntime(
definition: Pick<DungeonDefinition, "id" | "transform">,
sourcePosition: Vector3Tuple,
): Vector | null {
const data = SOURCE.dungeons[definition.id];
if (!data) return null;
const sourceDelta = subtract(sourcePosition, data.source.primaryPlacement.translation);
const inversePrimaryRotation = conjugate(data.source.primaryPlacement.rotation);
const localSource = rotateByQuaternion(sourceDelta, inversePrimaryRotation);
const scaledLocal = divide(localSource, data.source.primaryPlacement.scale);
if (!scaledLocal) return null;
const localThree: Vector = [scaledLocal[0] * data.source.metersPerSourceUnit, scaledLocal[1] * data.source.metersPerSourceUnit, -scaledLocal[2] * data.source.metersPerSourceUnit];
return add(
rotateByQuaternion(
[localThree[0] * definition.transform.scale, localThree[1] * definition.transform.scale, localThree[2] * definition.transform.scale],
quaternionFromEulerXYZ(definition.transform.rotation),
),
definition.transform.position,
);
}
/** Resolve source zone lighting at a runtime world position; null keeps the authored profile. */
export function resolveRunewakerNativeLighting(
definition: Pick<DungeonDefinition, "id" | "transform">,
runtimeWorldPosition: Vector3Tuple,
options: RunewakerLightingOptions = {},
): ResolvedRunewakerLighting | null {
const data = SOURCE.dungeons[definition.id];
if (!data) return null;
const sourcePosition = runewakerRuntimePositionToSource(definition, runtimeWorldPosition);
if (!sourcePosition) return null;
const selection = selectZoneSettings(
data,
sourcePosition,
options.timeGroup ?? SOURCE.runtimeDefaults.timeGroup,
options.weatherGroup ?? SOURCE.runtimeDefaults.weatherGroup,
);
if (!selection) return null;
const settings = selection.settings;
const rayDirection = (sourceDirection: Vector): Vector => sourceDirectionToRuntime(data, definition.transform, sourceDirection) ?? [0, 0, 0];
const far = options.cameraFarDistance;
const fogScale = settings.fog.end > 1 ? 1 / 1600 : 1;
const startFactor = settings.fog.start * fogScale;
const endFactor = settings.fog.end * fogScale;
const triggerDistance = selection.boundaryDistance * 10000;
return {
dungeonId: definition.id,
sourceWdb: data.source.wdb,
sourceWdbSha256: data.source.wdbSha256,
sourceCoordinateSystem: data.source.coordinateSystem,
metersPerSourceUnit: data.source.metersPerSourceUnit,
sourcePosition,
triggerZone: {
id: selection.triggerZone.id,
name: selection.triggerZone.name,
priority: selection.trigger.priority,
verticalHitDistanceSourceUnits: triggerDistance,
},
settingsZone: { id: selection.settingsZone.id, name: selection.settingsZone.name },
timeGroup: selection.timeGroup,
weatherGroup: selection.weatherGroup,
ambient: { colorArgb: settings.ambient.argb, colorLinear: settings.ambient.rgba.slice(0, 3) as unknown as Vector, intensity: 1 },
directionalLights: [
{
role: "primary",
colorArgb: settings.primary.argb,
colorLinear: settings.primary.rgba.slice(0, 3) as unknown as Vector,
intensity: 1,
rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.primary),
toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.primary), -1),
},
{
role: "secondary-0",
colorArgb: settings.secondary.argb,
colorLinear: settings.secondary.rgba.slice(0, 3) as unknown as Vector,
intensity: 0.8,
rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary0),
toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary0), -1),
},
{
role: "secondary-1",
colorArgb: settings.secondary.argb,
colorLinear: settings.secondary.rgba.slice(0, 3) as unknown as Vector,
intensity: 0.8,
rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary1),
toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary1), -1),
},
],
specular: {
colorArgb: settings.specular.argb,
colorLinear: settings.specular.rgba.slice(0, 3) as unknown as Vector,
direction: rayDirection(settings.nativeDirectionsLeftHandedYUp.specular),
},
fog: {
colorArgb: settings.fogColor.argb,
colorLinear: settings.fogColor.rgba.slice(0, 3) as unknown as Vector,
constant: settings.fog.constant,
startFactor,
endFactor,
...(far != null ? { startDistance: startFactor * far, endDistance: endFactor * far } : {}),
},
sky: {
weatherSystem: settings.weatherSystem,
skyGradientTexture: settings.skyGradientTexture,
customSkyBox: settings.customSkyBox,
cloudCover: settings.cloudCover,
cloudSharpness: settings.cloudSharpness,
cloudColorsLinear: [
settings.cloudColors[0].rgba.slice(0, 3) as unknown as Vector,
settings.cloudColors[1].rgba.slice(0, 3) as unknown as Vector,
],
},
};
}
function scaleVector(vector: Vector, scalar: number): Vector {
return [vector[0] * scalar, vector[1] * scalar, vector[2] * scalar];
}
/** Source map for generated-data integrity checks; consumers should use the resolver. */
export const RUNEWAKER_NATIVE_LIGHTING_DUNGEON_IDS = Object.freeze(Object.keys(SOURCE.dungeons));