forked from phenom/healer-man
472 lines
18 KiB
TypeScript
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));
|