t2-mapper/src/components/Sky.tsx
2026-03-16 18:16:34 -07:00

721 lines
24 KiB
TypeScript

import { memo, Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useThree, useFrame } from "@react-three/fiber";
import { useCubeTexture } from "@react-three/drei";
import { Color, Fog } from "three";
import { createLogger } from "../logger";
import { useSettings } from "./SettingsProvider";
import { loadDetailMapList, textureToUrl } from "../loaders";
import { CloudLayers } from "./CloudLayers";
import { fogStateFromScene, type FogState } from "./FogProvider";
import { installCustomFogShader } from "../fogShader";
import {
globalFogUniforms,
updateGlobalFogUniforms,
packFogVolumeData,
resetGlobalFogUniforms,
} from "../globalFogUniforms";
const log = createLogger("Sky");
// Track if fog shader has been installed (idempotent installation)
let fogShaderInstalled = false;
import type { Color3 } from "../scene/types";
import { SkyEntity } from "../state/gameEntityTypes";
/** Convert a Color3 to [sRGB Color, linear Color]. */
function color3ToThree(c: Color3): [Color, Color] {
return [
new Color().setRGB(c.r, c.g, c.b),
new Color().setRGB(c.r, c.g, c.b).convertSRGBToLinear(),
];
}
/**
* Load a .dml file, used to list the textures for different faces of a skybox.
*/
function useDetailMapList(name: string) {
const result = useQuery({
queryKey: ["detailMapList", name],
queryFn: () => {
log.debug("Loading detail map list: %s", name);
return loadDetailMapList(name);
},
});
useEffect(() => {
log.debug(
"DML query status: %s%s%s file=%s",
result.status,
result.error ? ` error=${result.error.message}` : "",
result.data ? ` (${result.data.length} entries)` : " (no data)",
name,
);
}, [result.status, result.error, result.data, name]);
return result;
}
/**
* Inner component that renders the skybox once texture URLs are known.
* Separated so useCubeTexture only runs with valid URLs.
*/
// Torque sky constants (from sky.cc)
// OFFSET_HEIGHT = 60.0 - height of the horizon fog band in world units
const HORIZON_FOG_HEIGHT = 60.0;
function SkyBoxTexture({
skyBoxFiles,
fogColor,
fogState,
}: {
skyBoxFiles: string[];
fogColor?: Color;
fogState?: FogState;
}) {
const camera = useThree((state) => state.camera);
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
const enableFog = !!fogColor;
const inverseProjectionMatrix = useMemo(() => {
return camera.projectionMatrixInverse;
}, [camera]);
const fogVolumeData = useMemo(
() =>
fogState ? packFogVolumeData(fogState.fogVolumes) : new Float32Array(12),
[fogState],
);
// Create stable uniform objects that persist across renders
// This ensures the shader gets updated values when props change
const uniformsRef = useRef({
skybox: { value: skyBox },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: 0.18 },
});
// Calculate the horizon fog cutoff based on visible distance
// In Torque's sky.cc:
// mRadius = visibleDistance * 0.95
// tpt = (1,1,1).normalize(mRadius) -> each component = mRadius / sqrt(3)
// mSkyBoxPt.x = mSkyBoxPt.z = mRadius / sqrt(3) (corner of cube)
//
// The fog band is rendered as geometry from height 0 to OFFSET_HEIGHT (60)
// on a skybox where the horizontal distance to the edge is mSkyBoxPt.x
//
// For a ray direction, direction.y corresponds to the vertical component
// The fog should cover directions where:
// height / horizontal_dist = direction.y / sqrt(1 - direction.y^2) < 60 / skyBoxPt.x
//
// Simplifying: direction.y < OFFSET_HEIGHT / sqrt(skyBoxPt.x^2 + OFFSET_HEIGHT^2)
const horizonFogHeight = useMemo(() => {
if (!fogState) return 0.18; // Default fallback
const mRadius = fogState.visibleDistance * 0.95;
const skyBoxPtX = mRadius / Math.sqrt(3); // Corner coordinate
// For direction vector (horizontal, y), y / horizontal = height / skyBoxPtX
// At the fog boundary: y / sqrt(1-y^2) = 60 / skyBoxPtX
// Solving for y: y = 60 / sqrt(skyBoxPtX^2 + 60^2)
return (
HORIZON_FOG_HEIGHT /
Math.sqrt(skyBoxPtX * skyBoxPtX + HORIZON_FOG_HEIGHT * HORIZON_FOG_HEIGHT)
);
}, [fogState]);
// Update uniform values when props change
useEffect(() => {
uniformsRef.current.skybox.value = skyBox;
uniformsRef.current.fogColor.value = fogColor ?? new Color(0, 0, 0);
uniformsRef.current.enableFog.value = enableFog;
uniformsRef.current.fogVolumeData.value = fogVolumeData;
uniformsRef.current.horizonFogHeight.value = horizonFogHeight;
}, [skyBox, fogColor, enableFog, fogVolumeData, horizonFogHeight]);
return (
<mesh renderOrder={-1000} frustumCulled={false}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3]}
count={3}
itemSize={3}
/>
<bufferAttribute
attach="attributes-uv"
args={[new Float32Array([0, 0, 2, 0, 0, 2]), 2]}
count={3}
itemSize={2}
/>
</bufferGeometry>
<shaderMaterial
uniforms={uniformsRef.current}
vertexShader={`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.9999, 1.0);
}
`}
fragmentShader={`
uniform samplerCube skybox;
uniform vec3 fogColor;
uniform bool enableFog;
uniform mat4 inverseProjectionMatrix;
uniform mat4 cameraMatrixWorld;
uniform float cameraHeight;
uniform float fogVolumeData[12];
uniform float horizonFogHeight;
varying vec2 vUv;
// Convert linear to sRGB for display
// shaderMaterial does NOT get automatic linear->sRGB output conversion
// Use proper sRGB transfer function (not simplified gamma 2.2) to match Three.js
vec3 linearToSRGB(vec3 linear) {
vec3 low = linear * 12.92;
vec3 high = 1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055;
return mix(low, high, step(vec3(0.0031308), linear));
}
void main() {
vec2 ndc = vUv * 2.0 - 1.0;
vec4 viewPos = inverseProjectionMatrix * vec4(ndc, 1.0, 1.0);
viewPos.xyz /= viewPos.w;
vec3 direction = normalize((cameraMatrixWorld * vec4(viewPos.xyz, 0.0)).xyz);
direction = vec3(direction.z, direction.y, -direction.x);
// Sample skybox - Three.js CubeTexture with SRGBColorSpace auto-converts to linear
vec4 skyColor = textureCube(skybox, direction);
vec3 finalColor;
if (enableFog) {
vec3 effectiveFogColor = fogColor;
// Calculate how much fog volume the ray passes through
// For skybox at "infinite" distance, the relevant height is how much
// of the volume is above/below camera depending on view direction
float volumeFogInfluence = 0.0;
for (int i = 0; i < 3; i++) {
int offset = i * 4;
float volVisDist = fogVolumeData[offset + 0];
float volMinH = fogVolumeData[offset + 1];
float volMaxH = fogVolumeData[offset + 2];
float volPct = fogVolumeData[offset + 3];
if (volVisDist <= 0.0) continue;
// Check if camera is inside this volume
if (cameraHeight >= volMinH && cameraHeight <= volMaxH) {
// Camera is inside the fog volume
// Looking horizontally or up at shallow angles means ray travels
// through more fog before exiting the volume
float heightAboveCamera = volMaxH - cameraHeight;
float heightBelowCamera = cameraHeight - volMinH;
float volumeHeight = volMaxH - volMinH;
// For horizontal rays (direction.y ≈ 0), maximum fog influence
// For rays going up steeply, less fog (exits volume quickly)
// For rays going down, more fog (travels through volume below)
float rayInfluence;
if (direction.y >= 0.0) {
// Looking up: influence based on how steep we're looking
// Shallow angles = long path through fog = high influence
rayInfluence = 1.0 - smoothstep(0.0, 0.3, direction.y);
} else {
// Looking down: always high fog (into the volume)
rayInfluence = 1.0;
}
// Scale by percentage and volume depth factor
volumeFogInfluence += rayInfluence * volPct;
}
}
// Base fog factor from view direction (for haze at horizon)
// In Torque, the fog "bans" (bands) are rendered as geometry from
// height 0 (HORIZON) to height 60 (OFFSET_HEIGHT) on the skybox.
// The skybox corner is at mSkyBoxPt.x = mRadius / sqrt(3).
//
// horizonFogHeight is the direction.y value where the fog band ends:
// horizonFogHeight = 60 / sqrt(skyBoxPt.x^2 + 60^2)
//
// For Firestorm (visDist=600): mRadius=570, skyBoxPt.x=329, horizonFogHeight≈0.18
//
// Torque renders the fog bands as geometry with linear vertex alpha
// interpolation. We use a squared curve (t^2) to create a gentler
// falloff at the top of the gradient, matching Tribes 2's appearance.
float baseFogFactor;
if (direction.y <= 0.0) {
// Looking at or below horizon: full fog
baseFogFactor = 1.0;
} else if (direction.y >= horizonFogHeight) {
// Above fog band: no fog
baseFogFactor = 0.0;
} else {
// Within fog band: squared curve for gentler falloff at top
float t = direction.y / horizonFogHeight;
baseFogFactor = (1.0 - t) * (1.0 - t);
}
// Combine base fog with volume fog influence
// When inside a volume, increase fog intensity
float finalFogFactor = min(1.0, baseFogFactor + volumeFogInfluence * 0.5);
finalColor = mix(skyColor.rgb, effectiveFogColor, finalFogFactor);
} else {
finalColor = skyColor.rgb;
}
// Convert linear result to sRGB for display
gl_FragColor = vec4(linearToSRGB(finalColor), 1.0);
}
`}
depthWrite={false}
depthTest={false}
/>
</mesh>
);
}
export function SkyBox({
materialList,
fogColor,
fogState,
}: {
materialList: string;
fogColor?: Color;
fogState?: FogState;
}) {
const { data: detailMapList } = useDetailMapList(materialList);
const skyBoxFiles = useMemo(
() =>
detailMapList
? [
textureToUrl(detailMapList[1]!), // +x
textureToUrl(detailMapList[3]!), // -x
textureToUrl(detailMapList[4]!), // +y
textureToUrl(detailMapList[5]!), // -y
textureToUrl(detailMapList[0]!), // +z
textureToUrl(detailMapList[2]!), // -z
]
: null,
[detailMapList],
);
// Don't render until we have real texture URLs
if (!skyBoxFiles) {
return null;
}
return (
<SkyBoxTexture
skyBoxFiles={skyBoxFiles}
fogColor={fogColor}
fogState={fogState}
/>
);
}
/**
* Solid color sky component for when useSkyTextures = 0.
* Renders SkySolidColor (ignoring alpha) with fog at the horizon.
* Uses the same fog logic as SkyBoxTexture for consistency.
*/
function SolidColorSky({
skyColor,
fogColor,
fogState,
}: {
skyColor: Color;
fogColor?: Color;
fogState?: FogState;
}) {
const camera = useThree((state) => state.camera);
const enableFog = !!fogColor;
const inverseProjectionMatrix = useMemo(() => {
return camera.projectionMatrixInverse;
}, [camera]);
const fogVolumeData = useMemo(
() =>
fogState ? packFogVolumeData(fogState.fogVolumes) : new Float32Array(12),
[fogState],
);
const horizonFogHeight = useMemo(() => {
if (!fogState) return 0.18;
const mRadius = fogState.visibleDistance * 0.95;
const skyBoxPtX = mRadius / Math.sqrt(3);
return (
HORIZON_FOG_HEIGHT /
Math.sqrt(skyBoxPtX * skyBoxPtX + HORIZON_FOG_HEIGHT * HORIZON_FOG_HEIGHT)
);
}, [fogState]);
// Create stable uniform objects that persist across renders
const uniformsRef = useRef({
skyColor: { value: skyColor },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: horizonFogHeight },
});
// Update uniform values when props change
useEffect(() => {
uniformsRef.current.skyColor.value = skyColor;
uniformsRef.current.fogColor.value = fogColor ?? new Color(0, 0, 0);
uniformsRef.current.enableFog.value = enableFog;
uniformsRef.current.fogVolumeData.value = fogVolumeData;
uniformsRef.current.horizonFogHeight.value = horizonFogHeight;
}, [skyColor, fogColor, enableFog, fogVolumeData, horizonFogHeight]);
return (
<mesh renderOrder={-1000} frustumCulled={false}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3]}
count={3}
itemSize={3}
/>
<bufferAttribute
attach="attributes-uv"
args={[new Float32Array([0, 0, 2, 0, 0, 2]), 2]}
count={3}
itemSize={2}
/>
</bufferGeometry>
<shaderMaterial
uniforms={uniformsRef.current}
vertexShader={`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.9999, 1.0);
}
`}
fragmentShader={`
uniform vec3 skyColor;
uniform vec3 fogColor;
uniform bool enableFog;
uniform mat4 inverseProjectionMatrix;
uniform mat4 cameraMatrixWorld;
uniform float cameraHeight;
uniform float fogVolumeData[12];
uniform float horizonFogHeight;
varying vec2 vUv;
// Convert linear to sRGB for display
vec3 linearToSRGB(vec3 linear) {
vec3 low = linear * 12.92;
vec3 high = 1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055;
return mix(low, high, step(vec3(0.0031308), linear));
}
void main() {
vec2 ndc = vUv * 2.0 - 1.0;
vec4 viewPos = inverseProjectionMatrix * vec4(ndc, 1.0, 1.0);
viewPos.xyz /= viewPos.w;
vec3 direction = normalize((cameraMatrixWorld * vec4(viewPos.xyz, 0.0)).xyz);
direction = vec3(direction.z, direction.y, -direction.x);
vec3 finalColor;
if (enableFog) {
// Calculate volume fog influence (same logic as SkyBoxTexture)
float volumeFogInfluence = 0.0;
for (int i = 0; i < 3; i++) {
int offset = i * 4;
float volVisDist = fogVolumeData[offset + 0];
float volMinH = fogVolumeData[offset + 1];
float volMaxH = fogVolumeData[offset + 2];
float volPct = fogVolumeData[offset + 3];
if (volVisDist <= 0.0) continue;
if (cameraHeight >= volMinH && cameraHeight <= volMaxH) {
float rayInfluence;
if (direction.y >= 0.0) {
rayInfluence = 1.0 - smoothstep(0.0, 0.3, direction.y);
} else {
rayInfluence = 1.0;
}
volumeFogInfluence += rayInfluence * volPct;
}
}
// Base fog factor from view direction
float baseFogFactor;
if (direction.y <= 0.0) {
baseFogFactor = 1.0;
} else if (direction.y >= horizonFogHeight) {
baseFogFactor = 0.0;
} else {
float t = direction.y / horizonFogHeight;
baseFogFactor = (1.0 - t) * (1.0 - t);
}
// Combine base fog with volume fog influence
float finalFogFactor = min(1.0, baseFogFactor + volumeFogInfluence * 0.5);
finalColor = mix(skyColor, fogColor, finalFogFactor);
} else {
finalColor = skyColor;
}
gl_FragColor = vec4(linearToSRGB(finalColor), 1.0);
}
`}
depthWrite={false}
depthTest={false}
/>
</mesh>
);
}
/**
* Get fog near/far parameters for the distance-based haze.
*
* IMPORTANT: In Torque, the distance-based haze ALWAYS uses the global
* fogDistance and visibleDistance parameters. Per-volume fog contributions
* are calculated separately in the volumetric fog shader and ADDED to haze.
*
* The shader's haze formula reads fogNear/fogFar from scene.fog, so these
* must be the global parameters, NOT per-volume adjusted values.
*
* @returns [near, far] distances for haze (always global values)
*/
function calculateFogParameters(
fogState: FogState,
_cameraHeight: number,
): [number, number] {
const { fogDistance, visibleDistance } = fogState;
// Always return global fog parameters for the haze calculation.
// Volumetric fog from fog volumes is computed separately in the shader
// and added to the haze value.
return [fogDistance, visibleDistance];
}
/**
* Dynamic fog component that manages Torque-style fog rendering.
*
* This component:
* - Sets up Three.js Fog with global fogDistance/visibleDistance for haze
* - Updates cameraHeight uniform each frame for volumetric fog shaders
* - Manages global fog uniforms lifecycle (reset on mount, cleanup on unmount)
*
* The custom fog shader (fogFragmentShader) handles:
* 1. Haze: Distance-based quadratic fog using global parameters
* 2. Volume fog: Height-based fog using per-volume parameters
* Both are combined additively, matching Torque's getHazeAndFog function.
*/
function DynamicFog({
fogState,
enabled,
}: {
fogState: FogState;
enabled: boolean;
}) {
const scene = useThree((state) => state.scene);
const camera = useThree((state) => state.camera);
const fogRef = useRef<Fog | null>(null);
// Pack fog volume data once (it doesn't change during runtime)
const fogVolumeData = useMemo(
() => packFogVolumeData(fogState.fogVolumes),
[fogState.fogVolumes],
);
// Install custom fog shader (idempotent - only runs once globally)
useEffect(() => {
if (!fogShaderInstalled) {
installCustomFogShader();
fogShaderInstalled = true;
}
}, []);
// Create fog object on mount
useEffect(() => {
// Reset global fog uniforms to ensure clean state for new mission
resetGlobalFogUniforms();
const [near, far] = calculateFogParameters(fogState, camera.position.y);
const fog = new Fog(fogState.fogColor, near, far);
scene.fog = fog;
fogRef.current = fog;
// Initial update of global fog uniforms
updateGlobalFogUniforms(camera.position.y, fogVolumeData);
return () => {
scene.fog = null;
fogRef.current = null;
// Reset fog uniforms on unmount so next mission starts clean
resetGlobalFogUniforms();
};
}, [scene, camera, fogState, fogVolumeData]);
// When fog is disabled, set near=far to effectively disable fog
// without removing scene.fog (which would require shader recompilation)
useEffect(() => {
const fog = fogRef.current;
if (!fog) return;
if (enabled) {
const [near, far] = calculateFogParameters(fogState, camera.position.y);
fog.near = near;
fog.far = far;
} else {
// Setting near = far = large value effectively disables fog
// (fog factor = 0 when distance < near)
fog.near = 1e10;
fog.far = 1e10;
}
}, [enabled, fogState, camera.position.y]);
// Update fog parameters each frame based on camera height
useFrame(() => {
const fog = fogRef.current;
if (!fog) return;
const cameraHeight = camera.position.y;
// Always update global fog uniforms so shaders know the enabled state
updateGlobalFogUniforms(cameraHeight, fogVolumeData, enabled);
if (enabled) {
// Update Three.js basic fog
const [near, far] = calculateFogParameters(fogState, cameraHeight);
fog.near = near;
fog.far = far;
fog.color.copy(fogState.fogColor);
}
// When disabled, fog.near/far are already set to 1e10 by the useEffect
});
return null;
}
export const Sky = memo(function Sky({ entity }: { entity: SkyEntity }) {
const { skyData } = entity;
log.debug(
"Rendering: materialList=%s, useSkyTextures=%s",
skyData.materialList,
skyData.useSkyTextures,
);
const { fogEnabled } = useSettings();
// Skybox textures
const materialList = skyData.materialList || undefined;
const skySolidColor = useMemo(
() => color3ToThree(skyData.skySolidColor),
[skyData.skySolidColor],
);
const useSkyTextures = skyData.useSkyTextures;
// Parse full fog state from typed scene sky
const fogState = useMemo(() => fogStateFromScene(skyData), [skyData]);
log.debug(
"fogState: fogColor=(%s, %s, %s) visibleDistance=%d fogDistance=%d enabled=%s volumes=%d",
skyData.fogColor.r.toFixed(3),
skyData.fogColor.g.toFixed(3),
skyData.fogColor.b.toFixed(3),
skyData.visibleDistance,
skyData.fogDistance,
fogState.enabled,
fogState.fogVolumes.length,
);
// Get sRGB fog color for background
const fogColor = useMemo(
() => color3ToThree(skyData.fogColor),
[skyData.fogColor],
);
const skyColor = skySolidColor || fogColor;
// Only enable fog if we have valid distance parameters
const hasFogParams = fogState.enabled && fogEnabled;
// Use the linear fog color from fogState - Three.js will handle display conversion
const effectiveFogColor = fogState.fogColor;
// Set scene background color directly using useThree
// This ensures the gap between fogged terrain and skybox blends correctly
const threeScene = useThree((state) => state.scene);
const gl = useThree((state) => state.gl);
useEffect(() => {
if (hasFogParams) {
// Use effective fog color for background (matches terrain fog)
const bgColor = effectiveFogColor.clone();
threeScene.background = bgColor;
// Also set the renderer clear color as a fallback
gl.setClearColor(bgColor);
} else if (skyColor) {
const bgColor = skyColor[0].clone();
threeScene.background = bgColor;
gl.setClearColor(bgColor);
} else {
threeScene.background = null;
}
return () => {
threeScene.background = null;
};
}, [threeScene, gl, hasFogParams, effectiveFogColor, skyColor]);
// Get linear sky solid color for the solid color sky shader
const linearSkySolidColor = skySolidColor?.[1];
return (
<>
{materialList && useSkyTextures && materialList.length > 0 ? (
<Suspense>
{/* Key forces remount when mission changes to clear texture caches */}
<SkyBox
key={materialList}
materialList={materialList}
fogColor={hasFogParams ? effectiveFogColor : undefined}
fogState={hasFogParams ? fogState : undefined}
/>
</Suspense>
) : linearSkySolidColor ? (
/* When useSkyTextures = 0, render solid color sky with SkySolidColor */
<SolidColorSky
skyColor={linearSkySolidColor}
fogColor={hasFogParams ? effectiveFogColor : undefined}
fogState={hasFogParams ? fogState : undefined}
/>
) : null}
{/* Cloud layers render independently of skybox textures */}
<Suspense>
<CloudLayers scene={skyData} />
</Suspense>
{/* Always render DynamicFog when mission has fog params.
Pass fogEnabled to control visibility - this avoids shader recompilation
when toggling fog (USE_FOG stays defined, but fog.near/far disable fog). */}
{fogState.enabled ? (
<DynamicFog fogState={fogState} enabled={fogEnabled} />
) : null}
</>
);
});