mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-02-18 06:03:50 +00:00
WaterBlock tiling to match T2/Torque, improve CLAUDE.md
This commit is contained in:
parent
bcf4f4a1a5
commit
aeda3ca8d5
940 changed files with 1207 additions and 337 deletions
38
src/colorUtils.ts
Normal file
38
src/colorUtils.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Color parsing utilities for Tribes 2 mission files.
|
||||
*
|
||||
* Torque (2001) worked in gamma/sRGB space - colors in mission files are
|
||||
* specified as they should appear on screen. Three.js expects linear colors
|
||||
* for lighting calculations, so convert with .convertSRGBToLinear() when
|
||||
* passing to lit materials.
|
||||
*/
|
||||
import { Color, SRGBColorSpace } from "three";
|
||||
|
||||
/**
|
||||
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
|
||||
* The values are interpreted as sRGB and stored as linear internally by Three.js.
|
||||
*
|
||||
* @param colorString - Space-separated "R G B" or "R G B A" string (0-1 range)
|
||||
* @returns Color (linear internally), or undefined if no string
|
||||
*/
|
||||
export function parseColor(colorString: string | undefined): Color | undefined {
|
||||
if (!colorString) return undefined;
|
||||
const parts = colorString.split(" ").map((s) => parseFloat(s));
|
||||
const [r = 0, g = 0, b = 0] = parts;
|
||||
// Interpret as sRGB, Three.js converts to linear internally
|
||||
return new Color().setRGB(r, g, b, SRGBColorSpace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Tribes 2 color string and convert to linear color space.
|
||||
* Use this when passing colors to Three.js lit materials.
|
||||
*
|
||||
* @param colorString - Space-separated "R G B" or "R G B A" string (0-1 range)
|
||||
* @returns Color in linear space, or undefined if no string
|
||||
*/
|
||||
export function parseColorLinear(
|
||||
colorString: string | undefined,
|
||||
): Color | undefined {
|
||||
const color = parseColor(colorString);
|
||||
return color?.convertSRGBToLinear();
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import type { TorqueObject } from "../torqueScript";
|
|||
import { getFloat, getProperty } from "../mission";
|
||||
import { useDebug, useSettings } from "./SettingsProvider";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const GRID_SIZE = 5;
|
||||
const VERTEX_COUNT = GRID_SIZE * GRID_SIZE;
|
||||
|
||||
|
|
@ -344,8 +346,6 @@ interface CloudLayerProps {
|
|||
speed: number;
|
||||
windDirection: Vector2;
|
||||
layerIndex: number;
|
||||
debugMode: boolean;
|
||||
animationEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -358,11 +358,10 @@ function CloudLayer({
|
|||
speed,
|
||||
windDirection,
|
||||
layerIndex,
|
||||
debugMode,
|
||||
animationEnabled,
|
||||
}: CloudLayerProps) {
|
||||
const materialRef = useRef<ShaderMaterial>(null!);
|
||||
const offsetRef = useRef(new Vector2(0, 0));
|
||||
const { debugMode } = useDebug();
|
||||
const { animationEnabled } = useSettings();
|
||||
const offsetRef = useRef<Vector2 | null>(null);
|
||||
|
||||
// Load cloud texture
|
||||
const texture = useTexture(textureUrl, setupCloudTexture);
|
||||
|
|
@ -376,6 +375,12 @@ function CloudLayer({
|
|||
return createCloudGeometry(radius, centerHeight, innerHeight, edgeHeight);
|
||||
}, [radius, heightPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
geometry.dispose();
|
||||
};
|
||||
}, [geometry]);
|
||||
|
||||
// Create shader material
|
||||
const material = useMemo(() => {
|
||||
return new ShaderMaterial({
|
||||
|
|
@ -393,36 +398,38 @@ function CloudLayer({
|
|||
});
|
||||
}, [texture, debugMode, layerIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
material.dispose();
|
||||
};
|
||||
}, [material]);
|
||||
|
||||
// Animate UV offset for cloud scrolling
|
||||
// From Tribes 2: mOffset = (currentTime - mLastTime) / 32.0 (time in ms)
|
||||
// delta is in seconds, so: delta * 1000 / 32 = delta * 31.25
|
||||
useFrame((_, delta) => {
|
||||
if (!materialRef.current || !animationEnabled) return;
|
||||
useFrame(
|
||||
animationEnabled
|
||||
? (_, delta) => {
|
||||
// Match Tribes 2 timing: deltaTime(ms) / 32
|
||||
const mOffset = (delta * 1000) / 32;
|
||||
|
||||
// Match Tribes 2 timing: deltaTime(ms) / 32
|
||||
const mOffset = (delta * 1000) / 32;
|
||||
offsetRef.current ??= new Vector2(0, 0);
|
||||
|
||||
offsetRef.current.x += windDirection.x * speed * mOffset;
|
||||
offsetRef.current.y += windDirection.y * speed * mOffset;
|
||||
offsetRef.current.x += windDirection.x * speed * mOffset;
|
||||
offsetRef.current.y += windDirection.y * speed * mOffset;
|
||||
|
||||
// Wrap to [0,1] range
|
||||
offsetRef.current.x = offsetRef.current.x - Math.floor(offsetRef.current.x);
|
||||
offsetRef.current.y = offsetRef.current.y - Math.floor(offsetRef.current.y);
|
||||
// Wrap to [0,1] range
|
||||
offsetRef.current.x -= Math.floor(offsetRef.current.x);
|
||||
offsetRef.current.y -= Math.floor(offsetRef.current.y);
|
||||
|
||||
materialRef.current.uniforms.uvOffset.value.copy(offsetRef.current);
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
};
|
||||
}, [geometry, material]);
|
||||
material.uniforms.uvOffset.value.copy(offsetRef.current);
|
||||
}
|
||||
: noop,
|
||||
);
|
||||
|
||||
return (
|
||||
<mesh geometry={geometry} frustumCulled={false} renderOrder={10}>
|
||||
<primitive ref={materialRef} object={material} attach="material" />
|
||||
<primitive object={material} attach="material" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
|
@ -439,7 +446,7 @@ export interface CloudLayerConfig {
|
|||
* 6: Environment map
|
||||
* 7+: Cloud layer textures
|
||||
*/
|
||||
const CLOUD_TEXTURE_OFFSET = 7;
|
||||
const CLOUD_TEXTURE_START_INDEX = 7;
|
||||
|
||||
/**
|
||||
* Hook to load a DML file.
|
||||
|
|
@ -467,8 +474,6 @@ export interface CloudLayersProps {
|
|||
* - windVelocity: Wind direction for cloud movement
|
||||
*/
|
||||
export function CloudLayers({ object }: CloudLayersProps) {
|
||||
const { debugMode } = useDebug();
|
||||
const { animationEnabled } = useSettings();
|
||||
const materialList = getProperty(object, "materialList");
|
||||
const { data: detailMapList } = useDetailMapList(materialList);
|
||||
|
||||
|
|
@ -476,7 +481,6 @@ export function CloudLayers({ object }: CloudLayersProps) {
|
|||
const visibleDistance = getFloat(object, "visibleDistance") ?? 500;
|
||||
const radius = visibleDistance * 0.95;
|
||||
|
||||
// Extract cloud speeds from object (cloudSpeed1/2/3 are scalar values)
|
||||
const cloudSpeeds = useMemo(
|
||||
() => [
|
||||
getFloat(object, "cloudSpeed1") ?? 0.0001,
|
||||
|
|
@ -486,17 +490,14 @@ export function CloudLayers({ object }: CloudLayersProps) {
|
|||
[object],
|
||||
);
|
||||
|
||||
// Extract cloud heights from object
|
||||
// Default heights match typical Tribes 2 values (e.g., 0.35, 0.25, 0.2)
|
||||
const cloudHeights = useMemo(() => {
|
||||
const defaults = [0.35, 0.25, 0.2];
|
||||
const heights: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const height = getFloat(object, `cloudHeightPer${i}`) ?? defaults[i];
|
||||
heights.push(height);
|
||||
}
|
||||
return heights;
|
||||
}, [object]);
|
||||
const cloudHeights = useMemo(
|
||||
() => [
|
||||
getFloat(object, "cloudHeightPer1") ?? 0.35,
|
||||
getFloat(object, "cloudHeightPer2") ?? 0.25,
|
||||
getFloat(object, "cloudHeightPer3") ?? 0.2,
|
||||
],
|
||||
[object],
|
||||
);
|
||||
|
||||
// Wind direction from windVelocity
|
||||
// Torque uses Z-up with windVelocity (x, y, z) where Y is forward.
|
||||
|
|
@ -519,14 +520,13 @@ export function CloudLayers({ object }: CloudLayersProps) {
|
|||
if (!detailMapList) return [];
|
||||
|
||||
const result: CloudLayerConfig[] = [];
|
||||
for (let i = CLOUD_TEXTURE_OFFSET; i < detailMapList.length; i++) {
|
||||
const texture = detailMapList[i];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const texture = detailMapList[CLOUD_TEXTURE_START_INDEX + i];
|
||||
if (texture) {
|
||||
const layerIndex = i - CLOUD_TEXTURE_OFFSET;
|
||||
result.push({
|
||||
texture,
|
||||
height: cloudHeights[layerIndex] ?? 0,
|
||||
speed: cloudSpeeds[layerIndex] ?? 0.0001 * (layerIndex + 1),
|
||||
height: cloudHeights[i],
|
||||
speed: cloudSpeeds[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -562,8 +562,6 @@ export function CloudLayers({ object }: CloudLayersProps) {
|
|||
speed={layer.speed}
|
||||
windDirection={windDirection}
|
||||
layerIndex={i}
|
||||
debugMode={debugMode}
|
||||
animationEnabled={animationEnabled}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@ function createMaterialFromFlags(
|
|||
side: 2, // DoubleSide
|
||||
transparent: isAdditive,
|
||||
alphaTest: isAdditive ? 0 : 0.5,
|
||||
blending: isAdditive ? AdditiveBlending : undefined,
|
||||
fog: true,
|
||||
...(isAdditive && { blending: AdditiveBlending }),
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
return mat;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { memo, Suspense, useMemo, useCallback } from "react";
|
||||
import { memo, Suspense, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import {
|
||||
Mesh,
|
||||
Material,
|
||||
MeshStandardMaterial,
|
||||
MeshBasicMaterial,
|
||||
MeshLambertMaterial,
|
||||
Texture,
|
||||
SRGBColorSpace,
|
||||
} from "three";
|
||||
|
|
@ -58,23 +60,43 @@ function InteriorTexture({
|
|||
injectCustomFog(shader, globalFogUniforms);
|
||||
injectInteriorLighting(shader, {
|
||||
surfaceOutsideVisible: isSurfaceOutsideVisible,
|
||||
debugMode,
|
||||
});
|
||||
},
|
||||
[isSurfaceOutsideVisible, debugMode],
|
||||
[isSurfaceOutsideVisible],
|
||||
);
|
||||
|
||||
// Key includes shader-affecting props to force recompilation when they change
|
||||
// (r3f doesn't reactively recompile shaders on prop changes)
|
||||
const materialKey = `${isSurfaceOutsideVisible}-${debugMode}`;
|
||||
// Refs for forcing shader recompilation
|
||||
const basicMaterialRef = useRef<MeshBasicMaterial>(null);
|
||||
const lambertMaterialRef = useRef<MeshLambertMaterial>(null);
|
||||
|
||||
// Force shader recompilation when debugMode changes
|
||||
// r3f doesn't sync defines prop changes, so we update the material directly
|
||||
useEffect(() => {
|
||||
const mat = (basicMaterialRef.current ?? lambertMaterialRef.current) as
|
||||
| (Material & { defines?: Record<string, number> })
|
||||
| null;
|
||||
if (mat) {
|
||||
mat.defines ??= {};
|
||||
mat.defines.DEBUG_MODE = debugMode ? 1 : 0;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
}, [debugMode]);
|
||||
|
||||
const defines = { DEBUG_MODE: debugMode ? 1 : 0 };
|
||||
|
||||
// Key for shader structure changes (surfaceOutsideVisible affects lighting model)
|
||||
const materialKey = `${isSurfaceOutsideVisible}`;
|
||||
|
||||
// Self-illuminating materials are fullbright (unlit), no lightmap
|
||||
if (isSelfIlluminating) {
|
||||
return (
|
||||
<meshBasicMaterial
|
||||
ref={basicMaterialRef}
|
||||
key={materialKey}
|
||||
map={texture}
|
||||
toneMapped={false}
|
||||
// @ts-expect-error - defines exists on Material but R3F types don't expose it
|
||||
defines={defines}
|
||||
onBeforeCompile={onBeforeCompile}
|
||||
/>
|
||||
);
|
||||
|
|
@ -89,10 +111,13 @@ function InteriorTexture({
|
|||
// Using FrontSide (default) - normals are fixed in io_dif Blender export
|
||||
return (
|
||||
<meshLambertMaterial
|
||||
ref={lambertMaterialRef}
|
||||
key={materialKey}
|
||||
map={texture}
|
||||
lightMap={lightMap ?? undefined}
|
||||
toneMapped={false}
|
||||
// @ts-expect-error - defines exists on Material but R3F types don't expose it
|
||||
defines={defines}
|
||||
onBeforeCompile={onBeforeCompile}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
|
|
@ -95,7 +96,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
);
|
||||
|
||||
// Read persisted settings from localStorage.
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
let savedSettings: PersistedSettings = {};
|
||||
try {
|
||||
savedSettings = JSON.parse(localStorage.getItem("settings")) || {};
|
||||
|
|
|
|||
|
|
@ -79,6 +79,19 @@ function SkyBoxTexture({
|
|||
[fogState],
|
||||
);
|
||||
|
||||
// Create stable uniform objects that persist across renders
|
||||
// This ensures the shader gets updated values when props change
|
||||
const uniformsRef = useRef({
|
||||
skybox: { value: skyBox },
|
||||
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
|
||||
enableFog: { value: enableFog },
|
||||
inverseProjectionMatrix: { value: inverseProjectionMatrix },
|
||||
cameraMatrixWorld: { value: camera.matrixWorld },
|
||||
cameraHeight: globalFogUniforms.cameraHeight,
|
||||
fogVolumeData: { value: fogVolumeData },
|
||||
horizonFogHeight: { value: 0.18 },
|
||||
});
|
||||
|
||||
// Calculate the horizon fog cutoff based on visible distance
|
||||
// In Torque's sky.cc:
|
||||
// mRadius = visibleDistance * 0.95
|
||||
|
|
@ -106,6 +119,15 @@ function SkyBoxTexture({
|
|||
);
|
||||
}, [fogState]);
|
||||
|
||||
// Update uniform values when props change
|
||||
useEffect(() => {
|
||||
uniformsRef.current.skybox.value = skyBox;
|
||||
uniformsRef.current.fogColor.value = fogColor ?? new Color(0, 0, 0);
|
||||
uniformsRef.current.enableFog.value = enableFog;
|
||||
uniformsRef.current.fogVolumeData.value = fogVolumeData;
|
||||
uniformsRef.current.horizonFogHeight.value = horizonFogHeight;
|
||||
}, [skyBox, fogColor, enableFog, fogVolumeData, horizonFogHeight]);
|
||||
|
||||
return (
|
||||
<mesh renderOrder={-1000} frustumCulled={false}>
|
||||
<bufferGeometry>
|
||||
|
|
@ -123,16 +145,7 @@ function SkyBoxTexture({
|
|||
/>
|
||||
</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 },
|
||||
}}
|
||||
uniforms={uniformsRef.current}
|
||||
vertexShader={`
|
||||
varying vec2 vUv;
|
||||
|
||||
|
|
@ -339,6 +352,27 @@ function SolidColorSky({
|
|||
);
|
||||
}, [fogState]);
|
||||
|
||||
// Create stable uniform objects that persist across renders
|
||||
const uniformsRef = useRef({
|
||||
skyColor: { value: skyColor },
|
||||
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
|
||||
enableFog: { value: enableFog },
|
||||
inverseProjectionMatrix: { value: inverseProjectionMatrix },
|
||||
cameraMatrixWorld: { value: camera.matrixWorld },
|
||||
cameraHeight: globalFogUniforms.cameraHeight,
|
||||
fogVolumeData: { value: fogVolumeData },
|
||||
horizonFogHeight: { value: horizonFogHeight },
|
||||
});
|
||||
|
||||
// Update uniform values when props change
|
||||
useEffect(() => {
|
||||
uniformsRef.current.skyColor.value = skyColor;
|
||||
uniformsRef.current.fogColor.value = fogColor ?? new Color(0, 0, 0);
|
||||
uniformsRef.current.enableFog.value = enableFog;
|
||||
uniformsRef.current.fogVolumeData.value = fogVolumeData;
|
||||
uniformsRef.current.horizonFogHeight.value = horizonFogHeight;
|
||||
}, [skyColor, fogColor, enableFog, fogVolumeData, horizonFogHeight]);
|
||||
|
||||
return (
|
||||
<mesh renderOrder={-1000} frustumCulled={false}>
|
||||
<bufferGeometry>
|
||||
|
|
@ -356,16 +390,7 @@ function SolidColorSky({
|
|||
/>
|
||||
</bufferGeometry>
|
||||
<shaderMaterial
|
||||
uniforms={{
|
||||
skyColor: { value: skyColor },
|
||||
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
|
||||
enableFog: { value: enableFog },
|
||||
inverseProjectionMatrix: { value: inverseProjectionMatrix },
|
||||
cameraMatrixWorld: { value: camera.matrixWorld },
|
||||
cameraHeight: globalFogUniforms.cameraHeight,
|
||||
fogVolumeData: { value: fogVolumeData },
|
||||
horizonFogHeight: { value: horizonFogHeight },
|
||||
}}
|
||||
uniforms={uniformsRef.current}
|
||||
vertexShader={`
|
||||
varying vec2 vUv;
|
||||
|
||||
|
|
@ -491,7 +516,13 @@ function calculateFogParameters(
|
|||
* 2. Volume fog: Height-based fog using per-volume parameters
|
||||
* Both are combined additively, matching Torque's getHazeAndFog function.
|
||||
*/
|
||||
function DynamicFog({ fogState }: { fogState: FogState }) {
|
||||
function DynamicFog({
|
||||
fogState,
|
||||
enabled,
|
||||
}: {
|
||||
fogState: FogState;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { scene, camera } = useThree();
|
||||
const fogRef = useRef<Fog | null>(null);
|
||||
|
||||
|
|
@ -530,6 +561,24 @@ function DynamicFog({ fogState }: { fogState: FogState }) {
|
|||
};
|
||||
}, [scene, camera, fogState, fogVolumeData]);
|
||||
|
||||
// When fog is disabled, set near=far to effectively disable fog
|
||||
// without removing scene.fog (which would require shader recompilation)
|
||||
useEffect(() => {
|
||||
const fog = fogRef.current;
|
||||
if (!fog) return;
|
||||
|
||||
if (enabled) {
|
||||
const [near, far] = calculateFogParameters(fogState, camera.position.y);
|
||||
fog.near = near;
|
||||
fog.far = far;
|
||||
} else {
|
||||
// Setting near = far = large value effectively disables fog
|
||||
// (fog factor = 0 when distance < near)
|
||||
fog.near = 1e10;
|
||||
fog.far = 1e10;
|
||||
}
|
||||
}, [enabled, fogState, camera.position.y]);
|
||||
|
||||
// Update fog parameters each frame based on camera height
|
||||
useFrame(() => {
|
||||
const fog = fogRef.current;
|
||||
|
|
@ -537,14 +586,17 @@ function DynamicFog({ fogState }: { fogState: FogState }) {
|
|||
|
||||
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);
|
||||
// Always update global fog uniforms so shaders know the enabled state
|
||||
updateGlobalFogUniforms(cameraHeight, fogVolumeData, enabled);
|
||||
|
||||
// Update global fog uniforms for volumetric fog shaders
|
||||
updateGlobalFogUniforms(cameraHeight, fogVolumeData);
|
||||
if (enabled) {
|
||||
// Update Three.js basic fog
|
||||
const [near, far] = calculateFogParameters(fogState, cameraHeight);
|
||||
fog.near = near;
|
||||
fog.far = far;
|
||||
fog.color.copy(fogState.fogColor);
|
||||
}
|
||||
// When disabled, fog.near/far are already set to 1e10 by the useEffect
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
@ -632,7 +684,12 @@ export function Sky({ object }: { object: TorqueObject }) {
|
|||
<Suspense>
|
||||
<CloudLayers object={object} />
|
||||
</Suspense>
|
||||
{hasFogParams ? <DynamicFog fogState={fogState} /> : null}
|
||||
{/* Always render DynamicFog when mission has fog params.
|
||||
Pass fogEnabled to control visibility - this avoids shader recompilation
|
||||
when toggling fog (USE_FOG stays defined, but fog.near/far disable fog). */}
|
||||
{fogState.enabled ? (
|
||||
<DynamicFog fogState={fogState} enabled={fogEnabled} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
89
src/components/TerrainProvider.tsx
Normal file
89
src/components/TerrainProvider.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { createContext, ReactNode, useContext, useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* Handle for querying terrain data.
|
||||
*
|
||||
* TerrainBlock registers this via useEffect, allowing other components
|
||||
* to query terrain data without prop drilling.
|
||||
*/
|
||||
export interface TerrainHandle {
|
||||
/**
|
||||
* Query terrain height at world coordinates.
|
||||
* Coordinates wrap via `& 255` to support infinite terrain tiling,
|
||||
* matching Torque's FluidSupport.cc:
|
||||
* i = (((m_SquareY0+Y) & 255) << 8) + ((m_SquareX0+X) & 255);
|
||||
*/
|
||||
getHeightAt: (worldX: number, worldZ: number) => number;
|
||||
|
||||
/**
|
||||
* Check if a point is above terrain at given world coordinates.
|
||||
* Used for water masking (matching Torque's fluid reject mask).
|
||||
* Coordinates wrap to support infinite terrain tiling.
|
||||
*/
|
||||
isAboveTerrain: (worldX: number, worldZ: number, height: number) => boolean;
|
||||
|
||||
/**
|
||||
* Get primary terrain tile bounds in world coordinates.
|
||||
* Note: Terrain actually tiles infinitely via coordinate wrapping.
|
||||
*/
|
||||
getBounds: () => {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minZ: number;
|
||||
maxZ: number;
|
||||
};
|
||||
}
|
||||
|
||||
type StateSetter<T> = ReturnType<typeof useState<T>>[1];
|
||||
|
||||
interface TerrainContextValue {
|
||||
terrain: TerrainHandle | null;
|
||||
setTerrain: StateSetter<TerrainHandle | null>;
|
||||
}
|
||||
|
||||
const TerrainContext = createContext<TerrainContextValue | null>(null);
|
||||
|
||||
interface TerrainProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for terrain query handle.
|
||||
*
|
||||
* TerrainBlock registers its handle via useEffect on mount, and other
|
||||
* components (like WaterBlock) can access it to query terrain heights.
|
||||
*/
|
||||
export function TerrainProvider({ children }: TerrainProviderProps) {
|
||||
const [terrain, setTerrain] = useState<TerrainHandle | null>(null);
|
||||
const context = useMemo(() => ({ terrain, setTerrain }), [terrain]);
|
||||
|
||||
return (
|
||||
<TerrainContext.Provider value={context}>
|
||||
{children}
|
||||
</TerrainContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the terrain handle from context.
|
||||
* Returns null if no TerrainBlock has registered yet.
|
||||
*/
|
||||
export function useTerrainHandle() {
|
||||
const context = useContext(TerrainContext);
|
||||
if (!context) {
|
||||
throw new Error("useTerrainHandle must be used within a TerrainProvider");
|
||||
}
|
||||
return context.terrain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the terrain setter for registration.
|
||||
* Used by TerrainBlock to register its handle.
|
||||
*/
|
||||
export function useRegisterTerrain() {
|
||||
const context = useContext(TerrainContext);
|
||||
if (!context) {
|
||||
throw new Error("useRegisterTerrain must be used within a TerrainProvider");
|
||||
}
|
||||
return context.setTerrain;
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { memo, Suspense, useCallback, useMemo } from "react";
|
||||
import { type BufferGeometry, DataTexture, FrontSide } from "three";
|
||||
import { memo, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
type BufferGeometry,
|
||||
DataTexture,
|
||||
FrontSide,
|
||||
type MeshLambertMaterial,
|
||||
} from "three";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import {
|
||||
FALLBACK_TEXTURE_URL,
|
||||
|
|
@ -83,7 +88,6 @@ function BlendedTerrainTextures({
|
|||
alphaTextures,
|
||||
visibilityMask,
|
||||
tiling: TILING,
|
||||
debugMode,
|
||||
detailTexture: detailTextureUrl ? detailTexture : null,
|
||||
lightmap,
|
||||
});
|
||||
|
|
@ -95,28 +99,43 @@ function BlendedTerrainTextures({
|
|||
baseTextures,
|
||||
alphaTextures,
|
||||
visibilityMask,
|
||||
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
|
||||
// - lightmap: affects shader structure (uses lightmap for NdotL instead of vertex normals)
|
||||
const materialKey = `${debugMode ? "debug" : "normal"}-${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
|
||||
// Ref for forcing shader recompilation
|
||||
const materialRef = useRef<MeshLambertMaterial>(null);
|
||||
|
||||
// Force shader recompilation when debugMode changes
|
||||
// r3f doesn't sync defines prop changes, so we update the material directly
|
||||
useEffect(() => {
|
||||
const mat = materialRef.current as MeshLambertMaterial & {
|
||||
defines?: Record<string, number>;
|
||||
};
|
||||
if (mat) {
|
||||
mat.defines ??= {};
|
||||
mat.defines.DEBUG_MODE = debugMode ? 1 : 0;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
}, [debugMode]);
|
||||
|
||||
// Key for shader structure changes (detail texture, lightmap)
|
||||
const materialKey = `${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
|
||||
ref={materialRef}
|
||||
key={materialKey}
|
||||
map={displacementMap}
|
||||
depthWrite
|
||||
side={FrontSide}
|
||||
// @ts-expect-error - defines exists on Material but R3F types don't expose it
|
||||
defines={{ DEBUG_MODE: debugMode ? 1 : 0 }}
|
||||
onBeforeCompile={onBeforeCompile}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,30 @@
|
|||
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { memo, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, useTexture } from "@react-three/drei";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { DoubleSide, NoColorSpace, PlaneGeometry, RepeatWrapping } from "three";
|
||||
import { textureToUrl } from "../loaders";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||
import {
|
||||
getFloat,
|
||||
getPosition,
|
||||
getProperty,
|
||||
getRotation,
|
||||
getScale,
|
||||
} from "../mission";
|
||||
import { setupColor } from "../textureUtils";
|
||||
import { createWaterMaterial } from "../waterMaterial";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
import { useDebug, useSettings } from "./SettingsProvider";
|
||||
import { usePositionTracker } from "./usePositionTracker";
|
||||
|
||||
const REP_SIZE = 2048;
|
||||
|
||||
/**
|
||||
* Terrain coordinate offset from world space.
|
||||
* In Tribes 2, terrain coordinates are offset by +1024 from world coordinates.
|
||||
* This allows world positions like (-672, -736) to map to valid terrain
|
||||
* positions (352, 288) after the offset.
|
||||
*/
|
||||
const TERRAIN_OFFSET = 1024;
|
||||
|
||||
/**
|
||||
* Calculate tessellation to match Tribes 2 engine.
|
||||
|
|
@ -35,81 +52,6 @@ function calculateWaterSegments(
|
|||
return [segmentsX, segmentsZ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated water surface material using Tribes 2-accurate shader.
|
||||
*
|
||||
* The Torque V12 engine renders water in multiple passes:
|
||||
* - Phase 1a/1b: Two cross-faded base texture passes, each rotated 30°
|
||||
* - Phase 3: Environment/specular map with reflection UVs
|
||||
* - Phase 4: Fog overlay
|
||||
*/
|
||||
export function WaterSurfaceMaterial({
|
||||
surfaceTexture,
|
||||
envMapTexture,
|
||||
opacity = 0.75,
|
||||
waveMagnitude = 1.0,
|
||||
envMapIntensity = 1.0,
|
||||
attach,
|
||||
}: {
|
||||
surfaceTexture: string;
|
||||
envMapTexture?: string;
|
||||
opacity?: number;
|
||||
waveMagnitude?: number;
|
||||
envMapIntensity?: number;
|
||||
attach?: string;
|
||||
}) {
|
||||
const baseUrl = textureToUrl(surfaceTexture);
|
||||
const envUrl = textureToUrl(envMapTexture ?? "special/lush_env");
|
||||
|
||||
const [baseTexture, envTexture] = useTexture(
|
||||
[baseUrl, envUrl],
|
||||
(textures) => {
|
||||
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;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { animationEnabled } = useSettings();
|
||||
|
||||
const material = useMemo(() => {
|
||||
return createWaterMaterial({
|
||||
opacity,
|
||||
waveMagnitude,
|
||||
envMapIntensity,
|
||||
baseTexture,
|
||||
envMapTexture: envTexture,
|
||||
});
|
||||
}, [opacity, waveMagnitude, envMapIntensity, baseTexture, envTexture]);
|
||||
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!animationEnabled) {
|
||||
elapsedRef.current = 0;
|
||||
material.uniforms.uTime.value = 0;
|
||||
return;
|
||||
}
|
||||
elapsedRef.current += delta;
|
||||
material.uniforms.uTime.value = elapsedRef.current;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
material.dispose();
|
||||
};
|
||||
}, [material]);
|
||||
|
||||
return <primitive object={material} attach={attach} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple fallback material for non-top faces and loading state.
|
||||
*/
|
||||
|
|
@ -145,26 +87,117 @@ export function WaterMaterial({
|
|||
*
|
||||
* Unlike a simple box, we use a subdivided PlaneGeometry for the water surface
|
||||
* so that vertex displacement can create visible waves.
|
||||
*
|
||||
* Water tiling follows Torque's FluidQuadTree.cc behavior:
|
||||
* - Determines camera's terrain "rep" via I = (s32)(m_Eye.X / 2048.0f)
|
||||
* - Renders 9 reps (3x3 grid) centered on camera's rep
|
||||
*/
|
||||
export const WaterBlock = memo(function WaterBlock({
|
||||
object,
|
||||
}: {
|
||||
object: TorqueObject;
|
||||
}) {
|
||||
const position = useMemo(() => getPosition(object), [object]);
|
||||
const { debugMode } = useDebug();
|
||||
const q = useMemo(() => getRotation(object), [object]);
|
||||
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
|
||||
const position = useMemo(() => getPosition(object), [object]);
|
||||
const scale = useMemo(() => getScale(object), [object]);
|
||||
const [scaleX, scaleY, scaleZ] = scale;
|
||||
const camera = useThree((state) => state.camera);
|
||||
const hasCameraPositionChanged = usePositionTracker();
|
||||
|
||||
// Water surface height (top of water volume)
|
||||
// TODO: Use this for terrain intersection masking (reject water blocks where
|
||||
// terrain height > surfaceZ + waveMagnitude/2). Requires TerrainProvider.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const surfaceZ = position[1] + scaleY;
|
||||
|
||||
// Wave magnitude affects terrain masking (Torque adds half to surface height)
|
||||
const waveMagnitude = getFloat(object, "waveMagnitude") ?? 1.0;
|
||||
|
||||
// Convert world position to terrain space and snap to grid.
|
||||
// Matches Torque's UpdateFluidRegion() and fluid::SetInfo():
|
||||
// 1. Add TERRAIN_OFFSET (1024) to convert world -> terrain space
|
||||
// 2. Round to nearest terrain square: (s32)((X0 / 8.0f) + 0.5f) = Math.round(x/8)
|
||||
// 3. Clamp to valid terrain range [0, 2040] squares
|
||||
// 4. Convert back to world units (multiply by 8)
|
||||
const basePosition = useMemo(() => {
|
||||
const [x, y, z] = position;
|
||||
|
||||
// Convert to terrain space (add 1024 offset)
|
||||
const terrainX = x + TERRAIN_OFFSET;
|
||||
const terrainZ = z + TERRAIN_OFFSET;
|
||||
|
||||
// Round to terrain squares: (s32)((X0 / 8.0f) + 0.5f) is Math.round()
|
||||
let squareX = Math.round(terrainX / 8);
|
||||
let squareZ = Math.round(terrainZ / 8);
|
||||
|
||||
// Clamp to valid range [0, 2040] as in fluidSupport.cc
|
||||
squareX = Math.max(0, Math.min(2040, squareX));
|
||||
squareZ = Math.max(0, Math.min(2040, squareZ));
|
||||
|
||||
// Convert back to world units (terrain squares * 8)
|
||||
// This is the fluid's anchor point in terrain space
|
||||
const baseX = squareX * 8;
|
||||
const baseZ = squareZ * 8;
|
||||
|
||||
return [baseX, y, baseZ] as [number, number, number];
|
||||
}, [position]);
|
||||
|
||||
// Calculate 3x3 grid of reps centered on camera position.
|
||||
// ALL water blocks use 9-rep tiling - the masking system handles visibility.
|
||||
// Matches fluidQuadTree.cc RunQuadTree():
|
||||
// I = (s32)(m_Eye.X / 2048.0f);
|
||||
// if( m_Eye.X < 0.0f ) I--;
|
||||
const calculateReps = (camX: number, camZ: number): Array<[number, number]> => {
|
||||
// Convert camera to terrain space
|
||||
const terrainCamX = camX + TERRAIN_OFFSET;
|
||||
const terrainCamZ = camZ + TERRAIN_OFFSET;
|
||||
|
||||
// Determine camera's terrain rep using terrain coordinates.
|
||||
// Torque uses: I = (s32)(m_Eye.X / 2048.0f); if (m_Eye.X < 0) I--;
|
||||
// This is truncation toward zero, then decrement for negative.
|
||||
let cameraRepX = Math.trunc(terrainCamX / REP_SIZE);
|
||||
let cameraRepZ = Math.trunc(terrainCamZ / REP_SIZE);
|
||||
if (terrainCamX < 0) cameraRepX--;
|
||||
if (terrainCamZ < 0) cameraRepZ--;
|
||||
|
||||
// Build 3x3 grid of reps around camera
|
||||
const newReps: Array<[number, number]> = [];
|
||||
for (let repZ = cameraRepZ - 1; repZ <= cameraRepZ + 1; repZ++) {
|
||||
for (let repX = cameraRepX - 1; repX <= cameraRepX + 1; repX++) {
|
||||
newReps.push([repX, repZ]);
|
||||
}
|
||||
}
|
||||
return newReps;
|
||||
};
|
||||
|
||||
// Track which reps to render, updated each frame based on camera position.
|
||||
// Initialize with current camera position so water is visible immediately.
|
||||
const [reps, setReps] = useState<Array<[number, number]>>(() =>
|
||||
calculateReps(camera.position.x, camera.position.z),
|
||||
);
|
||||
|
||||
useFrame(() => {
|
||||
if (!hasCameraPositionChanged(camera.position)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newReps = calculateReps(camera.position.x, camera.position.z);
|
||||
|
||||
// Only update state if reps actually changed (avoid unnecessary re-renders)
|
||||
setReps((prevReps) => {
|
||||
if (JSON.stringify(prevReps) === JSON.stringify(newReps)) {
|
||||
return prevReps;
|
||||
}
|
||||
return newReps;
|
||||
});
|
||||
});
|
||||
|
||||
const surfaceTexture =
|
||||
getProperty(object, "surfaceTexture") ?? "liquidTiles/BlueWater";
|
||||
const envMapTexture = getProperty(object, "envMapTexture");
|
||||
const opacity = parseFloat(getProperty(object, "surfaceOpacity") ?? "0.75");
|
||||
const waveMagnitude = parseFloat(
|
||||
getProperty(object, "waveMagnitude") ?? "1.0",
|
||||
);
|
||||
const envMapIntensity = parseFloat(
|
||||
getProperty(object, "envMapIntensity") ?? "1.0",
|
||||
);
|
||||
const opacity = getFloat(object, "surfaceOpacity") ?? 0.75;
|
||||
const envMapIntensity = getFloat(object, "envMapIntensity") ?? 1.0;
|
||||
|
||||
// Create subdivided plane geometry for the water surface
|
||||
// Tessellation matches Tribes 2 engine (5x5 vertices per block)
|
||||
|
|
@ -177,8 +210,8 @@ export const WaterBlock = memo(function WaterBlock({
|
|||
// Rotate from XY plane to XZ plane (lying flat)
|
||||
geom.rotateX(-Math.PI / 2);
|
||||
|
||||
// Translate so origin is at corner (matching Torque's water block positioning)
|
||||
// and position at top of water volume (Y = scaleY)
|
||||
// Position at top of water volume (Y = scaleY)
|
||||
// Offset by half scale to put corner at origin (position is corner after modulo)
|
||||
geom.translate(scaleX / 2, scaleY, scaleZ / 2);
|
||||
|
||||
return geom;
|
||||
|
|
@ -190,30 +223,148 @@ export const WaterBlock = memo(function WaterBlock({
|
|||
};
|
||||
}, [surfaceGeometry]);
|
||||
|
||||
// Render each rep that overlaps with terrain bounds
|
||||
return (
|
||||
<group position={position} quaternion={q}>
|
||||
{/* Water surface - subdivided plane with wave shader */}
|
||||
<mesh geometry={surfaceGeometry}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<meshStandardMaterial
|
||||
color="blue"
|
||||
transparent
|
||||
opacity={0.3}
|
||||
side={DoubleSide}
|
||||
/>
|
||||
}
|
||||
<group quaternion={q}>
|
||||
{/* Debug wireframe showing actual water block bounds */}
|
||||
{debugMode && (
|
||||
<Box
|
||||
args={scale}
|
||||
position={[
|
||||
position[0] + scaleX / 2,
|
||||
position[1] + scaleY / 2,
|
||||
position[2] + scaleZ / 2,
|
||||
]}
|
||||
>
|
||||
<WaterSurfaceMaterial
|
||||
attach="material"
|
||||
surfaceTexture={surfaceTexture}
|
||||
envMapTexture={envMapTexture}
|
||||
opacity={opacity}
|
||||
waveMagnitude={waveMagnitude}
|
||||
envMapIntensity={envMapIntensity}
|
||||
/>
|
||||
</Suspense>
|
||||
</mesh>
|
||||
<meshBasicMaterial color="#00fbff" wireframe />
|
||||
</Box>
|
||||
)}
|
||||
<Suspense
|
||||
fallback={reps.map(([repX, repZ]) => {
|
||||
// Convert from terrain space to world space by subtracting TERRAIN_OFFSET
|
||||
// Matches Torque's L2Wm transform: L2Wv = (-1024, -1024, 0)
|
||||
const worldX = basePosition[0] + repX * REP_SIZE - TERRAIN_OFFSET;
|
||||
const worldZ = basePosition[2] + repZ * REP_SIZE - TERRAIN_OFFSET;
|
||||
return (
|
||||
<mesh
|
||||
key={`${repX},${repZ}`}
|
||||
geometry={surfaceGeometry}
|
||||
position={[worldX, basePosition[1], worldZ]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#00fbff"
|
||||
transparent
|
||||
opacity={0.4}
|
||||
wireframe
|
||||
side={DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<WaterReps
|
||||
reps={reps}
|
||||
basePosition={basePosition}
|
||||
surfaceGeometry={surfaceGeometry}
|
||||
surfaceTexture={surfaceTexture}
|
||||
envMapTexture={envMapTexture}
|
||||
opacity={opacity}
|
||||
waveMagnitude={waveMagnitude}
|
||||
envMapIntensity={envMapIntensity}
|
||||
/>
|
||||
</Suspense>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Inner component that renders all water reps with a shared material.
|
||||
* Separated to allow Suspense boundary around texture loading while
|
||||
* ensuring all reps share the same material instance and animation.
|
||||
*/
|
||||
const WaterReps = memo(function WaterReps({
|
||||
reps,
|
||||
basePosition,
|
||||
surfaceGeometry,
|
||||
surfaceTexture,
|
||||
envMapTexture,
|
||||
opacity,
|
||||
waveMagnitude,
|
||||
envMapIntensity,
|
||||
}: {
|
||||
reps: Array<[number, number]>;
|
||||
basePosition: [number, number, number];
|
||||
surfaceGeometry: PlaneGeometry;
|
||||
surfaceTexture: string;
|
||||
envMapTexture: string | undefined;
|
||||
opacity: number;
|
||||
waveMagnitude: number;
|
||||
envMapIntensity: number;
|
||||
}) {
|
||||
const baseUrl = textureToUrl(surfaceTexture);
|
||||
const envUrl = textureToUrl(envMapTexture ?? "special/lush_env");
|
||||
|
||||
const [baseTexture, envTexture] = useTexture(
|
||||
[baseUrl, envUrl],
|
||||
(textures) => {
|
||||
const texArray = Array.isArray(textures) ? textures : [textures];
|
||||
texArray.forEach((tex) => {
|
||||
setupColor(tex);
|
||||
tex.colorSpace = NoColorSpace;
|
||||
tex.wrapS = RepeatWrapping;
|
||||
tex.wrapT = RepeatWrapping;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { animationEnabled } = useSettings();
|
||||
|
||||
// Single shared material for all water reps
|
||||
const material = useMemo(() => {
|
||||
return createWaterMaterial({
|
||||
opacity,
|
||||
waveMagnitude,
|
||||
envMapIntensity,
|
||||
baseTexture,
|
||||
envMapTexture: envTexture,
|
||||
});
|
||||
}, [opacity, waveMagnitude, envMapIntensity, baseTexture, envTexture]);
|
||||
|
||||
// Single animation loop for the shared material
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!animationEnabled) {
|
||||
elapsedRef.current = 0;
|
||||
material.uniforms.uTime.value = 0;
|
||||
} else {
|
||||
elapsedRef.current += delta;
|
||||
material.uniforms.uTime.value = elapsedRef.current;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
material.dispose();
|
||||
};
|
||||
}, [material]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{reps.map(([repX, repZ]) => {
|
||||
// Convert from terrain space to world space by subtracting TERRAIN_OFFSET
|
||||
// Matches Torque's L2Wm transform: L2Wv = (-1024, -1024, 0)
|
||||
const worldX = basePosition[0] + repX * REP_SIZE - TERRAIN_OFFSET;
|
||||
const worldZ = basePosition[2] + repZ * REP_SIZE - TERRAIN_OFFSET;
|
||||
return (
|
||||
<mesh
|
||||
key={`${repX},${repZ}`}
|
||||
geometry={surfaceGeometry}
|
||||
material={material}
|
||||
position={[worldX, basePosition[1], worldZ]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
25
src/components/usePositionTracker.ts
Normal file
25
src/components/usePositionTracker.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import { Vector3 } from "three";
|
||||
|
||||
export function usePositionTracker() {
|
||||
const positionRef = useRef<Vector3>(null);
|
||||
|
||||
const hasChanged = useCallback((position: Vector3) => {
|
||||
if (!positionRef.current) {
|
||||
positionRef.current = position.clone();
|
||||
return true;
|
||||
}
|
||||
const isSamePosition =
|
||||
positionRef.current.x === position.x &&
|
||||
positionRef.current.y === position.y &&
|
||||
positionRef.current.z === position.z;
|
||||
|
||||
if (!isSamePosition) {
|
||||
positionRef.current.copy(position);
|
||||
}
|
||||
|
||||
return isSamePosition;
|
||||
}, []);
|
||||
|
||||
return hasChanged;
|
||||
}
|
||||
112
src/file.vert
Normal file
112
src/file.vert
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#ifdef USE_FOG
|
||||
// Check runtime fog enabled uniform - allows toggling without shader recompilation
|
||||
if (fogEnabled) {
|
||||
// Fog disabled at runtime, skip all fog calculations
|
||||
} else {
|
||||
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 = allFogVolumes[offset + 0];
|
||||
float volMinH = allFogVolumes[offset + 1];
|
||||
float volMaxH = allFogVolumes[offset + 2];
|
||||
float volPct = allFogVolumes[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 = allFogVolumes[offset + 0];
|
||||
float volMinH = allFogVolumes[offset + 1];
|
||||
float volMaxH = allFogVolumes[offset + 2];
|
||||
float volPct = allFogVolumes[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
|
||||
|
|
@ -58,6 +58,13 @@ export const fogUniformsDeclaration = `
|
|||
*/
|
||||
export const fogFragmentShader = `
|
||||
#ifdef USE_FOG
|
||||
// Check fog enabled uniform - allows toggling without shader recompilation
|
||||
#ifdef USE_VOLUMETRIC_FOG
|
||||
if (!fogEnabled) {
|
||||
// Skip all fog calculations when disabled
|
||||
} else {
|
||||
#endif
|
||||
|
||||
float dist = vFogDepth;
|
||||
|
||||
// Discard fragments at or beyond visible distance - matches Torque's behavior
|
||||
|
|
@ -163,6 +170,10 @@ export const fogFragmentShader = `
|
|||
|
||||
// Apply fog using global fogColor (per-volume colors not used in Tribes 2)
|
||||
gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);
|
||||
|
||||
#ifdef USE_VOLUMETRIC_FOG
|
||||
} // end fogEnabled check
|
||||
#endif
|
||||
#endif
|
||||
`;
|
||||
|
||||
|
|
@ -245,6 +256,7 @@ export function installCustomFogShader(): void {
|
|||
export interface FogShaderUniformObjects {
|
||||
fogVolumeData: { value: Float32Array };
|
||||
cameraHeight: { value: number };
|
||||
fogEnabled: { value: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -261,6 +273,7 @@ export function addFogUniformsToShader(
|
|||
// Pass the uniform objects directly so they stay linked to FogProvider updates
|
||||
shader.uniforms.fogVolumeData = fogUniforms.fogVolumeData;
|
||||
shader.uniforms.cameraHeight = fogUniforms.cameraHeight;
|
||||
shader.uniforms.fogEnabled = fogUniforms.fogEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -309,6 +322,7 @@ export function injectCustomFog(
|
|||
#define USE_VOLUMETRIC_FOG
|
||||
uniform float fogVolumeData[12];
|
||||
uniform float cameraHeight;
|
||||
uniform bool fogEnabled;
|
||||
#define USE_FOG_WORLD_POSITION
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif`,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export const globalFogUniforms = {
|
|||
value: new Float32Array(MAX_FOG_VOLUMES * FLOATS_PER_VOLUME),
|
||||
},
|
||||
cameraHeight: { value: 0 },
|
||||
fogEnabled: { value: true },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -31,9 +32,11 @@ export const globalFogUniforms = {
|
|||
export function updateGlobalFogUniforms(
|
||||
cameraHeight: number,
|
||||
fogVolumeData: Float32Array,
|
||||
enabled: boolean = true,
|
||||
): void {
|
||||
globalFogUniforms.cameraHeight.value = cameraHeight;
|
||||
globalFogUniforms.fogVolumeData.value.set(fogVolumeData);
|
||||
globalFogUniforms.fogEnabled.value = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,6 +46,7 @@ export function updateGlobalFogUniforms(
|
|||
export function resetGlobalFogUniforms(): void {
|
||||
globalFogUniforms.cameraHeight.value = 0;
|
||||
globalFogUniforms.fogVolumeData.value.fill(0);
|
||||
globalFogUniforms.fogEnabled.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import { Vector3 } from "three";
|
|||
|
||||
export type InteriorLightingOptions = {
|
||||
surfaceOutsideVisible?: boolean;
|
||||
debugMode?: boolean;
|
||||
};
|
||||
|
||||
// sRGB <-> Linear conversion functions (GLSL)
|
||||
|
|
@ -57,18 +56,16 @@ float debugGrid(vec2 uv, float gridSize, float lineWidth) {
|
|||
|
||||
export function injectInteriorLighting(
|
||||
shader: any,
|
||||
options?: InteriorLightingOptions,
|
||||
options: InteriorLightingOptions,
|
||||
): void {
|
||||
const isOutsideVisible = options?.surfaceOutsideVisible ?? false;
|
||||
const isDebugMode = options?.debugMode ?? false;
|
||||
const isOutsideVisible = options.surfaceOutsideVisible ?? false;
|
||||
|
||||
// Outside surfaces: scene lighting + lightmap
|
||||
// Inside surfaces: lightmap only (no scene lighting)
|
||||
shader.uniforms.useSceneLighting = { value: isOutsideVisible };
|
||||
|
||||
// Debug mode uniforms
|
||||
shader.uniforms.interiorDebugMode = { value: isDebugMode };
|
||||
// Blue for outside visible, red for inside
|
||||
// Debug color: blue for outside visible, red for inside
|
||||
// Only used when DEBUG_MODE define is set
|
||||
shader.uniforms.interiorDebugColor = {
|
||||
value: isOutsideVisible
|
||||
? new Vector3(0.0, 0.4, 1.0)
|
||||
|
|
@ -81,7 +78,6 @@ export function injectInteriorLighting(
|
|||
`#include <common>
|
||||
${colorSpaceFunctions}
|
||||
uniform bool useSceneLighting;
|
||||
uniform bool interiorDebugMode;
|
||||
uniform vec3 interiorDebugColor;
|
||||
`,
|
||||
);
|
||||
|
|
@ -150,12 +146,10 @@ outgoingLight = resultLinear + totalEmissiveRadiance;
|
|||
`// Debug mode: overlay colored grid on top of normal rendering
|
||||
// Blue grid = SurfaceOutsideVisible (receives scene ambient light)
|
||||
// Red grid = inside surface (no scene ambient light)
|
||||
#ifdef USE_MAP
|
||||
if (interiorDebugMode) {
|
||||
#if DEBUG_MODE && defined(USE_MAP)
|
||||
// gridSize=4 creates 4x4 grid per UV tile, lineWidth=1.5 is ~1.5 pixels wide
|
||||
float gridIntensity = debugGrid(vMapUv, 4.0, 1.5);
|
||||
gl_FragColor.rgb = mix(gl_FragColor.rgb, interiorDebugColor, gridIntensity * 0.1);
|
||||
}
|
||||
#endif
|
||||
|
||||
#include <tonemapping_fragment>`,
|
||||
|
|
|
|||
127
src/skyMaterial.ts
Normal file
127
src/skyMaterial.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { ShaderMaterial } from "three";
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position.xy, 0.9999, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /*glsl*/ `
|
||||
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;
|
||||
|
||||
|
||||
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) {
|
||||
// fogColor is passed in linear space (converted in Sky.tsx)
|
||||
|
||||
// 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);
|
||||
|
||||
// Mix in linear space (skybox and fogColor are both linear)
|
||||
finalColor = mix(skyColor.rgb, fogColor, finalFogFactor);
|
||||
} else {
|
||||
finalColor = skyColor.rgb;
|
||||
}
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
export function createSkyBoxMaterial({ uniforms }) {
|
||||
return new ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -57,7 +57,6 @@ export function updateTerrainTextureShader({
|
|||
alphaTextures,
|
||||
visibilityMask,
|
||||
tiling,
|
||||
debugMode = false,
|
||||
detailTexture = null,
|
||||
lightmap = null,
|
||||
}: {
|
||||
|
|
@ -66,7 +65,6 @@ export function updateTerrainTextureShader({
|
|||
alphaTextures: any[];
|
||||
visibilityMask: any;
|
||||
tiling: Record<number, number>;
|
||||
debugMode?: boolean;
|
||||
detailTexture?: any;
|
||||
lightmap?: any;
|
||||
}) {
|
||||
|
|
@ -94,8 +92,6 @@ 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) {
|
||||
|
|
@ -141,7 +137,6 @@ uniform float tiling2;
|
|||
uniform float tiling3;
|
||||
uniform float tiling4;
|
||||
uniform float tiling5;
|
||||
uniform float debugMode;
|
||||
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
|
||||
${lightmap ? "uniform sampler2D terrainLightmap;" : ""}
|
||||
${
|
||||
|
|
@ -344,14 +339,15 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
|
|||
);
|
||||
|
||||
// Add debug grid overlay AFTER opaque_fragment sets gl_FragColor
|
||||
// Uses #if so material.defines.DEBUG_MODE (0 or 1) can trigger recompilation
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <tonemapping_fragment>",
|
||||
`// Debug mode: overlay green grid matching terrain grid squares (256x256)
|
||||
if (debugMode > 0.5) {
|
||||
`#if DEBUG_MODE
|
||||
// Debug mode: overlay green grid matching terrain grid squares (256x256)
|
||||
float gridIntensity = terrainDebugGrid(vMapUv, 256.0, 1.5);
|
||||
vec3 gridColor = vec3(0.0, 0.8, 0.4); // Green
|
||||
gl_FragColor.rgb = mix(gl_FragColor.rgb, gridColor, gridIntensity * 0.05);
|
||||
}
|
||||
#endif
|
||||
|
||||
#include <tonemapping_fragment>`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -75,18 +75,32 @@ export interface RunServerResult {
|
|||
export function runServer(options: RunServerOptions): RunServerResult {
|
||||
const { missionName, missionType, runtimeOptions, onMissionLoadDone } =
|
||||
options;
|
||||
const { signal, fileSystem } = runtimeOptions ?? {};
|
||||
const {
|
||||
signal,
|
||||
fileSystem,
|
||||
globals = {},
|
||||
preloadScripts = [],
|
||||
} = runtimeOptions ?? {};
|
||||
|
||||
// server.cs has a loop that calls `findFirstFile("scripts/*Game.cs")` and
|
||||
// runs `exec()` on each resulting glob match. Since we can't statically
|
||||
// analyze dynamic exec paths, we need to preload all game scripts in the same
|
||||
// way (so they're available when exec() is called). We could assume that we
|
||||
// only need some (like DefaultGame.cs and the one for our game type), but
|
||||
// sometimes map authors bundle a custom script that they don't exec() in the
|
||||
// .mis file, instead preferring to give it a "*Game.cs" name so it's loaded
|
||||
// automatically.
|
||||
const gameScripts = fileSystem.findFiles("scripts/*Game.cs");
|
||||
|
||||
const runtime = createRuntime({
|
||||
...runtimeOptions,
|
||||
globals: {
|
||||
...runtimeOptions?.globals,
|
||||
...globals,
|
||||
"$Host::Map": missionName,
|
||||
"$Host::MissionType": missionType,
|
||||
},
|
||||
preloadScripts: [...preloadScripts, ...gameScripts],
|
||||
});
|
||||
const gameTypeName = `${missionType}Game`;
|
||||
const gameTypeScript = `scripts/${gameTypeName}.cs`;
|
||||
|
||||
const ready = (async function createServer() {
|
||||
try {
|
||||
|
|
@ -94,29 +108,6 @@ export function runServer(options: RunServerOptions): RunServerResult {
|
|||
const serverScript = await runtime.loadFromPath("scripts/server.cs");
|
||||
signal?.throwIfAborted();
|
||||
|
||||
// server.cs has a glob loop that does: findFirstFile("scripts/*Game.cs")
|
||||
// and then exec()s each result dynamically. Since we can't statically
|
||||
// analyze dynamic exec paths, we need to either preload all game scripts
|
||||
// in the same way (so they're available when exec() is called) or just
|
||||
// exec() the ones we know we need...
|
||||
//
|
||||
// To load them all, do:
|
||||
// if (fileSystem) {
|
||||
// const gameScripts = fileSystem.findFiles("scripts/*Game.cs");
|
||||
// await Promise.all(
|
||||
// gameScripts.map((path) => runtime.loadFromPath(path)),
|
||||
// );
|
||||
// signal?.throwIfAborted();
|
||||
// }
|
||||
await runtime.loadFromPath("scripts/DefaultGame.cs");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
await runtime.loadFromPath(gameTypeScript);
|
||||
} catch (err) {
|
||||
// It's OK if that one fails. Not every game type needs its own script.
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
// Also preload the mission file (another dynamic exec path)
|
||||
await runtime.loadFromPath(`missions/${missionName}.mis`);
|
||||
signal?.throwIfAborted();
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ const fragmentShader = /* glsl */ `
|
|||
#ifdef USE_FOG
|
||||
uniform float fogVolumeData[12];
|
||||
uniform float cameraHeight;
|
||||
uniform bool fogEnabled;
|
||||
varying vec3 vFogWorldPosition;
|
||||
#endif
|
||||
|
||||
|
|
@ -210,6 +211,7 @@ export function createWaterMaterial(options?: {
|
|||
// Volumetric fog uniforms (shared with global fog system)
|
||||
fogVolumeData: globalFogUniforms.fogVolumeData,
|
||||
cameraHeight: globalFogUniforms.cameraHeight,
|
||||
fogEnabled: globalFogUniforms.fogEnabled,
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue