mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-10 22:14:40 +00:00
improve lighting, fog, clouds, force fields
This commit is contained in:
parent
3ba1ce9afd
commit
a4b7021acc
40 changed files with 4046 additions and 291 deletions
|
|
@ -11,7 +11,7 @@ import {
|
|||
Texture,
|
||||
RepeatWrapping,
|
||||
LinearFilter,
|
||||
SRGBColorSpace,
|
||||
NoColorSpace,
|
||||
Group,
|
||||
} from "three";
|
||||
import { loadDetailMapList, textureToUrl } from "../loaders";
|
||||
|
|
@ -119,8 +119,11 @@ function createCloudGeometry(
|
|||
positions[idx * 3 + 2] = z;
|
||||
|
||||
// UV coordinates for texture (will be offset for scrolling)
|
||||
uvs[idx * 2] = col / (GRID_SIZE - 1);
|
||||
uvs[idx * 2 + 1] = row / (GRID_SIZE - 1);
|
||||
// Torque uses mTextureScale default of (1, 1), which with x/y going 0-4
|
||||
// gives UV range 0-4, tiling the texture 4 times across the dome.
|
||||
// This creates the swirly detail effect visible in Tribes 2.
|
||||
uvs[idx * 2] = col; // 0 to 4 (tiles 4x)
|
||||
uvs[idx * 2 + 1] = row; // 0 to 4 (tiles 4x)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,13 +248,17 @@ function adjustCorners(positions: Float32Array): void {
|
|||
|
||||
/**
|
||||
* Setup cloud texture with proper wrapping and filtering.
|
||||
* Uses NoColorSpace to pass values through directly without conversion,
|
||||
* matching Torque's gamma-space rendering pipeline.
|
||||
*/
|
||||
function setupCloudTexture(texture: Texture): Texture {
|
||||
texture.wrapS = RepeatWrapping;
|
||||
texture.wrapT = RepeatWrapping;
|
||||
texture.minFilter = LinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
// NoColorSpace: values pass through directly without sRGB conversion.
|
||||
// Torque didn't do color space conversion - textures went straight to display.
|
||||
texture.colorSpace = NoColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
|
@ -484,12 +491,16 @@ export function CloudLayers({ object }: CloudLayersProps) {
|
|||
}, [object]);
|
||||
|
||||
// Wind direction from windVelocity
|
||||
// Torque uses Z-up with windVelocity (x, y, z) where Y is forward.
|
||||
// Our cloud geometry has UV U along world X, UV V along world Z.
|
||||
// Rotate 90 degrees clockwise to match Torque's coordinate system.
|
||||
const windDirection = useMemo(() => {
|
||||
const windVelocity = getProperty(object, "windVelocity");
|
||||
if (windVelocity) {
|
||||
const [x, y] = windVelocity.split(" ").map((s: string) => parseFloat(s));
|
||||
if (x !== 0 || y !== 0) {
|
||||
return new Vector2(x, y).normalize();
|
||||
// Rotate 90 degrees clockwise: (x, y) -> (y, -x)
|
||||
return new Vector2(y, -x).normalize();
|
||||
}
|
||||
}
|
||||
return new Vector2(1, 0);
|
||||
|
|
|
|||
303
src/components/FogProvider.tsx
Normal file
303
src/components/FogProvider.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* FogProvider - Manages Tribes 2 fog state and provides fog uniforms to materials.
|
||||
*
|
||||
* Tribes 2 has two fog systems:
|
||||
* 1. Distance-based haze: Global fog from fogDistance to visibleDistance with quadratic falloff
|
||||
* 2. Height-based volumetric fog: Up to 3 fog volumes with independent height ranges and colors
|
||||
*
|
||||
* The fog density depends on how much of the view ray passes through each fog volume,
|
||||
* which varies based on camera height relative to volume boundaries.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { Color } from "three";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { getFloat, getProperty } from "../mission";
|
||||
|
||||
/** Maximum number of fog volumes supported (matches Torque) */
|
||||
export const MAX_FOG_VOLUMES = 3;
|
||||
/** Floats per fog volume in shader uniform: [visDist, minH, maxH, percentage] */
|
||||
const FLOATS_PER_VOLUME = 4;
|
||||
|
||||
/**
|
||||
* A single fog volume with height boundaries and visibility settings.
|
||||
*
|
||||
* Note: Per-volume colors are NOT used in Tribes 2 ($specialFog defaults to false).
|
||||
* All fog uses the global fogColor regardless of fogVolumeColor values in mission files.
|
||||
*/
|
||||
export interface FogVolume {
|
||||
/** Distance at which objects are fully obscured within this volume */
|
||||
visibleDistance: number;
|
||||
/** Bottom height boundary of the fog volume */
|
||||
minHeight: number;
|
||||
/** Top height boundary of the fog volume */
|
||||
maxHeight: number;
|
||||
/** Fog density percentage (0-1), can be animated for storm effects */
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
/** Complete fog state parsed from a Sky object */
|
||||
export interface FogState {
|
||||
/** Distance at which fog starts (near plane) */
|
||||
fogDistance: number;
|
||||
/** Distance at which fog is fully opaque (far plane) */
|
||||
visibleDistance: number;
|
||||
/** Color for distance-based haze */
|
||||
fogColor: Color;
|
||||
/** Height-based fog volumes (up to 3) */
|
||||
fogVolumes: FogVolume[];
|
||||
/** Highest point of any fog volume (used for optimization) */
|
||||
fogLine: number;
|
||||
/** Whether fog is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Fog uniforms to pass to shaders */
|
||||
export interface FogUniforms {
|
||||
/** Distance fog near plane */
|
||||
fogNear: number;
|
||||
/** Distance fog far plane */
|
||||
fogFar: number;
|
||||
/** Distance fog color (linear color space) */
|
||||
fogColor: Color;
|
||||
/** Fog volume data as flat array for shader: [visDist, minH, maxH, percentage] x 3 = 12 floats */
|
||||
fogVolumeData: Float32Array;
|
||||
/** Current camera Y position */
|
||||
cameraHeight: number;
|
||||
/** Whether volumetric fog is active */
|
||||
hasVolumetricFog: boolean;
|
||||
}
|
||||
|
||||
const FogContext = createContext<FogState | null>(null);
|
||||
const FogUniformsContext =
|
||||
createContext<React.MutableRefObject<FogUniforms> | null>(null);
|
||||
|
||||
/**
|
||||
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
|
||||
*
|
||||
* Torque (2001) worked in gamma space - colors were specified as they should
|
||||
* appear on screen. Three.js expects linear colors (it converts to sRGB on output).
|
||||
* We convert sRGB->linear so the final output matches the intended appearance.
|
||||
*/
|
||||
function parseColor(colorString: string | undefined): Color {
|
||||
if (!colorString) return new Color(0.5, 0.5, 0.5);
|
||||
const parts = colorString.split(" ").map((s) => parseFloat(s));
|
||||
const [r, g, b] = parts;
|
||||
// Convert from sRGB (how Torque specified colors) to linear (what Three.js expects)
|
||||
return new Color().setRGB(r, g, b).convertSRGBToLinear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a fog volume property string.
|
||||
* Format: "visibleDistance minHeight maxHeight"
|
||||
*
|
||||
* Note: fogVolumeColor is intentionally not parsed - per-volume colors are
|
||||
* NOT used in Tribes 2 ($specialFog defaults to false). All fog uses fogColor.
|
||||
*/
|
||||
function parseFogVolume(
|
||||
volumeStr: string | undefined,
|
||||
percentage: number = 1.0,
|
||||
): FogVolume | null {
|
||||
if (!volumeStr) return null;
|
||||
|
||||
const parts = volumeStr.split(" ").map((s) => parseFloat(s));
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
const [visibleDistance, minHeight, maxHeight] = parts;
|
||||
|
||||
// Volume is invalid if visibleDistance is 0 or heights are equal
|
||||
if (visibleDistance <= 0 || maxHeight <= minHeight) return null;
|
||||
|
||||
return {
|
||||
visibleDistance,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
percentage: Math.max(0, Math.min(1, percentage)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse fog state from a Sky TorqueObject.
|
||||
* @param object - The Sky TorqueObject containing fog properties
|
||||
* @param highQuality - If true, use high_ fog distance variants when available
|
||||
*/
|
||||
export function parseFogState(
|
||||
object: TorqueObject,
|
||||
highQuality: boolean = true,
|
||||
): FogState {
|
||||
// Distance-based fog parameters
|
||||
const fogDistanceBase = getFloat(object, "fogDistance") ?? 0;
|
||||
const visibleDistanceBase = getFloat(object, "visibleDistance") ?? 1000;
|
||||
const highFogDistance = getFloat(object, "high_fogDistance");
|
||||
const highVisibleDistance = getFloat(object, "high_visibleDistance");
|
||||
|
||||
// Use high_ variants if highQuality is enabled and they're available
|
||||
const fogDistance =
|
||||
highQuality && highFogDistance != null && highFogDistance > 0
|
||||
? highFogDistance
|
||||
: fogDistanceBase;
|
||||
const visibleDistance =
|
||||
highQuality && highVisibleDistance != null && highVisibleDistance > 0
|
||||
? highVisibleDistance
|
||||
: visibleDistanceBase;
|
||||
|
||||
const fogColor = parseColor(getProperty(object, "fogColor"));
|
||||
|
||||
// Parse fog volumes (up to 3)
|
||||
// Note: fogVolumeColor is intentionally not parsed - see parseFogVolume comment
|
||||
const fogVolumes: FogVolume[] = [];
|
||||
|
||||
for (let i = 1; i <= MAX_FOG_VOLUMES; i++) {
|
||||
const volume = parseFogVolume(
|
||||
getProperty(object, `fogVolume${i}`),
|
||||
1.0, // Default percentage, could parse from storm fog state
|
||||
);
|
||||
if (volume) {
|
||||
fogVolumes.push(volume);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate fog line (highest point of any fog volume)
|
||||
const fogLine = fogVolumes.reduce(
|
||||
(max, vol) => Math.max(max, vol.maxHeight),
|
||||
0,
|
||||
);
|
||||
|
||||
// Fog is enabled if we have valid distance parameters
|
||||
const enabled = visibleDistance > fogDistance;
|
||||
|
||||
return {
|
||||
fogDistance,
|
||||
visibleDistance,
|
||||
fogColor,
|
||||
fogVolumes,
|
||||
fogLine,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial fog uniforms structure.
|
||||
*/
|
||||
function createFogUniforms(): FogUniforms {
|
||||
return {
|
||||
fogNear: 0,
|
||||
fogFar: 1000,
|
||||
fogColor: new Color(0.5, 0.5, 0.5),
|
||||
fogVolumeData: new Float32Array(MAX_FOG_VOLUMES * FLOATS_PER_VOLUME),
|
||||
cameraHeight: 0,
|
||||
hasVolumetricFog: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fog uniforms from fog state.
|
||||
*/
|
||||
function updateFogUniforms(
|
||||
uniforms: FogUniforms,
|
||||
state: FogState,
|
||||
cameraY: number,
|
||||
): void {
|
||||
uniforms.fogNear = state.fogDistance;
|
||||
uniforms.fogFar = state.visibleDistance;
|
||||
uniforms.fogColor.copy(state.fogColor);
|
||||
uniforms.cameraHeight = cameraY;
|
||||
uniforms.hasVolumetricFog = state.fogVolumes.length > 0;
|
||||
|
||||
// Pack fog volume data for shader: [visDist, minH, maxH, percentage] x 3
|
||||
for (let i = 0; i < MAX_FOG_VOLUMES; i++) {
|
||||
const offset = i * FLOATS_PER_VOLUME;
|
||||
const vol = state.fogVolumes[i];
|
||||
|
||||
if (vol) {
|
||||
uniforms.fogVolumeData[offset + 0] = vol.visibleDistance;
|
||||
uniforms.fogVolumeData[offset + 1] = vol.minHeight;
|
||||
uniforms.fogVolumeData[offset + 2] = vol.maxHeight;
|
||||
uniforms.fogVolumeData[offset + 3] = vol.percentage;
|
||||
} else {
|
||||
// Mark as inactive with visibleDistance = 0
|
||||
uniforms.fogVolumeData[offset + 0] = 0;
|
||||
uniforms.fogVolumeData[offset + 1] = 0;
|
||||
uniforms.fogVolumeData[offset + 2] = 0;
|
||||
uniforms.fogVolumeData[offset + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FogProviderProps {
|
||||
object: TorqueObject;
|
||||
enabled?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides fog state and uniforms to the scene.
|
||||
* Updates fog uniforms each frame based on camera position.
|
||||
*
|
||||
* Note: Shader materials get fog uniforms from globalFogUniforms (updated by Sky).
|
||||
* This provider is for React components that need fog state or the FogUniforms object.
|
||||
*/
|
||||
export function FogProvider({
|
||||
object,
|
||||
enabled = true,
|
||||
children,
|
||||
}: FogProviderProps) {
|
||||
const fogState = useMemo(() => {
|
||||
const state = parseFogState(object);
|
||||
state.enabled = state.enabled && enabled;
|
||||
return state;
|
||||
}, [object, enabled]);
|
||||
|
||||
const uniformsRef = useRef<FogUniforms>(createFogUniforms());
|
||||
|
||||
// Update uniforms each frame with current camera position
|
||||
useFrame(({ camera }) => {
|
||||
if (fogState.enabled) {
|
||||
updateFogUniforms(uniformsRef.current, fogState, camera.position.y);
|
||||
}
|
||||
});
|
||||
|
||||
// Initial update
|
||||
useMemo(() => {
|
||||
updateFogUniforms(uniformsRef.current, fogState, 0);
|
||||
}, [fogState]);
|
||||
|
||||
return (
|
||||
<FogContext.Provider value={fogState}>
|
||||
<FogUniformsContext.Provider value={uniformsRef}>
|
||||
{children}
|
||||
</FogUniformsContext.Provider>
|
||||
</FogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the current fog state.
|
||||
*/
|
||||
export function useFogState(): FogState | null {
|
||||
return useContext(FogContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access fog uniforms ref (for shader updates).
|
||||
*/
|
||||
export function useFogUniforms(): React.MutableRefObject<FogUniforms> | null {
|
||||
return useContext(FogUniformsContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fog color at a given height.
|
||||
* Used for skybox and background color blending.
|
||||
*
|
||||
* Note: Per-volume colors are not used in Tribes 2, so this always
|
||||
* returns the global fog color regardless of height.
|
||||
*/
|
||||
export function getFogColorAtHeight(state: FogState, _height: number): Color {
|
||||
return state.fogColor;
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
BoxGeometry,
|
||||
Color,
|
||||
DoubleSide,
|
||||
LinearSRGBColorSpace,
|
||||
NoColorSpace,
|
||||
RepeatWrapping,
|
||||
Texture,
|
||||
} from "three";
|
||||
|
|
@ -47,8 +47,9 @@ function parseColor(colorStr: string): [number, number, number] {
|
|||
|
||||
function setupForceFieldTexture(texture: Texture) {
|
||||
texture.wrapS = texture.wrapT = RepeatWrapping;
|
||||
// Linear color space - gamma correction is applied in the shader
|
||||
texture.colorSpace = LinearSRGBColorSpace;
|
||||
// NoColorSpace - values pass through directly to display without conversion,
|
||||
// matching how WaterBlock handles textures in custom ShaderMaterial.
|
||||
texture.colorSpace = NoColorSpace;
|
||||
texture.flipY = false;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
|
@ -155,21 +156,16 @@ function ForceFieldFallback({
|
|||
}: ForceFieldGeometryProps) {
|
||||
const geometry = useCornerBoxGeometry(scale);
|
||||
|
||||
// Apply gamma correction to match the main shader's pow(color, 2.2)
|
||||
const gammaColor = useMemo(
|
||||
() =>
|
||||
new Color(
|
||||
Math.pow(color[0], 2.2),
|
||||
Math.pow(color[1], 2.2),
|
||||
Math.pow(color[2], 2.2),
|
||||
),
|
||||
// Use color directly - no gamma correction needed to match main shader
|
||||
const fallbackColor = useMemo(
|
||||
() => new Color(color[0], color[1], color[2]),
|
||||
[color],
|
||||
);
|
||||
|
||||
return (
|
||||
<mesh geometry={geometry} renderOrder={1}>
|
||||
<meshBasicMaterial
|
||||
color={gammaColor}
|
||||
color={fallbackColor}
|
||||
transparent
|
||||
opacity={baseTranslucency * OPACITY_FACTOR}
|
||||
blending={AdditiveBlending}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import { useDebug } from "./SettingsProvider";
|
|||
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { useIflTexture } from "./useIflTexture";
|
||||
import { injectCustomFog } from "../fogShader";
|
||||
import { globalFogUniforms } from "../globalFogUniforms";
|
||||
import { injectShapeLighting } from "../shapeMaterial";
|
||||
|
||||
/** Shared props for texture rendering components */
|
||||
interface TextureProps {
|
||||
|
|
@ -43,6 +46,21 @@ type MaterialResult =
|
|||
| SingleMaterial
|
||||
| [MeshLambertMaterial, MeshLambertMaterial];
|
||||
|
||||
/**
|
||||
* Helper to apply volumetric fog and lighting multipliers to a material
|
||||
*/
|
||||
function applyShapeShaderModifications(
|
||||
mat: MeshBasicMaterial | MeshLambertMaterial,
|
||||
): void {
|
||||
mat.onBeforeCompile = (shader) => {
|
||||
injectCustomFog(shader, globalFogUniforms);
|
||||
// Only inject lighting for Lambert materials (Basic materials are unlit)
|
||||
if (mat instanceof MeshLambertMaterial) {
|
||||
injectShapeLighting(shader);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createMaterialFromFlags(
|
||||
baseMaterial: MeshStandardMaterial,
|
||||
texture: Texture,
|
||||
|
|
@ -64,6 +82,7 @@ function createMaterialFromFlags(
|
|||
blending: isAdditive ? AdditiveBlending : undefined,
|
||||
fog: true,
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
return mat;
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +109,8 @@ function createMaterialFromFlags(
|
|||
...baseProps,
|
||||
side: 0, // FrontSide
|
||||
});
|
||||
applyShapeShaderModifications(backMat);
|
||||
applyShapeShaderModifications(frontMat);
|
||||
return [backMat, frontMat];
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +121,7 @@ function createMaterialFromFlags(
|
|||
side: 2, // DoubleSide
|
||||
reflectivity: 0,
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
return mat;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, Suspense, useMemo } from "react";
|
||||
import { memo, Suspense, useMemo, useCallback } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Mesh, Material, MeshStandardMaterial, Texture } from "three";
|
||||
import { useGLTF, useTexture } from "@react-three/drei";
|
||||
|
|
@ -8,8 +8,16 @@ import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
|||
import { setupColor } from "../textureUtils";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { useDebug } from "./SettingsProvider";
|
||||
import { injectCustomFog } from "../fogShader";
|
||||
import { globalFogUniforms } from "../globalFogUniforms";
|
||||
import { injectInteriorLighting } from "../interiorMaterial";
|
||||
|
||||
const LIGHTMAP_INTENSITY = 4;
|
||||
/**
|
||||
* Lightmap intensity multiplier.
|
||||
* Lightmaps contain baked lighting from interior-specific lights only
|
||||
* (not scene sun/ambient - that's applied in real-time).
|
||||
*/
|
||||
const LIGHTMAP_INTENSITY = 2.5;
|
||||
|
||||
/**
|
||||
* Load a .gltf file that was converted from a .dif, used for "interior" models.
|
||||
|
|
@ -36,19 +44,35 @@ function InteriorTexture({
|
|||
const flagNames = new Set<string>(material?.userData?.flag_names ?? []);
|
||||
const isSelfIlluminating = flagNames.has("SelfIlluminating");
|
||||
|
||||
// Self-illuminating materials are fullbright (unlit)
|
||||
// Inject volumetric fog and lighting multipliers into materials
|
||||
const onBeforeCompile = useCallback((shader: any) => {
|
||||
injectCustomFog(shader, globalFogUniforms);
|
||||
injectInteriorLighting(shader);
|
||||
}, []);
|
||||
|
||||
// Self-illuminating materials are fullbright (unlit), no lightmap
|
||||
if (isSelfIlluminating) {
|
||||
return <meshBasicMaterial map={texture} side={2} toneMapped={false} />;
|
||||
return (
|
||||
<meshBasicMaterial
|
||||
map={texture}
|
||||
side={2}
|
||||
toneMapped={false}
|
||||
onBeforeCompile={onBeforeCompile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Use lightMap if available (baked lighting from DIF files)
|
||||
// Three.js MeshLambertMaterial automatically uses uv2 for lightMap
|
||||
// Use MeshLambertMaterial for diffuse-only lighting (matches Tribes 2's GL pipeline)
|
||||
// Interiors respond to scene sun + ambient (from Sky object) in real-time
|
||||
// Lightmaps contain baked lighting from interior-specific lights only
|
||||
// DIF files are reusable across missions with different sun settings
|
||||
return (
|
||||
<meshLambertMaterial
|
||||
map={texture}
|
||||
lightMap={lightMap ?? undefined}
|
||||
lightMapIntensity={lightMap ? LIGHTMAP_INTENSITY : undefined}
|
||||
side={2}
|
||||
onBeforeCompile={onBeforeCompile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -56,13 +80,22 @@ function InteriorTexture({
|
|||
/**
|
||||
* Extract lightmap texture from a glTF material.
|
||||
* The io_dif Blender addon stores lightmaps in the emissive channel for transport.
|
||||
*
|
||||
* Note: Torque used lightmaps directly as linear data (no gamma correction in
|
||||
* the engine). The glTF loader preserves the original PNG data. We explicitly
|
||||
* set colorSpace to linear to match Torque's behavior.
|
||||
*/
|
||||
function getLightMap(material: Material | null): Texture | null {
|
||||
if (!material) return null;
|
||||
// glTF materials come through as MeshStandardMaterial
|
||||
const stdMat = material as MeshStandardMaterial;
|
||||
// Lightmap is stored in emissiveMap with 0 strength (just for glTF transport)
|
||||
return stdMat.emissiveMap ?? null;
|
||||
const lightMap = stdMat.emissiveMap;
|
||||
if (lightMap) {
|
||||
// Use linear color space to match Torque's direct multiply behavior
|
||||
lightMap.colorSpace = "srgb-linear";
|
||||
}
|
||||
return lightMap ?? null;
|
||||
}
|
||||
|
||||
function InteriorMesh({ node }: { node: Mesh }) {
|
||||
|
|
@ -70,7 +103,7 @@ function InteriorMesh({ node }: { node: Mesh }) {
|
|||
const lightMaps = useMemo(() => {
|
||||
if (!node.material) return [];
|
||||
if (Array.isArray(node.material)) {
|
||||
return node.material.map(getLightMap);
|
||||
return node.material.map((m) => getLightMap(m));
|
||||
}
|
||||
return [getLightMap(node.material)];
|
||||
}, [node.material]);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ type StateSetter<T> = ReturnType<typeof useState<T>>[1];
|
|||
type SettingsContext = {
|
||||
fogEnabled: boolean;
|
||||
setFogEnabled: StateSetter<boolean>;
|
||||
highQualityFog: boolean;
|
||||
setHighQualityFog: StateSetter<boolean>;
|
||||
fov: number;
|
||||
setFov: StateSetter<number>;
|
||||
audioEnabled: boolean;
|
||||
|
|
@ -37,6 +39,7 @@ const ControlsContext = createContext<ControlsContext | null>(null);
|
|||
|
||||
type PersistedSettings = {
|
||||
fogEnabled?: boolean;
|
||||
highQualityFog?: boolean;
|
||||
speedMultiplier?: number;
|
||||
fov?: number;
|
||||
audioEnabled?: boolean;
|
||||
|
|
@ -58,6 +61,7 @@ export function useControls() {
|
|||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [fogEnabled, setFogEnabled] = useState(true);
|
||||
const [highQualityFog, setHighQualityFog] = useState(false);
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState(1);
|
||||
const [fov, setFov] = useState(90);
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
|
|
@ -68,6 +72,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
() => ({
|
||||
fogEnabled,
|
||||
setFogEnabled,
|
||||
highQualityFog,
|
||||
setHighQualityFog,
|
||||
fov,
|
||||
setFov,
|
||||
audioEnabled,
|
||||
|
|
@ -75,7 +81,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
animationEnabled,
|
||||
setAnimationEnabled,
|
||||
}),
|
||||
[fogEnabled, speedMultiplier, fov, audioEnabled, animationEnabled],
|
||||
[fogEnabled, highQualityFog, fov, audioEnabled, animationEnabled],
|
||||
);
|
||||
|
||||
const debugContext: DebugContext = useMemo(
|
||||
|
|
@ -108,6 +114,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
if (savedSettings.fogEnabled != null) {
|
||||
setFogEnabled(savedSettings.fogEnabled);
|
||||
}
|
||||
if (savedSettings.highQualityFog != null) {
|
||||
setHighQualityFog(savedSettings.highQualityFog);
|
||||
}
|
||||
if (savedSettings.speedMultiplier != null) {
|
||||
setSpeedMultiplier(savedSettings.speedMultiplier);
|
||||
}
|
||||
|
|
@ -129,6 +138,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
saveTimerRef.current = setTimeout(() => {
|
||||
const settingsToSave: PersistedSettings = {
|
||||
fogEnabled,
|
||||
highQualityFog,
|
||||
speedMultiplier,
|
||||
fov,
|
||||
audioEnabled,
|
||||
|
|
@ -149,6 +159,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
}, [
|
||||
fogEnabled,
|
||||
highQualityFog,
|
||||
speedMultiplier,
|
||||
fov,
|
||||
audioEnabled,
|
||||
|
|
|
|||
|
|
@ -1,49 +1,26 @@
|
|||
import { 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, ShaderMaterial, BackSide, ShaderChunk } from "three";
|
||||
import { Color, Fog } from "three";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { getFloat, getInt, getProperty } from "../mission";
|
||||
import { getInt, getProperty } from "../mission";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
import { BASE_URL, loadDetailMapList, textureToUrl } from "../loaders";
|
||||
import { CloudLayers } from "./CloudLayers";
|
||||
import { parseFogState, type FogState, type FogVolume } from "./FogProvider";
|
||||
import { installCustomFogShader } from "../fogShader";
|
||||
import {
|
||||
globalFogUniforms,
|
||||
updateGlobalFogUniforms,
|
||||
packFogVolumeData,
|
||||
resetGlobalFogUniforms,
|
||||
} from "../globalFogUniforms";
|
||||
|
||||
const FALLBACK_TEXTURE_URL = `${BASE_URL}/black.png`;
|
||||
|
||||
/**
|
||||
* Tribes 2 fog formula (from sceneState.cc getHaze):
|
||||
* fogScale = 1.0 / (visibleDistance - fogDistance)
|
||||
* distFactor = (dist - fogDistance) * fogScale - 1.0
|
||||
* haze = 1.0 - distFactor * distFactor
|
||||
*
|
||||
* This creates an "ease-in" quadratic curve where fog builds slowly at first,
|
||||
* then accelerates toward visibleDistance.
|
||||
*
|
||||
* Set USE_QUADRATIC_FOG to true to use this formula, false to use Three.js linear fog.
|
||||
*/
|
||||
const USE_QUADRATIC_FOG = false;
|
||||
|
||||
function installQuadraticFogShader() {
|
||||
ShaderChunk.fog_fragment = `
|
||||
#ifdef USE_FOG
|
||||
float fogFactor = 0.0;
|
||||
if (vFogDepth > fogNear) {
|
||||
if (vFogDepth >= fogFar) {
|
||||
fogFactor = 1.0;
|
||||
} else {
|
||||
float fogScale = 1.0 / (fogFar - fogNear);
|
||||
float distFactor = (vFogDepth - fogNear) * fogScale - 1.0;
|
||||
fogFactor = 1.0 - distFactor * distFactor;
|
||||
}
|
||||
}
|
||||
gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);
|
||||
#endif
|
||||
`;
|
||||
}
|
||||
|
||||
if (USE_QUADRATIC_FOG) {
|
||||
installQuadraticFogShader();
|
||||
}
|
||||
// Track if fog shader has been installed (idempotent installation)
|
||||
let fogShaderInstalled = false;
|
||||
|
||||
/**
|
||||
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
|
||||
|
|
@ -70,12 +47,225 @@ function useDetailMapList(name: string) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
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],
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
return (
|
||||
<mesh renderOrder={-1000} frustumCulled={false}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
array={new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0])}
|
||||
count={3}
|
||||
itemSize={3}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-uv"
|
||||
array={new Float32Array([0, 0, 2, 0, 0, 2])}
|
||||
count={3}
|
||||
itemSize={2}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<shaderMaterial
|
||||
uniforms={{
|
||||
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: horizonFogHeight },
|
||||
}}
|
||||
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);
|
||||
|
||||
|
|
@ -90,89 +280,121 @@ export function SkyBox({
|
|||
textureToUrl(detailMapList[0]), // +z
|
||||
textureToUrl(detailMapList[2]), // -z
|
||||
]
|
||||
: [
|
||||
FALLBACK_TEXTURE_URL,
|
||||
FALLBACK_TEXTURE_URL,
|
||||
FALLBACK_TEXTURE_URL,
|
||||
FALLBACK_TEXTURE_URL,
|
||||
FALLBACK_TEXTURE_URL,
|
||||
FALLBACK_TEXTURE_URL,
|
||||
],
|
||||
: null,
|
||||
[detailMapList],
|
||||
);
|
||||
|
||||
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
|
||||
|
||||
const materialRef = useRef<ShaderMaterial>(null!);
|
||||
|
||||
const shaderMaterial = useMemo(() => {
|
||||
// Always use a shader to apply the X-axis mirror transformation.
|
||||
// Optionally blend fog toward the horizon.
|
||||
return new ShaderMaterial({
|
||||
uniforms: {
|
||||
skybox: { value: skyBox },
|
||||
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
|
||||
enableFog: { value: !!fogColor },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec3 vDirection;
|
||||
|
||||
void main() {
|
||||
vDirection = position;
|
||||
vec4 pos = projectionMatrix * mat4(mat3(modelViewMatrix)) * vec4(position, 1.0);
|
||||
gl_Position = pos.xyww;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform samplerCube skybox;
|
||||
uniform vec3 fogColor;
|
||||
uniform bool enableFog;
|
||||
|
||||
varying vec3 vDirection;
|
||||
|
||||
void main() {
|
||||
vec3 direction = normalize(vDirection);
|
||||
// Swap X and Z, negate X to mirror across X axis
|
||||
direction = vec3(direction.z, direction.y, -direction.x);
|
||||
vec4 skyColor = textureCube(skybox, direction);
|
||||
|
||||
if (enableFog) {
|
||||
// Fog increases toward and below horizon
|
||||
// direction.y: -1 = straight down, 0 = horizon, 1 = straight up
|
||||
// Use smoothstep for gradual transition (matches Three.js linear fog feel)
|
||||
float fogFactor = 1.0 - smoothstep(-0.1, 0.5, direction.y);
|
||||
vec3 finalColor = mix(skyColor.rgb, fogColor, fogFactor);
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
} else {
|
||||
gl_FragColor = skyColor;
|
||||
}
|
||||
}
|
||||
`,
|
||||
side: BackSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
}, [skyBox, fogColor]);
|
||||
|
||||
// Update uniforms when props change (ensures reactivity)
|
||||
useEffect(() => {
|
||||
if (materialRef.current) {
|
||||
materialRef.current.uniforms.skybox.value = skyBox;
|
||||
materialRef.current.uniforms.fogColor.value =
|
||||
fogColor ?? new Color(0, 0, 0);
|
||||
materialRef.current.uniforms.enableFog.value = !!fogColor;
|
||||
}
|
||||
}, [skyBox, fogColor]);
|
||||
// Don't render until we have real texture URLs
|
||||
if (!skyBoxFiles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<mesh scale={5000} frustumCulled={false}>
|
||||
<sphereGeometry args={[1, 60, 40]} />
|
||||
<primitive ref={materialRef} object={shaderMaterial} attach="material" />
|
||||
</mesh>
|
||||
<SkyBoxTexture
|
||||
skyBoxFiles={skyBoxFiles}
|
||||
fogColor={fogColor}
|
||||
fogState={fogState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }: { fogState: FogState }) {
|
||||
const { scene, camera } = useThree();
|
||||
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]);
|
||||
|
||||
// Update fog parameters each frame based on camera height
|
||||
useFrame(() => {
|
||||
const fog = fogRef.current;
|
||||
if (!fog) return;
|
||||
|
||||
const cameraHeight = camera.position.y;
|
||||
|
||||
// Update Three.js basic fog
|
||||
const [near, far] = calculateFogParameters(fogState, cameraHeight);
|
||||
fog.near = near;
|
||||
fog.far = far;
|
||||
fog.color.copy(fogState.fogColor);
|
||||
|
||||
// Update global fog uniforms for volumetric fog shaders
|
||||
updateGlobalFogUniforms(cameraHeight, fogVolumeData);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Sky({ object }: { object: TorqueObject }) {
|
||||
const { fogEnabled } = useSettings();
|
||||
const { fogEnabled, highQualityFog } = useSettings();
|
||||
|
||||
// Skybox textures
|
||||
const materialList = getProperty(object, "materialList");
|
||||
|
|
@ -184,50 +406,13 @@ export function Sky({ object }: { object: TorqueObject }) {
|
|||
|
||||
const useSkyTextures = getInt(object, "useSkyTextures") ?? 1;
|
||||
|
||||
// Fog parameters - Tribes 2 uses fogDistance (near) and visibleDistance (far)
|
||||
// high_* variants are used for high quality settings (-1 or 0 means use normal)
|
||||
const fogDistanceBase = getFloat(object, "fogDistance");
|
||||
const visibleDistanceBase = getFloat(object, "visibleDistance");
|
||||
const highFogDistance = getFloat(object, "high_fogDistance");
|
||||
const highVisibleDistance = getFloat(object, "high_visibleDistance");
|
||||
|
||||
// Parse fog volumes - format: "visibleDistance minHeight maxHeight"
|
||||
// These define height-based fog bands with different densities
|
||||
const fogVolume1 = useMemo(() => {
|
||||
const value = getProperty(object, "fogVolume1");
|
||||
if (value) {
|
||||
const [visibleDistance, minHeight, maxHeight] = value
|
||||
.split(" ")
|
||||
.map((s: string) => parseFloat(s));
|
||||
// Only valid if visibleDistance > 0 and has a height range
|
||||
if (visibleDistance > 0 && maxHeight > minHeight) {
|
||||
return { visibleDistance, minHeight, maxHeight };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [object]);
|
||||
|
||||
// Use high quality values if available and valid (> 0)
|
||||
const baseFogNear =
|
||||
highFogDistance != null && highFogDistance > 0
|
||||
? highFogDistance
|
||||
: fogDistanceBase;
|
||||
const baseFogFar =
|
||||
highVisibleDistance != null && highVisibleDistance > 0
|
||||
? highVisibleDistance
|
||||
: visibleDistanceBase;
|
||||
|
||||
// If fogVolume1 is defined, use denser fog
|
||||
// Torque's fog volumes ADD density on top of base fog - objects inside
|
||||
// a fog volume get significantly more haze. We approximate this by
|
||||
// using a fraction of the volume's visibleDistance.
|
||||
const fogNear = fogVolume1
|
||||
? Math.min(baseFogNear ?? Infinity, fogVolume1.visibleDistance * 0.25)
|
||||
: baseFogNear;
|
||||
const fogFar = fogVolume1
|
||||
? Math.min(baseFogFar ?? Infinity, fogVolume1.visibleDistance * 0.9)
|
||||
: baseFogFar;
|
||||
// Parse full fog state from Sky object using FogProvider's parser
|
||||
const fogState = useMemo(
|
||||
() => parseFogState(object, highQualityFog),
|
||||
[object, highQualityFog],
|
||||
);
|
||||
|
||||
// Get sRGB fog color for background
|
||||
const fogColor = useMemo(
|
||||
() => parseColorString(getProperty(object, "fogColor")),
|
||||
[object],
|
||||
|
|
@ -235,34 +420,52 @@ export function Sky({ object }: { object: TorqueObject }) {
|
|||
|
||||
const skyColor = skySolidColor || fogColor;
|
||||
|
||||
const backgroundColor = skyColor ? (
|
||||
<color attach="background" args={[skyColor[0]]} />
|
||||
) : null;
|
||||
// Only enable fog if we have valid distance parameters
|
||||
const hasFogParams = fogState.enabled && fogEnabled;
|
||||
|
||||
// Only enable fog if we have valid near/far distances
|
||||
const hasFogParams = fogNear != null && fogFar != null && fogFar > fogNear;
|
||||
// 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 { scene, gl } = useThree();
|
||||
useEffect(() => {
|
||||
if (hasFogParams) {
|
||||
// Use effective fog color for background (matches terrain fog)
|
||||
const bgColor = effectiveFogColor.clone();
|
||||
scene.background = bgColor;
|
||||
// Also set the renderer clear color as a fallback
|
||||
gl.setClearColor(bgColor);
|
||||
} else if (skyColor) {
|
||||
const bgColor = skyColor[0].clone();
|
||||
scene.background = bgColor;
|
||||
gl.setClearColor(bgColor);
|
||||
} else {
|
||||
scene.background = null;
|
||||
}
|
||||
return () => {
|
||||
scene.background = null;
|
||||
};
|
||||
}, [scene, gl, hasFogParams, effectiveFogColor, skyColor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{materialList && useSkyTextures ? (
|
||||
<Suspense fallback={backgroundColor}>
|
||||
<Suspense fallback={null}>
|
||||
{/* Key forces remount when mission changes to clear texture caches */}
|
||||
<SkyBox
|
||||
key={materialList}
|
||||
materialList={materialList}
|
||||
fogColor={fogEnabled && hasFogParams ? fogColor?.[1] : undefined}
|
||||
fogColor={hasFogParams ? effectiveFogColor : undefined}
|
||||
fogState={hasFogParams ? fogState : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
// If there's no material list or skybox textures are disabled,
|
||||
// render solid background
|
||||
backgroundColor
|
||||
)}
|
||||
) : null}
|
||||
{/* Cloud layers render independently of skybox textures */}
|
||||
<Suspense>
|
||||
<CloudLayers object={object} />
|
||||
</Suspense>
|
||||
{fogEnabled && hasFogParams && fogColor ? (
|
||||
<fog attach="fog" color={fogColor[1]} near={fogNear!} far={fogFar!} />
|
||||
) : null}
|
||||
{hasFogParams ? <DynamicFog fogState={fogState} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,9 @@ export function Sun({ object }: { object: TorqueObject }) {
|
|||
return new Color(r, g, b);
|
||||
}, [object]);
|
||||
|
||||
// Lighting intensities - terrain and shapes need good directional + ambient balance
|
||||
const directionalIntensity = 1.8;
|
||||
// Base lighting intensities - neutral baseline, each object type applies its own multipliers
|
||||
// See lightingConfig.ts for per-object-type adjustments
|
||||
const directionalIntensity = 1.0;
|
||||
const ambientIntensity = 1.0;
|
||||
|
||||
// Shadow camera covers the entire terrain (Tribes 2 terrains are typically 2048+ units)
|
||||
|
|
@ -58,15 +59,16 @@ export function Sun({ object }: { object: TorqueObject }) {
|
|||
color={color}
|
||||
intensity={directionalIntensity}
|
||||
castShadow
|
||||
shadow-mapSize-width={4096}
|
||||
shadow-mapSize-height={4096}
|
||||
shadow-mapSize-width={8192}
|
||||
shadow-mapSize-height={8192}
|
||||
shadow-camera-left={-shadowCameraSize}
|
||||
shadow-camera-right={shadowCameraSize}
|
||||
shadow-camera-top={shadowCameraSize}
|
||||
shadow-camera-bottom={-shadowCameraSize}
|
||||
shadow-camera-near={100}
|
||||
shadow-camera-far={12000}
|
||||
shadow-bias={-0.001}
|
||||
shadow-bias={-0.0003}
|
||||
shadow-normalBias={0.5}
|
||||
/>
|
||||
{/* Ambient fill light - prevents pure black shadows */}
|
||||
<ambientLight color={ambient} intensity={ambientIntensity} />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import {
|
||||
DataTexture,
|
||||
FloatType,
|
||||
LinearFilter,
|
||||
NearestFilter,
|
||||
NoColorSpace,
|
||||
ClampToEdgeWrapping,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
RedFormat,
|
||||
RepeatWrapping,
|
||||
UnsignedByteType,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { getFloat, getInt, getPosition, getProperty } from "../mission";
|
||||
|
|
@ -23,6 +25,238 @@ import { useSceneObject } from "./useSceneObject";
|
|||
const DEFAULT_SQUARE_SIZE = 8;
|
||||
const DEFAULT_VISIBLE_DISTANCE = 600;
|
||||
const TERRAIN_SIZE = 256;
|
||||
const LIGHTMAP_SIZE = 512; // Match Tribes 2's 512x512 lightmap
|
||||
const HEIGHT_SCALE = 2048; // Matches displacementScale for terrain
|
||||
|
||||
/**
|
||||
* Displace terrain vertices on CPU and compute smooth normals from heightmap gradients.
|
||||
*
|
||||
* Height sampling uses NEAREST filtering to match the GPU DataTexture default:
|
||||
* texel = floor(uv * textureWidth), clamped to valid range.
|
||||
*
|
||||
* Normals use bilinear interpolation for smooth gradients, preventing banding
|
||||
* that would occur with face normals from computeVertexNormals().
|
||||
*/
|
||||
function displaceTerrainAndComputeNormals(
|
||||
geometry: PlaneGeometry,
|
||||
heightMap: Uint16Array,
|
||||
squareSize: number,
|
||||
): void {
|
||||
const posAttr = geometry.attributes.position;
|
||||
const uvAttr = geometry.attributes.uv;
|
||||
const normalAttr = geometry.attributes.normal;
|
||||
const positions = posAttr.array as Float32Array;
|
||||
const uvs = uvAttr.array as Float32Array;
|
||||
const normals = normalAttr.array as Float32Array;
|
||||
const vertexCount = posAttr.count;
|
||||
|
||||
// Helper to get height at heightmap coordinates with clamping (integer coords)
|
||||
const getHeightInt = (col: number, row: number): number => {
|
||||
col = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
|
||||
row = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
|
||||
return (heightMap[row * TERRAIN_SIZE + col] / 65535) * HEIGHT_SCALE;
|
||||
};
|
||||
|
||||
// Helper to get bilinearly interpolated height (matches GPU texture sampling)
|
||||
const getHeight = (col: number, row: number): number => {
|
||||
col = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
|
||||
row = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
|
||||
|
||||
const col0 = Math.floor(col);
|
||||
const row0 = Math.floor(row);
|
||||
const col1 = Math.min(col0 + 1, TERRAIN_SIZE - 1);
|
||||
const row1 = Math.min(row0 + 1, TERRAIN_SIZE - 1);
|
||||
|
||||
const fx = col - col0;
|
||||
const fy = row - row0;
|
||||
|
||||
const h00 = (heightMap[row0 * TERRAIN_SIZE + col0] / 65535) * HEIGHT_SCALE;
|
||||
const h10 = (heightMap[row0 * TERRAIN_SIZE + col1] / 65535) * HEIGHT_SCALE;
|
||||
const h01 = (heightMap[row1 * TERRAIN_SIZE + col0] / 65535) * HEIGHT_SCALE;
|
||||
const h11 = (heightMap[row1 * TERRAIN_SIZE + col1] / 65535) * HEIGHT_SCALE;
|
||||
|
||||
// Bilinear interpolation
|
||||
const h0 = h00 * (1 - fx) + h10 * fx;
|
||||
const h1 = h01 * (1 - fx) + h11 * fx;
|
||||
return h0 * (1 - fy) + h1 * fy;
|
||||
};
|
||||
|
||||
// Process each vertex
|
||||
for (let i = 0; i < vertexCount; i++) {
|
||||
const u = uvs[i * 2];
|
||||
const v = uvs[i * 2 + 1];
|
||||
|
||||
// Map UV to heightmap coordinates - must match Torque's terrain sampling.
|
||||
// Torque formula: floor(worldPos / squareSize) & BlockMask
|
||||
// UV 0→1 maps to world 0→2048, squareSize=8, so: floor(UV * 256) & 255
|
||||
// This wraps at edges for seamless terrain tiling.
|
||||
const col = Math.floor(u * TERRAIN_SIZE) & (TERRAIN_SIZE - 1);
|
||||
const row = Math.floor(v * TERRAIN_SIZE) & (TERRAIN_SIZE - 1);
|
||||
|
||||
// Use direct integer sampling to match GPU nearest-neighbor filtering
|
||||
const height = getHeightInt(col, row);
|
||||
positions[i * 3 + 1] = height;
|
||||
|
||||
// Compute normal using central differences on heightmap with smooth interpolation.
|
||||
// Use fractional coordinates for gradient sampling to get smooth normals.
|
||||
const colF = u * (TERRAIN_SIZE - 1);
|
||||
const rowF = v * (TERRAIN_SIZE - 1);
|
||||
const hL = getHeight(colF - 1, rowF); // left
|
||||
const hR = getHeight(colF + 1, rowF); // right
|
||||
const hD = getHeight(colF, rowF + 1); // down (increasing row)
|
||||
const hU = getHeight(colF, rowF - 1); // up (decreasing row)
|
||||
|
||||
// Gradients in heightmap space (col increases = +U, row increases = +V)
|
||||
const dCol = (hR - hL) / 2; // height change per column
|
||||
const dRow = (hD - hU) / 2; // height change per row
|
||||
|
||||
// Now map heightmap gradients to world-space normal
|
||||
// After rotateX(-PI/2) and rotateY(-PI/2):
|
||||
// - U direction (col) maps to world +Z
|
||||
// - V direction (row) maps to world +X
|
||||
//
|
||||
// For heightfield normal: n = normalize(-dh/dx, 1, -dh/dz) in world space
|
||||
// But we need the normal to face outward (toward the viewer), so use positive signs
|
||||
let nx = dRow;
|
||||
let ny = squareSize;
|
||||
let nz = dCol;
|
||||
|
||||
// Normalize
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||
if (len > 0) {
|
||||
nx /= len;
|
||||
ny /= len;
|
||||
nz /= len;
|
||||
} else {
|
||||
nx = 0;
|
||||
ny = 1;
|
||||
nz = 0;
|
||||
}
|
||||
|
||||
normals[i * 3] = nx;
|
||||
normals[i * 3 + 1] = ny;
|
||||
normals[i * 3 + 2] = nz;
|
||||
}
|
||||
|
||||
posAttr.needsUpdate = true;
|
||||
normalAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a terrain lightmap texture with smooth normals.
|
||||
*
|
||||
* The key insight: banding occurs because vertex normals are computed from
|
||||
* discrete heightmap samples, creating discontinuities at grid boundaries.
|
||||
*
|
||||
* Solution: Compute normals from BILINEARLY INTERPOLATED heights at each
|
||||
* lightmap pixel. This produces smooth gradients because the interpolated
|
||||
* height surface is C0 continuous (no discontinuities).
|
||||
*
|
||||
* @param heightMap - Uint16 heightmap data (256x256)
|
||||
* @param sunDirection - Normalized sun direction vector (points FROM sun TO scene)
|
||||
* @param squareSize - World units per heightmap cell
|
||||
* @returns DataTexture with lighting intensity values
|
||||
*/
|
||||
function generateTerrainLightmap(
|
||||
heightMap: Uint16Array,
|
||||
sunDirection: Vector3,
|
||||
squareSize: number,
|
||||
): DataTexture {
|
||||
// Helper to get bilinearly interpolated height at any fractional position
|
||||
// Supports negative and out-of-range coordinates via wrapping
|
||||
const getInterpolatedHeight = (col: number, row: number): number => {
|
||||
// Wrap to valid range using modulo (handles negative values correctly)
|
||||
const wrappedCol = ((col % TERRAIN_SIZE) + TERRAIN_SIZE) % TERRAIN_SIZE;
|
||||
const wrappedRow = ((row % TERRAIN_SIZE) + TERRAIN_SIZE) % TERRAIN_SIZE;
|
||||
|
||||
const col0 = Math.floor(wrappedCol);
|
||||
const row0 = Math.floor(wrappedRow);
|
||||
const col1 = (col0 + 1) & (TERRAIN_SIZE - 1); // Wrap at edge
|
||||
const row1 = (row0 + 1) & (TERRAIN_SIZE - 1);
|
||||
|
||||
const fx = wrappedCol - col0;
|
||||
const fy = wrappedRow - row0;
|
||||
|
||||
const h00 = heightMap[row0 * TERRAIN_SIZE + col0] / 65535;
|
||||
const h10 = heightMap[row0 * TERRAIN_SIZE + col1] / 65535;
|
||||
const h01 = heightMap[row1 * TERRAIN_SIZE + col0] / 65535;
|
||||
const h11 = heightMap[row1 * TERRAIN_SIZE + col1] / 65535;
|
||||
|
||||
// Bilinear interpolation
|
||||
const h0 = h00 * (1 - fx) + h10 * fx;
|
||||
const h1 = h01 * (1 - fx) + h11 * fx;
|
||||
return (h0 * (1 - fy) + h1 * fy) * HEIGHT_SCALE;
|
||||
};
|
||||
|
||||
// Light direction (negate sun direction since it points FROM sun)
|
||||
const lightDir = new Vector3(
|
||||
-sunDirection.x,
|
||||
-sunDirection.y,
|
||||
-sunDirection.z,
|
||||
).normalize();
|
||||
|
||||
const lightmapData = new Uint8Array(LIGHTMAP_SIZE * LIGHTMAP_SIZE);
|
||||
|
||||
// Epsilon for gradient sampling (in heightmap units)
|
||||
// Use 0.5 to sample across a reasonable distance for smooth gradients
|
||||
const eps = 0.5;
|
||||
|
||||
// Generate lightmap by computing normal from interpolated heights at each pixel
|
||||
for (let lRow = 0; lRow < LIGHTMAP_SIZE; lRow++) {
|
||||
for (let lCol = 0; lCol < LIGHTMAP_SIZE; lCol++) {
|
||||
// Generate texel for terrain position matching Torque's relight():
|
||||
// Torque starts at halfStep (0.25) within each square, not at corner.
|
||||
// With 2 lightmap pixels per terrain square: pos = lCol/2 + 0.25
|
||||
const col = lCol / 2 + 0.25;
|
||||
const row = lRow / 2 + 0.25;
|
||||
|
||||
// Compute gradient using central differences on interpolated heights
|
||||
const hL = getInterpolatedHeight(col - eps, row);
|
||||
const hR = getInterpolatedHeight(col + eps, row);
|
||||
const hU = getInterpolatedHeight(col, row - eps);
|
||||
const hD = getInterpolatedHeight(col, row + eps);
|
||||
|
||||
// Gradient in heightmap units
|
||||
const dCol = (hR - hL) / (2 * eps);
|
||||
const dRow = (hD - hU) / (2 * eps);
|
||||
|
||||
// Convert to world-space normal - must match displaceTerrainAndComputeNormals
|
||||
// After geometry rotations: U (col) → +Z, V (row) → +X
|
||||
const nx = -dRow;
|
||||
const ny = squareSize;
|
||||
const nz = -dCol;
|
||||
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||
|
||||
// Compute NdotL
|
||||
const NdotL = Math.max(
|
||||
0,
|
||||
(nx / len) * lightDir.x +
|
||||
(ny / len) * lightDir.y +
|
||||
(nz / len) * lightDir.z,
|
||||
);
|
||||
|
||||
lightmapData[lRow * LIGHTMAP_SIZE + lCol] = Math.floor(NdotL * 255);
|
||||
}
|
||||
}
|
||||
|
||||
const texture = new DataTexture(
|
||||
lightmapData,
|
||||
LIGHTMAP_SIZE,
|
||||
LIGHTMAP_SIZE,
|
||||
RedFormat,
|
||||
UnsignedByteType,
|
||||
);
|
||||
texture.colorSpace = NoColorSpace;
|
||||
texture.generateMipmaps = true;
|
||||
texture.wrapS = ClampToEdgeWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.minFilter = LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a .ter file, used for terrain heightmap and texture info.
|
||||
|
|
@ -112,16 +346,45 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
return value ? value.split(" ").map((s: string) => parseInt(s, 10)) : [];
|
||||
}, [object]);
|
||||
|
||||
// Shared geometry for all tiles
|
||||
const { data: terrain } = useTerrain(terrainFile);
|
||||
|
||||
// Shared geometry for all tiles - with smooth normals computed from heightmap
|
||||
const sharedGeometry = useMemo(() => {
|
||||
if (!terrain) return null;
|
||||
|
||||
const size = squareSize * 256;
|
||||
const geometry = new PlaneGeometry(size, size, 256, 256);
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
geometry.rotateY(-Math.PI / 2);
|
||||
return geometry;
|
||||
}, [squareSize]);
|
||||
|
||||
const { data: terrain } = useTerrain(terrainFile);
|
||||
// Displace vertices on CPU and compute smooth normals
|
||||
displaceTerrainAndComputeNormals(geometry, terrain.heightMap, squareSize);
|
||||
|
||||
return geometry;
|
||||
}, [squareSize, terrain]);
|
||||
|
||||
// Get sun direction for lightmap generation
|
||||
const sun = useSceneObject("Sun");
|
||||
const sunDirection = useMemo(() => {
|
||||
if (!sun) return new Vector3(0.57735, -0.57735, 0.57735); // Default diagonal
|
||||
const directionStr =
|
||||
getProperty(sun, "direction") ?? "0.57735 0.57735 -0.57735";
|
||||
const [tx, ty, tz] = directionStr
|
||||
.split(" ")
|
||||
.map((s: string) => parseFloat(s));
|
||||
// Convert Torque (X, Y, Z) to Three.js: swap Y/Z
|
||||
const x = tx;
|
||||
const y = tz;
|
||||
const z = ty;
|
||||
const len = Math.sqrt(x * x + y * y + z * z);
|
||||
return new Vector3(x / len, y / len, z / len);
|
||||
}, [sun]);
|
||||
|
||||
// Generate terrain lightmap for smooth per-pixel lighting
|
||||
const terrainLightmap = useMemo(() => {
|
||||
if (!terrain) return null;
|
||||
return generateTerrainLightmap(terrain.heightMap, sunDirection, squareSize);
|
||||
}, [terrain, sunDirection, squareSize]);
|
||||
|
||||
// Shared displacement map from heightmap - created once for all tiles
|
||||
const sharedDisplacementMap = useMemo(() => {
|
||||
|
|
@ -218,7 +481,12 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
setTileAssignments(newAssignments);
|
||||
});
|
||||
|
||||
if (!terrain || !sharedDisplacementMap || !sharedAlphaTextures) {
|
||||
if (
|
||||
!terrain ||
|
||||
!sharedGeometry ||
|
||||
!sharedDisplacementMap ||
|
||||
!sharedAlphaTextures
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +504,7 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
visibilityMask={primaryVisibilityMask}
|
||||
alphaTextures={sharedAlphaTextures}
|
||||
detailTextureName={detailTexture}
|
||||
lightmap={terrainLightmap}
|
||||
/>
|
||||
{/* Pooled tiles - stable keys, always mounted */}
|
||||
{poolIndices.map((poolIndex) => {
|
||||
|
|
@ -253,6 +522,7 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
visibilityMask={pooledVisibilityMask}
|
||||
alphaTextures={sharedAlphaTextures}
|
||||
detailTextureName={detailTexture}
|
||||
lightmap={terrainLightmap}
|
||||
visible={assignment !== null}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
import { setupColor } from "../textureUtils";
|
||||
import { updateTerrainTextureShader } from "../terrainMaterial";
|
||||
import { useDebug } from "./SettingsProvider";
|
||||
import { injectCustomFog } from "../fogShader";
|
||||
import { globalFogUniforms } from "../globalFogUniforms";
|
||||
|
||||
const DEFAULT_SQUARE_SIZE = 8;
|
||||
|
||||
|
|
@ -39,6 +41,7 @@ interface TerrainTileProps {
|
|||
visibilityMask: DataTexture;
|
||||
alphaTextures: DataTexture[];
|
||||
detailTextureName?: string;
|
||||
lightmap?: DataTexture;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -48,12 +51,14 @@ function BlendedTerrainTextures({
|
|||
textureNames,
|
||||
alphaTextures,
|
||||
detailTextureName,
|
||||
lightmap,
|
||||
}: {
|
||||
displacementMap: DataTexture;
|
||||
visibilityMask: DataTexture;
|
||||
textureNames: string[];
|
||||
alphaTextures: DataTexture[];
|
||||
detailTextureName?: string;
|
||||
lightmap?: DataTexture;
|
||||
}) {
|
||||
const { debugMode } = useDebug();
|
||||
|
||||
|
|
@ -86,7 +91,11 @@ function BlendedTerrainTextures({
|
|||
tiling: TILING,
|
||||
debugMode,
|
||||
detailTexture: detailTextureUrl ? detailTexture : null,
|
||||
lightmap,
|
||||
});
|
||||
|
||||
// Inject volumetric fog using global uniforms
|
||||
injectCustomFog(shader, globalFogUniforms);
|
||||
},
|
||||
[
|
||||
baseTextures,
|
||||
|
|
@ -95,22 +104,25 @@ function BlendedTerrainTextures({
|
|||
debugMode,
|
||||
detailTexture,
|
||||
detailTextureUrl,
|
||||
lightmap,
|
||||
],
|
||||
);
|
||||
|
||||
// Key must include factors that change shader code structure (not just uniforms)
|
||||
// - debugMode: affects fragment shader branching
|
||||
// - detailTextureUrl: affects vertex shader (adds varying) and fragment shader
|
||||
const materialKey = `${debugMode ? "debug" : "normal"}-${detailTextureUrl ? "detail" : "nodetail"}`;
|
||||
// - lightmap: affects shader structure (uses lightmap for NdotL instead of vertex normals)
|
||||
const materialKey = `${debugMode ? "debug" : "normal"}-${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
|
||||
|
||||
// Displacement is done on CPU, so no displacementMap needed
|
||||
// We keep 'map' to provide UV coordinates for shader (vMapUv)
|
||||
// Use MeshLambertMaterial for compatibility with shadow maps
|
||||
return (
|
||||
<meshLambertMaterial
|
||||
key={materialKey}
|
||||
displacementMap={displacementMap}
|
||||
map={displacementMap}
|
||||
displacementScale={2048}
|
||||
depthWrite
|
||||
side={debugMode ? DoubleSide : FrontSide}
|
||||
side={DoubleSide}
|
||||
onBeforeCompile={onBeforeCompile}
|
||||
/>
|
||||
);
|
||||
|
|
@ -122,12 +134,14 @@ function TerrainMaterial({
|
|||
textureNames,
|
||||
alphaTextures,
|
||||
detailTextureName,
|
||||
lightmap,
|
||||
}: {
|
||||
displacementMap: DataTexture;
|
||||
visibilityMask: DataTexture;
|
||||
textureNames: string[];
|
||||
alphaTextures: DataTexture[];
|
||||
detailTextureName?: string;
|
||||
lightmap?: DataTexture;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
|
|
@ -146,6 +160,7 @@ function TerrainMaterial({
|
|||
textureNames={textureNames}
|
||||
alphaTextures={alphaTextures}
|
||||
detailTextureName={detailTextureName}
|
||||
lightmap={lightmap}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -162,6 +177,7 @@ export const TerrainTile = memo(function TerrainTile({
|
|||
visibilityMask,
|
||||
alphaTextures,
|
||||
detailTextureName,
|
||||
lightmap,
|
||||
visible = true,
|
||||
}: TerrainTileProps) {
|
||||
const position = useMemo(() => {
|
||||
|
|
@ -189,6 +205,7 @@ export const TerrainTile = memo(function TerrainTile({
|
|||
textureNames={textureNames}
|
||||
alphaTextures={alphaTextures}
|
||||
detailTextureName={detailTextureName}
|
||||
lightmap={lightmap}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { DoubleSide, PlaneGeometry, RepeatWrapping } from "three";
|
||||
import { DoubleSide, NoColorSpace, PlaneGeometry, RepeatWrapping } from "three";
|
||||
import { textureToUrl } from "../loaders";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||
|
|
@ -67,6 +67,10 @@ export function WaterSurfaceMaterial({
|
|||
const texArray = Array.isArray(textures) ? textures : [textures];
|
||||
texArray.forEach((tex) => {
|
||||
setupColor(tex);
|
||||
// Use NoColorSpace for water textures - our custom ShaderMaterial
|
||||
// outputs values that are already in the correct space for display.
|
||||
// Using SRGBColorSpace would cause double-conversion.
|
||||
tex.colorSpace = NoColorSpace;
|
||||
tex.wrapS = RepeatWrapping;
|
||||
tex.wrapT = RepeatWrapping;
|
||||
});
|
||||
|
|
|
|||
322
src/fogShader.ts
Normal file
322
src/fogShader.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* Custom fog shader code for Tribes 2-style fog rendering.
|
||||
*
|
||||
* Based on the V12/Torque engine fog system used in Tribes 2 (circa 2001).
|
||||
* See Tribes2_Fog_System.md for complete documentation.
|
||||
*
|
||||
* Implements:
|
||||
* - Quadratic distance-based haze (Torque's getHaze formula)
|
||||
* - Height-based fog volumes with ray-marching accumulation
|
||||
*
|
||||
* Key insight from Torque source: Fog volumes ADD fog based on distance
|
||||
* traveled through each volume, they don't replace the global fog parameters.
|
||||
*/
|
||||
|
||||
import { ShaderChunk } from "three";
|
||||
|
||||
/**
|
||||
* Fog uniform declarations for fragment shaders.
|
||||
* Add this to the top of fragment shaders that need fog.
|
||||
*/
|
||||
export const fogUniformsDeclaration = `
|
||||
#ifdef USE_FOG
|
||||
uniform vec3 fogColor;
|
||||
uniform float fogNear;
|
||||
uniform float fogFar;
|
||||
|
||||
// Volumetric fog: 3 volumes, 4 floats each
|
||||
// [visDist, minHeight, maxHeight, percentage]
|
||||
// Note: Per-volume colors not used ($specialFog = false), all fog uses fogColor
|
||||
uniform float fogVolumeData[12];
|
||||
uniform float cameraHeight;
|
||||
uniform bool hasVolumetricFog;
|
||||
#endif
|
||||
`;
|
||||
|
||||
/**
|
||||
* Custom fog fragment shader that implements Torque's fog system.
|
||||
* Replaces Three.js default fog_fragment chunk.
|
||||
*
|
||||
* Torque fog algorithm (from sceneState.cc getHazeAndFog):
|
||||
*
|
||||
* 1. HAZE (distance-based):
|
||||
* - No fog if dist <= fogDistance
|
||||
* - Full fog if dist > visibleDistance
|
||||
* - Otherwise: quadratic curve using formula:
|
||||
* distFactor = (dist - fogDistance) * fogScale - 1.0
|
||||
* haze = 1.0 - distFactor * distFactor
|
||||
* where fogScale = 1.0 / (visibleDistance - fogDistance)
|
||||
*
|
||||
* 2. FOG VOLUMES (height-based):
|
||||
* - Each volume has a fog factor = (1 / visibleDistance) * percentage
|
||||
* - Ray-march from camera to fragment, accumulating fog through each volume
|
||||
* - Use similar triangles: subDist = dist * (heightInVolume / totalDeltaZ)
|
||||
* - Fog contribution = subDist * factor
|
||||
* - Sum all volume contributions
|
||||
*
|
||||
* 3. Final fog = clamp(haze + volumeFog, 0, 1)
|
||||
*/
|
||||
export const fogFragmentShader = `
|
||||
#ifdef USE_FOG
|
||||
float dist = vFogDepth;
|
||||
|
||||
// Discard fragments at or beyond visible distance - matches Torque's behavior
|
||||
// where objects beyond visibleDistance are not rendered at all.
|
||||
// This prevents fully-fogged geometry from showing as silhouettes against
|
||||
// the sky's fog-to-sky gradient.
|
||||
if (dist >= fogFar) {
|
||||
discard;
|
||||
}
|
||||
|
||||
// Step 1: Calculate distance-based haze (quadratic falloff)
|
||||
// Since we discard at fogFar, haze never reaches 1.0 here
|
||||
float haze = 0.0;
|
||||
if (dist > fogNear) {
|
||||
float fogScale = 1.0 / (fogFar - fogNear);
|
||||
float distFactor = (dist - fogNear) * fogScale - 1.0;
|
||||
haze = 1.0 - distFactor * distFactor;
|
||||
}
|
||||
|
||||
// Step 2: Calculate fog volume contributions
|
||||
// Note: Per-volume colors are NOT used in Tribes 2 ($specialFog defaults to false)
|
||||
// All fog uses the global fogColor - see Tribes2_Fog_System.md for details
|
||||
float volumeFog = 0.0;
|
||||
|
||||
#ifdef USE_VOLUMETRIC_FOG
|
||||
{
|
||||
#ifdef USE_FOG_WORLD_POSITION
|
||||
float fragmentHeight = vFogWorldPosition.y;
|
||||
#else
|
||||
float fragmentHeight = cameraHeight;
|
||||
#endif
|
||||
|
||||
float deltaY = fragmentHeight - cameraHeight;
|
||||
float absDeltaY = abs(deltaY);
|
||||
|
||||
// Determine if we're going up (positive) or down (negative)
|
||||
if (absDeltaY > 0.01) {
|
||||
// Non-horizontal ray: ray-march through fog volumes
|
||||
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];
|
||||
|
||||
// Skip inactive volumes (visibleDistance = 0)
|
||||
if (volVisDist <= 0.0) continue;
|
||||
|
||||
// Calculate fog factor for this volume
|
||||
// From Torque: factor = (1 / (volumeVisDist * visFactor)) * percentage
|
||||
// where visFactor is smVisibleDistanceMod (a user quality pref, default 1.0)
|
||||
// Since we don't have quality settings, we use visFactor = 1.0
|
||||
float factor = (1.0 / volVisDist) * volPct;
|
||||
|
||||
// Find ray intersection with this volume's height range
|
||||
float rayMinY = min(cameraHeight, fragmentHeight);
|
||||
float rayMaxY = max(cameraHeight, fragmentHeight);
|
||||
|
||||
// Check if ray intersects volume height range
|
||||
if (rayMinY < volMaxH && rayMaxY > volMinH) {
|
||||
float intersectMin = max(rayMinY, volMinH);
|
||||
float intersectMax = min(rayMaxY, volMaxH);
|
||||
float intersectHeight = intersectMax - intersectMin;
|
||||
|
||||
// Calculate distance traveled through this volume using similar triangles:
|
||||
// subDist / dist = intersectHeight / absDeltaY
|
||||
float subDist = dist * (intersectHeight / absDeltaY);
|
||||
|
||||
// Accumulate fog: fog += subDist * factor
|
||||
volumeFog += subDist * factor;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Near-horizontal ray: if camera is inside a volume, apply full fog for that volume
|
||||
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 camera is inside this volume, apply fog for full distance
|
||||
if (cameraHeight >= volMinH && cameraHeight <= volMaxH) {
|
||||
float factor = (1.0 / volVisDist) * volPct;
|
||||
volumeFog += dist * factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Step 3: Combine haze and volume fog
|
||||
// Torque's clamping: if (bandPct + hazePct > 1) hazePct = 1 - bandPct
|
||||
// This gives fog volumes priority over haze
|
||||
float volPct = min(volumeFog, 1.0);
|
||||
float hazePct = haze;
|
||||
if (volPct + hazePct > 1.0) {
|
||||
hazePct = 1.0 - volPct;
|
||||
}
|
||||
float fogFactor = hazePct + volPct;
|
||||
|
||||
// Apply fog using global fogColor (per-volume colors not used in Tribes 2)
|
||||
gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);
|
||||
#endif
|
||||
`;
|
||||
|
||||
/**
|
||||
* Vertex shader code to pass world position for fog calculation.
|
||||
*/
|
||||
export const fogVertexShader = `
|
||||
#ifdef USE_FOG
|
||||
#define USE_FOG_WORLD_POSITION
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif
|
||||
`;
|
||||
|
||||
export const fogVertexShaderWorldPos = `
|
||||
#ifdef USE_FOG
|
||||
vFogWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||
#endif
|
||||
`;
|
||||
|
||||
/**
|
||||
* Install custom fog shaders globally.
|
||||
* Call this once at app startup to replace Three.js default fog.
|
||||
*/
|
||||
export function installCustomFogShader(): void {
|
||||
// Note: This modifies global shader chunks, affecting all materials
|
||||
// For more control, use onBeforeCompile on individual materials
|
||||
|
||||
ShaderChunk.fog_pars_fragment = `
|
||||
#ifdef USE_FOG
|
||||
uniform vec3 fogColor;
|
||||
varying float vFogDepth;
|
||||
#ifdef FOG_EXP2
|
||||
uniform float fogDensity;
|
||||
#else
|
||||
uniform float fogNear;
|
||||
uniform float fogFar;
|
||||
#endif
|
||||
|
||||
// Custom volumetric fog uniforms (only defined when USE_VOLUMETRIC_FOG is set)
|
||||
// Format: [visDist, minH, maxH, percentage] x 3 volumes = 12 floats
|
||||
#ifdef USE_VOLUMETRIC_FOG
|
||||
uniform float fogVolumeData[12];
|
||||
uniform float cameraHeight;
|
||||
#endif
|
||||
|
||||
#ifdef USE_FOG_WORLD_POSITION
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif
|
||||
#endif
|
||||
`;
|
||||
|
||||
ShaderChunk.fog_fragment = fogFragmentShader;
|
||||
|
||||
// Add world position output to vertex shader
|
||||
ShaderChunk.fog_pars_vertex = `
|
||||
#ifdef USE_FOG
|
||||
varying float vFogDepth;
|
||||
#ifdef USE_FOG_WORLD_POSITION
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif
|
||||
#endif
|
||||
`;
|
||||
|
||||
ShaderChunk.fog_vertex = `
|
||||
#ifdef USE_FOG
|
||||
// Use Euclidean distance from camera, not view-space z-depth
|
||||
// This ensures fog doesn't change when rotating the camera
|
||||
vFogDepth = length(mvPosition.xyz);
|
||||
#ifdef USE_FOG_WORLD_POSITION
|
||||
vFogWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
|
||||
#endif
|
||||
#endif
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared fog shader uniform objects interface.
|
||||
* These objects are passed directly to shaders so uniform values can be updated per-frame.
|
||||
*/
|
||||
export interface FogShaderUniformObjects {
|
||||
fogVolumeData: { value: Float32Array };
|
||||
cameraHeight: { value: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add fog uniforms to a shader via onBeforeCompile.
|
||||
* Use this for materials that need custom fog without modifying global chunks.
|
||||
*
|
||||
* @param shader - The shader object from onBeforeCompile
|
||||
* @param fogUniforms - Shared uniform objects (pass the objects, not values)
|
||||
*/
|
||||
export function addFogUniformsToShader(
|
||||
shader: { uniforms: Record<string, { value: unknown }> },
|
||||
fogUniforms: FogShaderUniformObjects,
|
||||
): void {
|
||||
// Pass the uniform objects directly so they stay linked to FogProvider updates
|
||||
shader.uniforms.fogVolumeData = fogUniforms.fogVolumeData;
|
||||
shader.uniforms.cameraHeight = fogUniforms.cameraHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject custom fog code into a material's shader.
|
||||
* Call this in material's onBeforeCompile callback.
|
||||
* This enables full volumetric fog support for the material.
|
||||
*
|
||||
* @param shader - The shader object from onBeforeCompile
|
||||
* @param fogUniforms - Shared uniform objects from globalFogUniforms
|
||||
*/
|
||||
export function injectCustomFog(
|
||||
shader: {
|
||||
uniforms: Record<string, { value: unknown }>;
|
||||
vertexShader: string;
|
||||
fragmentShader: string;
|
||||
},
|
||||
fogUniforms: FogShaderUniformObjects,
|
||||
): void {
|
||||
// Add uniforms - pass objects directly so they stay linked
|
||||
addFogUniformsToShader(shader, fogUniforms);
|
||||
|
||||
// Add world position varying to vertex shader
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <fog_pars_vertex>",
|
||||
`#include <fog_pars_vertex>
|
||||
#ifdef USE_FOG
|
||||
#define USE_FOG_WORLD_POSITION
|
||||
#define USE_VOLUMETRIC_FOG
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif`,
|
||||
);
|
||||
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <fog_vertex>",
|
||||
`#include <fog_vertex>
|
||||
#ifdef USE_FOG
|
||||
vFogWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
|
||||
#endif`,
|
||||
);
|
||||
|
||||
// Add volumetric fog uniforms to fragment shader
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <fog_pars_fragment>",
|
||||
`#include <fog_pars_fragment>
|
||||
#ifdef USE_FOG
|
||||
#define USE_VOLUMETRIC_FOG
|
||||
uniform float fogVolumeData[12];
|
||||
uniform float cameraHeight;
|
||||
#define USE_FOG_WORLD_POSITION
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif`,
|
||||
);
|
||||
|
||||
// Replace fog fragment with custom implementation
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <fog_fragment>",
|
||||
fogFragmentShader,
|
||||
);
|
||||
}
|
||||
|
|
@ -22,9 +22,10 @@ import {
|
|||
Vector2,
|
||||
} from "three";
|
||||
|
||||
// Opacity multiplier to compensate for DoubleSide rendering both front and back faces.
|
||||
// In Tribes 2, back faces were often occluded by surrounding geometry (door frames, walls).
|
||||
export const OPACITY_FACTOR = 0.5;
|
||||
// Opacity multiplier - set to 1.0 to match Tribes 2's baseTranslucency directly.
|
||||
// Previously 0.5 to compensate for DoubleSide, but this made force fields too dim.
|
||||
// Tribes 2 used the full baseTranslucency value even though back faces could render.
|
||||
export const OPACITY_FACTOR = 1.0;
|
||||
|
||||
// Vertex shader
|
||||
const vertexShader = `
|
||||
|
|
@ -78,13 +79,10 @@ void main() {
|
|||
}
|
||||
|
||||
// Tribes 2 GL_MODULATE: output = texture * vertexColor
|
||||
// No gamma correction - textures use NoColorSpace and values pass through
|
||||
// directly to display, matching how WaterBlock handles sRGB textures.
|
||||
vec3 modulatedColor = texColor.rgb * tintColor;
|
||||
|
||||
// Gamma correction: T2 textures were authored for CRT displays (~2.2 gamma).
|
||||
// Converting to linear space makes them appear as they did on those displays.
|
||||
// This significantly darkens the colors to match the original look.
|
||||
modulatedColor = pow(modulatedColor, vec3(2.2));
|
||||
|
||||
float adjustedOpacity = opacity * opacityFactor;
|
||||
|
||||
gl_FragColor = vec4(modulatedColor, adjustedOpacity);
|
||||
|
|
@ -92,12 +90,19 @@ void main() {
|
|||
// Custom fog for additive blending: fade out rather than blend to fog color.
|
||||
// Standard fog (mix toward fogColor) doesn't work with additive blending
|
||||
// because we'd still be adding fogColor to the framebuffer.
|
||||
// Uses Torque's quadratic haze formula for consistency.
|
||||
#ifdef USE_FOG
|
||||
#ifdef FOG_EXP2
|
||||
float fogFactor = 1.0 - exp(-fogDensity * fogDensity * vFogDepth * vFogDepth);
|
||||
#else
|
||||
float fogFactor = smoothstep(fogNear, fogFar, vFogDepth);
|
||||
#endif
|
||||
float dist = vFogDepth;
|
||||
float fogFactor = 0.0;
|
||||
if (dist > fogNear) {
|
||||
if (dist >= fogFar) {
|
||||
fogFactor = 1.0;
|
||||
} else {
|
||||
float fogScale = 1.0 / (fogFar - fogNear);
|
||||
float distFactor = (dist - fogNear) * fogScale - 1.0;
|
||||
fogFactor = 1.0 - distFactor * distFactor;
|
||||
}
|
||||
}
|
||||
gl_FragColor.a *= 1.0 - fogFactor;
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
79
src/globalFogUniforms.ts
Normal file
79
src/globalFogUniforms.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Global fog shader uniforms that can be shared across all materials.
|
||||
*
|
||||
* This module provides a singleton set of fog uniforms that:
|
||||
* 1. Sky component updates each frame with camera height and fog volume data
|
||||
* 2. Materials reference directly via import (avoiding React context issues)
|
||||
*
|
||||
* The uniform objects themselves are stable - only their .value properties change.
|
||||
* This allows Three.js materials to reference them once and get automatic updates.
|
||||
*/
|
||||
|
||||
const MAX_FOG_VOLUMES = 3;
|
||||
/** Floats per fog volume: [visDist, minH, maxH, percentage] */
|
||||
const FLOATS_PER_VOLUME = 4;
|
||||
|
||||
/**
|
||||
* Shared fog shader uniform objects.
|
||||
* Materials should import and use these directly in onBeforeCompile.
|
||||
*/
|
||||
export const globalFogUniforms = {
|
||||
fogVolumeData: {
|
||||
value: new Float32Array(MAX_FOG_VOLUMES * FLOATS_PER_VOLUME),
|
||||
},
|
||||
cameraHeight: { value: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the global fog uniforms with new values.
|
||||
* Called by Sky component each frame.
|
||||
*/
|
||||
export function updateGlobalFogUniforms(
|
||||
cameraHeight: number,
|
||||
fogVolumeData: Float32Array,
|
||||
): void {
|
||||
globalFogUniforms.cameraHeight.value = cameraHeight;
|
||||
globalFogUniforms.fogVolumeData.value.set(fogVolumeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset global fog uniforms to default values.
|
||||
* Called when Sky unmounts to clean up fog state for next mission.
|
||||
*/
|
||||
export function resetGlobalFogUniforms(): void {
|
||||
globalFogUniforms.cameraHeight.value = 0;
|
||||
globalFogUniforms.fogVolumeData.value.fill(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack fog volume data into a flat array for shaders.
|
||||
* Format: [visDist, minH, maxH, percentage] x 3 = 12 floats
|
||||
*
|
||||
* Note: Per-volume colors are NOT used in Tribes 2 ($specialFog defaults to false).
|
||||
* All fog uses the global fogColor, so we don't pack color data.
|
||||
*/
|
||||
export function packFogVolumeData(
|
||||
fogVolumes: Array<{
|
||||
visibleDistance: number;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
percentage: number;
|
||||
}>,
|
||||
): Float32Array {
|
||||
const data = new Float32Array(MAX_FOG_VOLUMES * FLOATS_PER_VOLUME);
|
||||
|
||||
for (let i = 0; i < MAX_FOG_VOLUMES; i++) {
|
||||
const offset = i * FLOATS_PER_VOLUME;
|
||||
const vol = fogVolumes[i];
|
||||
|
||||
if (vol) {
|
||||
data[offset + 0] = vol.visibleDistance;
|
||||
data[offset + 1] = vol.minHeight;
|
||||
data[offset + 2] = vol.maxHeight;
|
||||
data[offset + 3] = vol.percentage;
|
||||
}
|
||||
// Inactive volumes default to 0 (Float32Array is zero-initialized)
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
37
src/interiorMaterial.ts
Normal file
37
src/interiorMaterial.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Interior material shader modifications.
|
||||
* Injects per-object-type lighting multipliers into MeshLambertMaterial.
|
||||
*/
|
||||
|
||||
import { INTERIOR_LIGHTING } from "./lightingConfig";
|
||||
|
||||
/**
|
||||
* Inject lighting multipliers into a MeshLambertMaterial shader.
|
||||
* Call this from onBeforeCompile after other shader modifications (e.g., fog).
|
||||
*/
|
||||
export function injectInteriorLighting(shader: any): void {
|
||||
// Add lighting multiplier uniforms
|
||||
shader.uniforms.interiorDirectionalFactor = {
|
||||
value: INTERIOR_LIGHTING.directional,
|
||||
};
|
||||
shader.uniforms.interiorAmbientFactor = { value: INTERIOR_LIGHTING.ambient };
|
||||
|
||||
// Declare uniforms in fragment shader
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
uniform float interiorDirectionalFactor;
|
||||
uniform float interiorAmbientFactor;
|
||||
`,
|
||||
);
|
||||
|
||||
// Scale directional light contribution
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <lights_fragment_end>",
|
||||
`#include <lights_fragment_end>
|
||||
// Apply interior-specific lighting multipliers
|
||||
reflectedLight.directDiffuse *= interiorDirectionalFactor;
|
||||
reflectedLight.indirectDiffuse *= interiorAmbientFactor;
|
||||
`,
|
||||
);
|
||||
}
|
||||
33
src/lightingConfig.ts
Normal file
33
src/lightingConfig.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Per-object-type lighting intensity multipliers.
|
||||
*
|
||||
* These allow fine-tuning of directional and ambient light contributions
|
||||
* for each object type to match Tribes 2's original appearance.
|
||||
*
|
||||
* The Torque engine uses distinctly different lighting systems for each object type:
|
||||
* - Terrain: Pre-baked lightmaps with directional sun dot-product calculation
|
||||
* - Interiors: Vertex colors or lightmaps with animated light states
|
||||
* - DTS Shapes: OpenGL hardware lighting with per-material flags
|
||||
*
|
||||
* Values are multipliers applied to the global Sun intensity (which is set to 1.0/1.0):
|
||||
* - directional: Multiplier for directional (sun) light contribution
|
||||
* - ambient: Multiplier for ambient light contribution (affects shadow darkness)
|
||||
*/
|
||||
|
||||
export const TERRAIN_LIGHTING = {
|
||||
directional: 4,
|
||||
ambient: 1.5,
|
||||
};
|
||||
|
||||
export const INTERIOR_LIGHTING = {
|
||||
directional: 3,
|
||||
ambient: 1,
|
||||
};
|
||||
|
||||
export const SHAPE_LIGHTING = {
|
||||
directional: 1,
|
||||
ambient: 1.5,
|
||||
};
|
||||
|
||||
// Note: Water does not use lighting - Tribes 2's Phase 2 (lightmap) is disabled.
|
||||
// Water textures are rendered directly without scene lighting.
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
/**
|
||||
* Shape material utilities and shader modifications.
|
||||
*/
|
||||
|
||||
import {
|
||||
MeshStandardMaterial,
|
||||
Texture,
|
||||
|
|
@ -6,6 +10,38 @@ import {
|
|||
LinearMipmapLinearFilter,
|
||||
SRGBColorSpace,
|
||||
} from "three";
|
||||
import { SHAPE_LIGHTING } from "./lightingConfig";
|
||||
|
||||
/**
|
||||
* Inject lighting multipliers into a MeshLambertMaterial or MeshBasicMaterial shader.
|
||||
* Call this from onBeforeCompile after other shader modifications (e.g., fog).
|
||||
*/
|
||||
export function injectShapeLighting(shader: any): void {
|
||||
// Add lighting multiplier uniforms
|
||||
shader.uniforms.shapeDirectionalFactor = {
|
||||
value: SHAPE_LIGHTING.directional,
|
||||
};
|
||||
shader.uniforms.shapeAmbientFactor = { value: SHAPE_LIGHTING.ambient };
|
||||
|
||||
// Declare uniforms in fragment shader
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
uniform float shapeDirectionalFactor;
|
||||
uniform float shapeAmbientFactor;
|
||||
`,
|
||||
);
|
||||
|
||||
// Scale directional and ambient light contributions
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <lights_fragment_end>",
|
||||
`#include <lights_fragment_end>
|
||||
// Apply shape-specific lighting multipliers
|
||||
reflectedLight.directDiffuse *= shapeDirectionalFactor;
|
||||
reflectedLight.indirectDiffuse *= shapeAmbientFactor;
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// Shared shader modification function to avoid duplication
|
||||
const alphaAsRoughnessShaderModifier = (shader: any) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@
|
|||
* Handles multi-layer texture blending for Tribes 2 terrain rendering.
|
||||
*/
|
||||
|
||||
import { TERRAIN_LIGHTING } from "./lightingConfig";
|
||||
|
||||
// Terrain and texture dimensions (must match TerrainBlock.tsx constants)
|
||||
const TERRAIN_SIZE = 256; // Terrain grid size in squares
|
||||
const LIGHTMAP_SIZE = 512; // Lightmap texture size (2 pixels per terrain square)
|
||||
|
||||
// Texture brightness scale to prevent clipping and preserve shadow visibility
|
||||
const TEXTURE_BRIGHTNESS_SCALE = 0.7;
|
||||
|
||||
// Detail texture tiling factor.
|
||||
// Torque uses world-space generation: U = worldX * (62.0 / textureWidth)
|
||||
// For 256px texture across 2048 world units, this gives ~496 repeats mathematically.
|
||||
|
|
@ -24,6 +33,7 @@ export function updateTerrainTextureShader({
|
|||
tiling,
|
||||
debugMode = false,
|
||||
detailTexture = null,
|
||||
lightmap = null,
|
||||
}: {
|
||||
shader: any;
|
||||
baseTextures: any[];
|
||||
|
|
@ -32,9 +42,16 @@ export function updateTerrainTextureShader({
|
|||
tiling: Record<number, number>;
|
||||
debugMode?: boolean;
|
||||
detailTexture?: any;
|
||||
lightmap?: any;
|
||||
}) {
|
||||
const layerCount = baseTextures.length;
|
||||
|
||||
// Add terrain lighting multiplier uniforms
|
||||
shader.uniforms.terrainDirectionalFactor = {
|
||||
value: TERRAIN_LIGHTING.directional,
|
||||
};
|
||||
shader.uniforms.terrainAmbientFactor = { value: TERRAIN_LIGHTING.ambient };
|
||||
|
||||
baseTextures.forEach((tex, i) => {
|
||||
shader.uniforms[`albedo${i}`] = { value: tex };
|
||||
});
|
||||
|
|
@ -60,6 +77,11 @@ export function updateTerrainTextureShader({
|
|||
// Add debug mode uniform
|
||||
shader.uniforms.debugMode = { value: debugMode ? 1.0 : 0.0 };
|
||||
|
||||
// Add lightmap uniform for smooth per-pixel terrain lighting
|
||||
if (lightmap) {
|
||||
shader.uniforms.terrainLightmap = { value: lightmap };
|
||||
}
|
||||
|
||||
// Add detail texture uniforms
|
||||
if (detailTexture) {
|
||||
shader.uniforms.detailTexture = { value: detailTexture };
|
||||
|
|
@ -82,6 +104,8 @@ vTerrainWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`,
|
|||
// Declare our uniforms at the top of the fragment shader
|
||||
shader.fragmentShader =
|
||||
`
|
||||
uniform float terrainDirectionalFactor;
|
||||
uniform float terrainAmbientFactor;
|
||||
uniform sampler2D albedo0;
|
||||
uniform sampler2D albedo1;
|
||||
uniform sampler2D albedo2;
|
||||
|
|
@ -101,6 +125,7 @@ uniform float tiling4;
|
|||
uniform float tiling5;
|
||||
uniform float debugMode;
|
||||
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
|
||||
${lightmap ? "uniform sampler2D terrainLightmap;" : ""}
|
||||
${
|
||||
detailTexture
|
||||
? `uniform sampler2D detailTexture;
|
||||
|
|
@ -169,11 +194,14 @@ float getWireframe(vec2 uv, float gridSize, float lineWidth) {
|
|||
}
|
||||
|
||||
// Sample linear masks (use R channel)
|
||||
float a1 = texture2D(mask1, baseUv).r;
|
||||
${layerCount > 1 ? `float a2 = texture2D(mask2, baseUv).r;` : ""}
|
||||
${layerCount > 2 ? `float a3 = texture2D(mask3, baseUv).r;` : ""}
|
||||
${layerCount > 3 ? `float a4 = texture2D(mask4, baseUv).r;` : ""}
|
||||
${layerCount > 4 ? `float a5 = texture2D(mask5, baseUv).r;` : ""}
|
||||
// Add +0.5 texel offset: Torque samples alpha at grid corners (integer indices),
|
||||
// but GPU linear filtering samples at texel centers. This offset aligns them.
|
||||
vec2 alphaUv = baseUv + vec2(0.5 / ${TERRAIN_SIZE}.0);
|
||||
float a1 = texture2D(mask1, alphaUv).r;
|
||||
${layerCount > 1 ? `float a2 = texture2D(mask2, alphaUv).r;` : ""}
|
||||
${layerCount > 2 ? `float a3 = texture2D(mask3, alphaUv).r;` : ""}
|
||||
${layerCount > 3 ? `float a4 = texture2D(mask4, alphaUv).r;` : ""}
|
||||
${layerCount > 4 ? `float a5 = texture2D(mask5, alphaUv).r;` : ""}
|
||||
|
||||
// Bottom-up compositing: each mask tells how much the higher layer replaces lower
|
||||
${layerCount > 1 ? `vec3 blended = mix(c0, c1, clamp(a1, 0.0, 1.0));` : ""}
|
||||
|
|
@ -203,25 +231,83 @@ float getWireframe(vec2 uv, float gridSize, float lineWidth) {
|
|||
: ""
|
||||
}
|
||||
|
||||
// Debug mode wireframe handling
|
||||
// Apply texture color or debug mode solid gray
|
||||
if (debugMode > 0.5) {
|
||||
// 256 grid cells across the terrain (matches terrain resolution)
|
||||
float wireframe = getWireframe(baseUv, 256.0, 1.0);
|
||||
vec3 wireColor = vec3(0.0, 0.8, 0.4); // Green wireframe
|
||||
|
||||
if (gl_FrontFacing) {
|
||||
// Front face: show textures with barely visible wireframe overlay
|
||||
diffuseColor.rgb = mix(textureColor, wireColor, wireframe * 0.05);
|
||||
} else {
|
||||
// Back face: show only wireframe, discard non-wireframe pixels
|
||||
if (wireframe < 0.1) {
|
||||
discard;
|
||||
}
|
||||
diffuseColor.rgb = mix(vec3(0.0), wireColor, 0.25);
|
||||
}
|
||||
// Solid gray to visualize lighting only (without texture influence)
|
||||
diffuseColor.rgb = vec3(0.5);
|
||||
} else {
|
||||
diffuseColor.rgb = textureColor;
|
||||
// Scale texture to prevent clipping, preserving shadow visibility
|
||||
diffuseColor.rgb = textureColor * ${TEXTURE_BRIGHTNESS_SCALE};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// When lightmap is available, replace vertex normal-based lighting with smooth lightmap
|
||||
// This eliminates banding by using pre-computed per-pixel NdotL values
|
||||
if (lightmap) {
|
||||
// Override the RE_Direct_Lambert function to use our lightmap NdotL
|
||||
// instead of computing dotNL from vertex normals
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <lights_lambert_pars_fragment>",
|
||||
`#include <lights_lambert_pars_fragment>
|
||||
|
||||
// Override RE_Direct to use terrain lightmap for smooth NdotL
|
||||
#undef RE_Direct
|
||||
void RE_Direct_TerrainLightmap( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {
|
||||
|
||||
// Sample pre-computed terrain lightmap (smooth NdotL values)
|
||||
// Add +0.5 texel offset to align GPU texel-center sampling with Torque's corner sampling
|
||||
vec2 lightmapUv = vMapUv + vec2(0.5 / ${LIGHTMAP_SIZE}.0);
|
||||
float lightmapNdotL = texture2D(terrainLightmap, lightmapUv).r;
|
||||
|
||||
// Use lightmap NdotL instead of dot(geometryNormal, directLight.direction)
|
||||
// directLight.color already has shadow factor applied from getShadow()
|
||||
// Apply terrain-specific directional intensity multiplier
|
||||
vec3 directIrradiance = lightmapNdotL * directLight.color * terrainDirectionalFactor;
|
||||
|
||||
// Debug mode: visualize raw lightmap values (no textures)
|
||||
if (debugMode > 0.5) {
|
||||
reflectedLight.directDiffuse = directIrradiance;
|
||||
} else {
|
||||
reflectedLight.directDiffuse += directIrradiance * BRDF_Lambert( material.diffuseColor );
|
||||
}
|
||||
}
|
||||
#define RE_Direct RE_Direct_TerrainLightmap
|
||||
|
||||
`,
|
||||
);
|
||||
|
||||
// Override lights_fragment_begin to fix hemisphere light irradiance calculation
|
||||
// The default uses geometryNormal which causes banding
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <lights_fragment_begin>",
|
||||
`#include <lights_fragment_begin>
|
||||
// Fix: Recalculate irradiance without using vertex normals (causes banding)
|
||||
// Use flat upward normal for hemisphere/light probe calculations
|
||||
#if defined( RE_IndirectDiffuse )
|
||||
{
|
||||
vec3 flatNormal = vec3(0.0, 1.0, 0.0);
|
||||
irradiance = getAmbientLightIrradiance( ambientLightColor );
|
||||
#if defined( USE_LIGHT_PROBES )
|
||||
irradiance += getLightProbeIrradiance( lightProbe, flatNormal );
|
||||
#endif
|
||||
#if ( NUM_HEMI_LIGHTS > 0 )
|
||||
for ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {
|
||||
irradiance += getHemisphereLightIrradiance( hemisphereLights[i], flatNormal );
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// Scale ambient/indirect lighting to darken shadows on terrain
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <lights_fragment_end>",
|
||||
`#include <lights_fragment_end>
|
||||
// Scale indirect (ambient) light to increase shadow contrast on terrain
|
||||
reflectedLight.indirectDiffuse *= terrainAmbientFactor;
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { ShaderMaterial, Texture, DoubleSide, Color } from "three";
|
||||
import { globalFogUniforms } from "./globalFogUniforms";
|
||||
import { fogFragmentShader } from "./fogShader";
|
||||
|
||||
/**
|
||||
* Tribes 2 WaterBlock shader material
|
||||
|
|
@ -21,6 +23,11 @@ import { ShaderMaterial, Texture, DoubleSide, Color } from "three";
|
|||
const vertexShader = /* glsl */ `
|
||||
#include <fog_pars_vertex>
|
||||
|
||||
#ifdef USE_FOG
|
||||
#define USE_FOG_WORLD_POSITION
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uWaveMagnitude;
|
||||
|
||||
|
|
@ -46,6 +53,12 @@ const vertexShader = /* glsl */ `
|
|||
vec3 displaced = position;
|
||||
displaced.y += getWaveHeight(worldPos.xyz);
|
||||
|
||||
// Calculate final world position after displacement for fog
|
||||
#ifdef USE_FOG
|
||||
vec4 displacedWorldPos = modelMatrix * vec4(displaced, 1.0);
|
||||
vFogWorldPosition = displacedWorldPos.xyz;
|
||||
#endif
|
||||
|
||||
// Calculate view vector for environment mapping
|
||||
vViewVector = cameraPosition - worldPos.xyz;
|
||||
vDistance = length(vViewVector);
|
||||
|
|
@ -53,19 +66,36 @@ const vertexShader = /* glsl */ `
|
|||
vec4 mvPosition = viewMatrix * modelMatrix * vec4(displaced, 1.0);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
|
||||
#include <fog_vertex>
|
||||
// Set fog depth (distance from camera) - normally done by fog_vertex include
|
||||
// but we can't use that include because it references 'transformed' which we don't have
|
||||
#ifdef USE_FOG
|
||||
vFogDepth = length(mvPosition.xyz);
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
#include <fog_pars_fragment>
|
||||
|
||||
// Enable volumetric fog (must be defined before fog uniforms)
|
||||
#ifdef USE_FOG
|
||||
#define USE_VOLUMETRIC_FOG
|
||||
#define USE_FOG_WORLD_POSITION
|
||||
#endif
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uOpacity;
|
||||
uniform float uEnvMapIntensity;
|
||||
uniform sampler2D uBaseTexture;
|
||||
uniform sampler2D uEnvMapTexture;
|
||||
|
||||
// Volumetric fog uniforms
|
||||
#ifdef USE_FOG
|
||||
uniform float fogVolumeData[12];
|
||||
uniform float cameraHeight;
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif
|
||||
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec3 vViewVector;
|
||||
varying float vDistance;
|
||||
|
|
@ -95,8 +125,6 @@ const fragmentShader = /* glsl */ `
|
|||
|
||||
void main() {
|
||||
// Calculate base texture UVs using world position (1/48 tiling)
|
||||
// Note: In Three.js Y-up coordinates, the water surface is on the XZ plane
|
||||
// Torque uses Z-up where the surface is XY, so we use xz here
|
||||
vec2 baseUV = vWorldPosition.xz * TEXTURE_SCALE;
|
||||
|
||||
// Phase (time in radians for drift cycle)
|
||||
|
|
@ -110,21 +138,14 @@ const fragmentShader = /* glsl */ `
|
|||
vec2 uv1a = rotateUV(baseUV, radians(30.0));
|
||||
|
||||
// === Phase 1b: Second base texture pass (rotated 60 degrees total, with drift) ===
|
||||
// OpenGL matrix order: glRotatef(60) then glTranslatef(drift) means
|
||||
// the transform is R60 * T, so when applied to UV: R60 * (UV + drift)
|
||||
// Translation is applied first, then rotation.
|
||||
vec2 uv1b = rotateUV(baseUV + vec2(baseDriftX, baseDriftY), radians(60.0));
|
||||
|
||||
// Calculate cross-fade swing value
|
||||
// From engine: A1 = cos((X/Q1 + time/Q2) * 6.0), A2 = sin((Y/Q1 + time/Q2) * 6.28)
|
||||
// Using xz for Three.js Y-up coordinate system
|
||||
float A1 = cos(((vWorldPosition.x / Q1) + (uTime / Q2)) * 6.0);
|
||||
float A2 = sin(((vWorldPosition.z / Q1) + (uTime / Q2)) * TWO_PI);
|
||||
float swing = (A1 + A2) * 0.15 + 0.5;
|
||||
|
||||
// Cross-fade alpha calculation from engine
|
||||
// alpha1a = ((1-swing) * opacity) / (1 - (swing * opacity))
|
||||
// alpha1b = swing * opacity
|
||||
float alpha1a = ((1.0 - swing) * uOpacity) / max(1.0 - (swing * uOpacity), 0.001);
|
||||
float alpha1b = swing * uOpacity;
|
||||
|
||||
|
|
@ -132,53 +153,38 @@ const fragmentShader = /* glsl */ `
|
|||
vec4 texColor1a = texture2D(uBaseTexture, uv1a);
|
||||
vec4 texColor1b = texture2D(uBaseTexture, uv1b);
|
||||
|
||||
// Simulate multi-pass alpha accumulation (screen blend formula)
|
||||
// Pass 1a: framebuffer = tex1a * alpha1a + bg * (1 - alpha1a)
|
||||
// Pass 1b: framebuffer = tex1b * alpha1b + prev * (1 - alpha1b)
|
||||
// Combined alpha = 1 - (1 - alpha1a) * (1 - alpha1b)
|
||||
// Combined alpha and color
|
||||
float combinedAlpha = 1.0 - (1.0 - alpha1a) * (1.0 - alpha1b);
|
||||
|
||||
// Combined color (premultiplied then divided by combined alpha)
|
||||
// color = tex1b * alpha1b + tex1a * alpha1a * (1 - alpha1b)
|
||||
vec3 baseColor = (texColor1a.rgb * alpha1a * (1.0 - alpha1b) + texColor1b.rgb * alpha1b) / max(combinedAlpha, 0.001);
|
||||
|
||||
// === Phase 3: Environment map / specular ===
|
||||
// Reflection UV calculation from engine (fluidQuadTree.cc lines 910-962)
|
||||
// Engine uses eye-to-point vector (point - eye), unnormalized.
|
||||
// vViewVector is camera - worldPos (point-to-eye), so we negate it.
|
||||
// Torque Z-up maps XY to UV; Three.js Y-up maps XZ to UV.
|
||||
vec3 reflectVec = -vViewVector;
|
||||
reflectVec.y = abs(reflectVec.y); // Y is vertical in Three.js (was Z in Torque)
|
||||
reflectVec.y = abs(reflectVec.y);
|
||||
if (reflectVec.y < 0.001) reflectVec.y = 0.001;
|
||||
|
||||
vec2 envUV;
|
||||
if (vDistance < 0.001) {
|
||||
envUV = vec2(0.0);
|
||||
} else {
|
||||
// Standard UV reflection mapping with adjustment to reduce edge emphasis
|
||||
float value = (vDistance - reflectVec.y) / (vDistance * vDistance);
|
||||
envUV.x = reflectVec.x * value;
|
||||
envUV.y = reflectVec.z * value; // Z maps to V in Three.js Y-up
|
||||
envUV.y = reflectVec.z * value;
|
||||
}
|
||||
|
||||
// Convert from [-1,1] to [0,1]
|
||||
envUV = envUV * 0.5 + 0.5;
|
||||
|
||||
// Add time-based wobble to environment map
|
||||
envUV.x += A1 * Q3;
|
||||
envUV.y += A2 * Q3;
|
||||
|
||||
vec4 envColor = texture2D(uEnvMapTexture, envUV);
|
||||
|
||||
// Blend environment map additively (GL_SRC_ALPHA, GL_ONE in original engine)
|
||||
// Engine uses GL_MODULATE with color (1,1,1,envMapIntensity), so texture alpha
|
||||
// is multiplied with intensity before additive blend.
|
||||
vec3 finalColor = baseColor + envColor.rgb * envColor.a * uEnvMapIntensity;
|
||||
|
||||
// Note: Tribes 2 water does NOT use lighting - Phase 2 (lightmap) is disabled
|
||||
// in the original engine. Water colors come directly from textures.
|
||||
|
||||
gl_FragColor = vec4(finalColor, combinedAlpha);
|
||||
|
||||
// Apply scene fog (integrated with Three.js fog system)
|
||||
#include <fog_fragment>
|
||||
// Apply volumetric fog using shared Torque-style fog shader
|
||||
${fogFragmentShader}
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -201,6 +207,9 @@ export function createWaterMaterial(options?: {
|
|||
fogColor: { value: new Color() },
|
||||
fogNear: { value: 1 },
|
||||
fogFar: { value: 2000 },
|
||||
// Volumetric fog uniforms (shared with global fog system)
|
||||
fogVolumeData: globalFogUniforms.fogVolumeData,
|
||||
cameraHeight: globalFogUniforms.cameraHeight,
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue