Add underwater fog functionality
This commit is contained in:
37
src/runtime-three/underwater-fog.js
Normal file
37
src/runtime-three/underwater-fog.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Euler, Quaternion, Vector3 } from "three";
|
||||||
|
|
||||||
|
const MIN_UNDERWATER_FOG_DENSITY = 0.018;
|
||||||
|
const MAX_UNDERWATER_FOG_DENSITY = 0.055;
|
||||||
|
|
||||||
|
function clampNumber(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPointInsideWaterVolume(point, volume) {
|
||||||
|
const offset = new Vector3(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z);
|
||||||
|
const inverseRotation = new Quaternion()
|
||||||
|
.setFromEuler(new Euler((volume.rotationDegrees.x * Math.PI) / 180, (volume.rotationDegrees.y * Math.PI) / 180, (volume.rotationDegrees.z * Math.PI) / 180, "XYZ"))
|
||||||
|
.invert();
|
||||||
|
offset.applyQuaternion(inverseRotation);
|
||||||
|
return (Math.abs(offset.x) <= volume.size.x * 0.5 &&
|
||||||
|
Math.abs(offset.y) <= volume.size.y * 0.5 &&
|
||||||
|
Math.abs(offset.z) <= volume.size.z * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUnderwaterFogDensity(volume) {
|
||||||
|
return clampNumber(MIN_UNDERWATER_FOG_DENSITY + volume.surfaceOpacity * 0.016 + Math.max(volume.waveStrength, 0) * 0.01, MIN_UNDERWATER_FOG_DENSITY, MAX_UNDERWATER_FOG_DENSITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUnderwaterFogState(runtimeScene, telemetry) {
|
||||||
|
if (runtimeScene === null || telemetry === null || telemetry.cameraSubmerged !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const containingVolume = runtimeScene.volumes.water.find((volume) => isPointInsideWaterVolume(telemetry.eyePosition, volume));
|
||||||
|
if (containingVolume === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
colorHex: containingVolume.colorHex,
|
||||||
|
density: resolveUnderwaterFogDensity(containingVolume)
|
||||||
|
};
|
||||||
|
}
|
||||||
66
src/runtime-three/underwater-fog.ts
Normal file
66
src/runtime-three/underwater-fog.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Euler, Quaternion, Vector3 } from "three";
|
||||||
|
|
||||||
|
import type { FirstPersonTelemetry } from "./navigation-controller";
|
||||||
|
import type { RuntimeSceneDefinition, RuntimeWaterVolume } from "./runtime-scene-build";
|
||||||
|
|
||||||
|
export interface UnderwaterFogState {
|
||||||
|
colorHex: string;
|
||||||
|
density: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_UNDERWATER_FOG_DENSITY = 0.018;
|
||||||
|
const MAX_UNDERWATER_FOG_DENSITY = 0.055;
|
||||||
|
|
||||||
|
function clampNumber(value: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPointInsideWaterVolume(point: { x: number; y: number; z: number }, volume: RuntimeWaterVolume) {
|
||||||
|
const offset = new Vector3(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z);
|
||||||
|
const inverseRotation = new Quaternion()
|
||||||
|
.setFromEuler(
|
||||||
|
new Euler(
|
||||||
|
(volume.rotationDegrees.x * Math.PI) / 180,
|
||||||
|
(volume.rotationDegrees.y * Math.PI) / 180,
|
||||||
|
(volume.rotationDegrees.z * Math.PI) / 180,
|
||||||
|
"XYZ"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.invert();
|
||||||
|
|
||||||
|
offset.applyQuaternion(inverseRotation);
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.abs(offset.x) <= volume.size.x * 0.5 &&
|
||||||
|
Math.abs(offset.y) <= volume.size.y * 0.5 &&
|
||||||
|
Math.abs(offset.z) <= volume.size.z * 0.5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUnderwaterFogDensity(volume: RuntimeWaterVolume) {
|
||||||
|
return clampNumber(
|
||||||
|
MIN_UNDERWATER_FOG_DENSITY + volume.surfaceOpacity * 0.016 + Math.max(volume.waveStrength, 0) * 0.01,
|
||||||
|
MIN_UNDERWATER_FOG_DENSITY,
|
||||||
|
MAX_UNDERWATER_FOG_DENSITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUnderwaterFogState(
|
||||||
|
runtimeScene: Pick<RuntimeSceneDefinition, "volumes"> | null,
|
||||||
|
telemetry: Pick<FirstPersonTelemetry, "cameraSubmerged" | "eyePosition"> | null
|
||||||
|
): UnderwaterFogState | null {
|
||||||
|
if (runtimeScene === null || telemetry === null || telemetry.cameraSubmerged !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containingVolume = runtimeScene.volumes.water.find((volume) => isPointInsideWaterVolume(telemetry.eyePosition, volume));
|
||||||
|
|
||||||
|
if (containingVolume === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
colorHex: containingVolume.colorHex,
|
||||||
|
density: resolveUnderwaterFogDensity(containingVolume)
|
||||||
|
};
|
||||||
|
}
|
||||||
51
tests/unit/underwater-fog.test.ts
Normal file
51
tests/unit/underwater-fog.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveUnderwaterFogState } from "../../src/runtime-three/underwater-fog";
|
||||||
|
|
||||||
|
describe("resolveUnderwaterFogState", () => {
|
||||||
|
const runtimeScene = {
|
||||||
|
volumes: {
|
||||||
|
water: [
|
||||||
|
{
|
||||||
|
brushId: "water-1",
|
||||||
|
center: { x: 0, y: 0, z: 0 },
|
||||||
|
rotationDegrees: { x: 0, y: 25, z: 0 },
|
||||||
|
size: { x: 10, y: 4, z: 8 },
|
||||||
|
colorHex: "#347aa0",
|
||||||
|
surfaceOpacity: 0.72,
|
||||||
|
waveStrength: 0.4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
fog: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns fog tint and density from the containing water volume", () => {
|
||||||
|
const fogState = resolveUnderwaterFogState(runtimeScene, {
|
||||||
|
cameraSubmerged: true,
|
||||||
|
eyePosition: { x: 0.3, y: 1.1, z: 0.2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fogState).not.toBeNull();
|
||||||
|
expect(fogState?.colorHex).toBe("#347aa0");
|
||||||
|
expect(fogState?.density ?? 0).toBeCloseTo(0.03352, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not enable fog when the camera is not submerged", () => {
|
||||||
|
expect(
|
||||||
|
resolveUnderwaterFogState(runtimeScene, {
|
||||||
|
cameraSubmerged: false,
|
||||||
|
eyePosition: { x: 0.3, y: 1.1, z: 0.2 }
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not enable fog for points outside the water volume", () => {
|
||||||
|
expect(
|
||||||
|
resolveUnderwaterFogState(runtimeScene, {
|
||||||
|
cameraSubmerged: true,
|
||||||
|
eyePosition: { x: 20, y: 1.1, z: 0.2 }
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user