mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-01-19 12:14:47 +00:00
improve lighting, fog, clouds, force fields
This commit is contained in:
parent
3ba1ce9afd
commit
a4b7021acc
15
app/page.tsx
15
app/page.tsx
|
|
@ -2,7 +2,7 @@
|
|||
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { EffectComposer, N8AO } from "@react-three/postprocessing";
|
||||
import { NoToneMapping, SRGBColorSpace } from "three";
|
||||
import { Mission } from "@/src/components/Mission";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ObserverControls } from "@/src/components/ObserverControls";
|
||||
|
|
@ -18,6 +18,14 @@ import { getMissionList, getMissionInfo } from "@/src/manifest";
|
|||
// stuff too, e.g. missions, terrains, and more. This client is used for those.
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Renderer settings to match Tribes 2's simple rendering pipeline.
|
||||
// Tribes 2 (Torque engine, 2001) worked entirely in gamma/sRGB space with no HDR
|
||||
// or tone mapping. We disable tone mapping and ensure proper sRGB output.
|
||||
const glSettings = {
|
||||
toneMapping: NoToneMapping,
|
||||
outputColorSpace: SRGBColorSpace,
|
||||
};
|
||||
|
||||
function MapInspector() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
|
@ -86,7 +94,7 @@ function MapInspector() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Canvas shadows frameloop="always">
|
||||
<Canvas shadows frameloop="always" gl={glSettings}>
|
||||
<CamerasProvider>
|
||||
<AudioProvider>
|
||||
<Mission
|
||||
|
|
@ -99,9 +107,6 @@ function MapInspector() {
|
|||
<ObserverControls />
|
||||
</AudioProvider>
|
||||
</CamerasProvider>
|
||||
<EffectComposer>
|
||||
<N8AO intensity={3} aoRadius={3} quality="performance" />
|
||||
</EffectComposer>
|
||||
</Canvas>
|
||||
</div>
|
||||
<InspectorControls
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/40a2f59d0c05dc35.js
Normal file
1
docs/_next/static/chunks/40a2f59d0c05dc35.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/a99c02adf7563d85.js
Normal file
1
docs/_next/static/chunks/a99c02adf7563d85.js
Normal file
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/bce28defe6a29ff5.js
Normal file
1
docs/_next/static/chunks/bce28defe6a29ff5.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/f863efae27259b81.js
Normal file
1
docs/_next/static/chunks/f863efae27259b81.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -2,7 +2,7 @@
|
|||
2:I[39756,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"default"]
|
||||
3:I[37457,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"default"]
|
||||
4:I[47257,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"ClientPageRoot"]
|
||||
5:I[31713,["/t2-mapper/_next/static/chunks/544fd4f09b7df0da.js","/t2-mapper/_next/static/chunks/32ef0c8650712240.js","/t2-mapper/_next/static/chunks/d07990f13ea8bb98.js","/t2-mapper/_next/static/chunks/b70f08013a69708a.js"],"default"]
|
||||
5:I[31713,["/t2-mapper/_next/static/chunks/a99c02adf7563d85.js","/t2-mapper/_next/static/chunks/bce28defe6a29ff5.js","/t2-mapper/_next/static/chunks/32ef0c8650712240.js","/t2-mapper/_next/static/chunks/49f75d30e4f6ac74.js"],"default"]
|
||||
8:I[97367,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"OutletBoundary"]
|
||||
a:I[11533,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"AsyncMetadataOutlet"]
|
||||
c:I[97367,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"ViewportBoundary"]
|
||||
|
|
@ -10,7 +10,7 @@ e:I[97367,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"MetadataBounda
|
|||
f:"$Sreact.suspense"
|
||||
11:I[68027,[],"default"]
|
||||
:HL["/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","style"]
|
||||
0:{"P":null,"b":"phNqOyvmceJswVwTPTCoJ","p":"/t2-mapper","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","searchParams":{},"params":{},"promises":["$@6","$@7"]}],[["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/544fd4f09b7df0da.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/32ef0c8650712240.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/d07990f13ea8bb98.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/b70f08013a69708a.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$L9",["$","$La",null,{"promise":"$@b"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Lc",null,{"children":"$Ld"}],null],["$","$Le",null,{"children":["$","div",null,{"hidden":true,"children":["$","$f",null,{"fallback":null,"children":"$L10"}]}]}]]}],false]],"m":"$undefined","G":["$11",[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"s":false,"S":true}
|
||||
0:{"P":null,"b":"Z2orC9Oxj30KOCL7Wakqt","p":"/t2-mapper","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","searchParams":{},"params":{},"promises":["$@6","$@7"]}],[["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/a99c02adf7563d85.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/bce28defe6a29ff5.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/32ef0c8650712240.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/49f75d30e4f6ac74.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$L9",["$","$La",null,{"promise":"$@b"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Lc",null,{"children":"$Ld"}],null],["$","$Le",null,{"children":["$","div",null,{"hidden":true,"children":["$","$f",null,{"fallback":null,"children":"$L10"}]}]}]]}],false]],"m":"$undefined","G":["$11",[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"s":false,"S":true}
|
||||
6:{}
|
||||
7:"$0:f:0:1:2:children:1:props:children:0:props:params"
|
||||
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||
|
|
|
|||
1152
package-lock.json
generated
1152
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -49,6 +49,7 @@
|
|||
"express": "^5.2.0",
|
||||
"peggy": "^5.0.6",
|
||||
"prettier": "^3.7.1",
|
||||
"puppeteer": "^24.32.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "5.9.2",
|
||||
|
|
|
|||
1004
reference/Tribes2_Fog_System.md
Normal file
1004
reference/Tribes2_Fog_System.md
Normal file
File diff suppressed because it is too large
Load diff
114
scripts/screenshot.ts
Normal file
114
scripts/screenshot.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import puppeteer, { type KeyInput } from "puppeteer";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
options: {
|
||||
camera: {
|
||||
type: "string",
|
||||
default: "1",
|
||||
},
|
||||
debug: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
"no-fog": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
const missionName = positionals[0];
|
||||
const cameraNumber = parseInt(values.camera, 10);
|
||||
const debugMode = values.debug;
|
||||
const fogEnabled = !values["no-fog"];
|
||||
|
||||
if (!missionName) {
|
||||
console.error(
|
||||
"Usage: npx tsx scripts/screenshot.ts <missionName> [cameraNumber]",
|
||||
);
|
||||
console.error("Example: npx tsx scripts/screenshot.ts TWL2_WoodyMyrk 1");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cameraKey = String(cameraNumber) as KeyInput;
|
||||
const outputType = "png";
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t2-screenshot-"));
|
||||
const outputPath = path.join(
|
||||
tempDir,
|
||||
`${missionName}.${cameraNumber}.${debugMode ? "debug." : ""}${outputType}`,
|
||||
);
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 900, height: 600 });
|
||||
|
||||
const baseUrl = `http://localhost:3000/t2-mapper/?mission=${encodeURIComponent(missionName)}`;
|
||||
|
||||
await page.evaluateOnNewDocument(
|
||||
(debugMode, fogEnabled) => {
|
||||
localStorage.setItem(
|
||||
"settings",
|
||||
JSON.stringify({
|
||||
fov: 80,
|
||||
audioEnabled: false,
|
||||
animationEnabled: false,
|
||||
debugMode,
|
||||
fogEnabled,
|
||||
}),
|
||||
);
|
||||
},
|
||||
debugMode,
|
||||
fogEnabled,
|
||||
);
|
||||
|
||||
console.log(`Loading: ${baseUrl}`);
|
||||
await page.goto(baseUrl, { waitUntil: "load" });
|
||||
await page.waitForNetworkIdle({ idleTime: 500 });
|
||||
|
||||
const mapViewer = await page.waitForSelector("canvas");
|
||||
if (!mapViewer) {
|
||||
console.error("Could not find canvas element");
|
||||
process.exit(1);
|
||||
}
|
||||
await sleep(50);
|
||||
|
||||
// Close the popover by pressing Escape
|
||||
await page.keyboard.press("Escape");
|
||||
await sleep(50);
|
||||
|
||||
// Hide controls from screenshots while keeping them selectable
|
||||
await page.$eval("#controls", (el: HTMLElement) => {
|
||||
el.style.visibility = "hidden";
|
||||
});
|
||||
|
||||
// Wait for mission to load
|
||||
await page.waitForSelector("#loadingIndicator", { hidden: true });
|
||||
await sleep(500);
|
||||
|
||||
// Select the camera
|
||||
console.log(`Selecting camera: ${cameraNumber}`);
|
||||
await mapViewer.press(cameraKey);
|
||||
await page.waitForNetworkIdle({ idleTime: 250 });
|
||||
await sleep(100);
|
||||
|
||||
// Take screenshot
|
||||
await mapViewer.screenshot({
|
||||
path: outputPath,
|
||||
type: outputType,
|
||||
});
|
||||
|
||||
console.log(`Screenshot saved to: ${outputPath}`);
|
||||
|
||||
await Promise.race([
|
||||
browser.close(),
|
||||
sleep(3000).then(() => browser.process()?.kill("SIGKILL")),
|
||||
]);
|
||||
|
|
@ -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…
Reference in a new issue