add terrain tiling, tweak fog

This commit is contained in:
Brian Beck 2025-12-04 21:25:38 -08:00
parent 2a730b8a44
commit d320fbd694
15 changed files with 473 additions and 239 deletions

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

View file

@ -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/f620a0b974993323.js","/t2-mapper/_next/static/chunks/32ef0c8650712240.js","/t2-mapper/_next/static/chunks/e48d3b25285f3054.js","/t2-mapper/_next/static/chunks/1e4f7733a4dd09be.js"],"default"]
5:I[31713,["/t2-mapper/_next/static/chunks/f620a0b974993323.js","/t2-mapper/_next/static/chunks/32ef0c8650712240.js","/t2-mapper/_next/static/chunks/e48d3b25285f3054.js","/t2-mapper/_next/static/chunks/3a414cdd3d87edcc.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":"XHGJaNs99HGJmX9H1-TS5","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/f620a0b974993323.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/e48d3b25285f3054.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/1e4f7733a4dd09be.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":"a7k-N1DrVO1eOsXgEtp_x","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/f620a0b974993323.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/e48d3b25285f3054.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/3a414cdd3d87edcc.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"}]]

View file

@ -7,7 +7,9 @@ module.exports = {
async headers() {
return [
{
// TorqueScript files should be served as text
// TorqueScript files should be served as text. This won't affect what
// GitHub Pages does, but it'll at least improve the dev server. Otherwise,
// the responses can't be easily inspected in the Network tab.
source: "/:path*.cs",
headers: [
{
@ -18,4 +20,19 @@ module.exports = {
},
];
},
async redirects() {
// For the dev server, redirect / to the `basePath` for convenience, so you
// can just open localhost:3000.
if (process.env.NODE_ENV !== "production") {
return [
{
source: "/",
destination: "/t2-mapper/",
basePath: false,
permanent: false,
},
];
}
return [];
},
};

View file

@ -1,7 +1,7 @@
import { Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei";
import { Color, ShaderMaterial, BackSide, Euler } from "three";
import { Color, ShaderMaterial, BackSide, Euler, ShaderChunk } from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getInt, getProperty } from "../mission";
import { useSettings } from "./SettingsProvider";
@ -11,6 +11,41 @@ import { CloudLayers } from "./CloudLayers";
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();
}
/**
* Load a .dml file, used to list the textures for different faces of a skybox.
*/
@ -24,11 +59,13 @@ function useDetailMapList(name: string) {
export function SkyBox({
materialList,
fogColor,
fogDistance,
fogNear,
fogFar,
}: {
materialList: string;
fogColor?: Color;
fogDistance?: number;
fogNear?: number;
fogFar?: number;
}) {
const { data: detailMapList } = useDetailMapList(materialList);
@ -59,13 +96,14 @@ export function SkyBox({
// Create a shader material for the skybox with fog
const materialRef = useRef<ShaderMaterial>(null!);
const hasFog = !!fogColor && !!fogDistance;
const hasFog = !!fogColor && fogNear != null && fogFar != null;
const shaderMaterial = useMemo(() => {
if (!hasFog) {
return null;
}
// Skybox fog blends toward horizon
return new ShaderMaterial({
uniforms: {
skybox: { value: skyBox },
@ -75,12 +113,9 @@ export function SkyBox({
varying vec3 vDirection;
void main() {
// Use position directly as direction (no world transform needed)
vDirection = position;
// Transform position but ignore translation
vec4 pos = projectionMatrix * mat4(mat3(modelViewMatrix)) * vec4(position, 1.0);
gl_Position = pos.xyww; // Set depth to far plane
gl_Position = pos.xyww;
}
`,
fragmentShader: `
@ -95,13 +130,12 @@ export function SkyBox({
direction = vec3(direction.z, direction.y, direction.x);
vec4 skyColor = textureCube(skybox, direction);
// Calculate fog factor based on vertical direction
// Fog increases toward and below horizon
// direction.y: -1 = straight down, 0 = horizon, 1 = straight up
// 100% fog from bottom to horizon, then fade from horizon (0) to 0.4
float fogFactor = smoothstep(0.0, 0.4, direction.y);
// Use smoothstep for gradual transition (matches Three.js linear fog feel)
float fogFactor = 1.0 - smoothstep(-0.1, 0.5, direction.y);
// Mix in sRGB space to match Three.js fog rendering
vec3 finalColor = mix(fogColor, skyColor.rgb, fogFactor);
vec3 finalColor = mix(skyColor.rgb, fogColor, fogFactor);
gl_FragColor = vec4(finalColor, 1.0);
}
`,
@ -131,7 +165,7 @@ export function SkyBox({
}
return (
<mesh scale={5000}>
<mesh scale={5000} frustumCulled={false}>
<sphereGeometry args={[1, 60, 40]} />
<primitive ref={materialRef} object={shaderMaterial} attach="material" />
</mesh>
@ -141,7 +175,7 @@ export function SkyBox({
export function Sky({ object }: { object: TorqueObject }) {
const { fogEnabled } = useSettings();
// Skybox textures.
// Skybox textures
const materialList = getProperty(object, "materialList");
const skySolidColor = useMemo(() => {
@ -162,9 +196,22 @@ export function Sky({ object }: { object: TorqueObject }) {
const useSkyTextures = getInt(object, "useSkyTextures") ?? 1;
// Fog parameters.
// TODO: There can be multiple fog volumes/layers. Render simple fog for now.
const fogDistance = getFloat(object, "fogDistance");
// 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");
// Use high quality values if available and valid (> 0)
const fogNear =
highFogDistance != null && highFogDistance > 0
? highFogDistance
: fogDistanceBase;
const fogFar =
highVisibleDistance != null && highVisibleDistance > 0
? highVisibleDistance
: visibleDistanceBase;
const fogColor = useMemo(() => {
const colorString = getProperty(object, "fogColor");
@ -188,15 +235,18 @@ export function Sky({ object }: { object: TorqueObject }) {
<color attach="background" args={[skyColor[0]]} />
) : null;
// Only enable fog if we have valid near/far distances
const hasFogParams = fogNear != null && fogFar != null && fogFar > fogNear;
return (
<>
{materialList && useSkyTextures ? (
// Load the DML for skybox textures
<Suspense fallback={backgroundColor}>
<SkyBox
materialList={materialList}
fogColor={fogEnabled ? fogColor?.[1] : undefined}
fogDistance={fogEnabled ? fogDistance : undefined}
fogColor={fogEnabled && hasFogParams ? fogColor?.[1] : undefined}
fogNear={fogEnabled && hasFogParams ? fogNear : undefined}
fogFar={fogEnabled && hasFogParams ? fogFar : undefined}
/>
</Suspense>
) : (
@ -208,13 +258,8 @@ export function Sky({ object }: { object: TorqueObject }) {
<Suspense>
<CloudLayers object={object} />
</Suspense>
{fogEnabled && fogDistance && fogColor ? (
<fog
attach="fog"
color={fogColor[1]}
near={100}
far={Math.max(400, fogDistance * 2)}
/>
{fogEnabled && hasFogParams && fogColor ? (
<fog attach="fog" color={fogColor[1]} near={fogNear!} far={fogFar!} />
) : null}
</>
);

View file

@ -1,36 +1,28 @@
import { memo, Suspense, useCallback, useMemo } from "react";
import { memo, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useQuery } from "@tanstack/react-query";
import {
DataTexture,
RedFormat,
FloatType,
NoColorSpace,
NearestFilter,
NoColorSpace,
ClampToEdgeWrapping,
UnsignedByteType,
PlaneGeometry,
DoubleSide,
FrontSide,
RedFormat,
RepeatWrapping,
UnsignedByteType,
} from "three";
import { useTexture } from "@react-three/drei";
import { uint16ToFloat32 } from "../arrayUtils";
import { loadTerrain, terrainTextureToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import {
getInt,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import {
setupColor,
setupMask,
updateTerrainTextureShader,
} from "../textureUtils";
import { useDebug } from "./SettingsProvider";
import { getFloat, getInt, getPosition, getProperty } from "../mission";
import { loadTerrain } from "../loaders";
import { uint16ToFloat32 } from "../arrayUtils";
import { setupMask } from "../textureUtils";
import { TerrainTile } from "./TerrainTile";
import { useSceneObject } from "./useSceneObject";
const DEFAULT_SQUARE_SIZE = 8;
const DEFAULT_VISIBLE_DISTANCE = 600;
const TERRAIN_SIZE = 256;
/**
* Load a .ter file, used for terrain heightmap and texture info.
@ -42,164 +34,60 @@ function useTerrain(terrainFile: string) {
});
}
function BlendedTerrainTextures({
displacementMap,
visibilityMask,
textureNames,
alphaMaps,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaMaps: Uint8Array[];
}) {
const { debugMode } = useDebug();
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
textures.forEach((tex) => setupColor(tex));
},
);
const alphaTextures = useMemo(
() => alphaMaps.map((data) => setupMask(data)),
[alphaMaps],
);
const tiling = useMemo(
() => ({
0: 32,
1: 32,
2: 32,
3: 32,
4: 32,
5: 32,
}),
[],
);
const onBeforeCompile = useCallback(
(shader) => {
updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
tiling,
debugMode,
});
},
[baseTextures, alphaTextures, visibilityMask, tiling, debugMode],
);
return (
<meshStandardMaterial
// For testing tiling values; forces recompile.
key={`${JSON.stringify(tiling)}-${debugMode}`}
displacementMap={displacementMap}
map={displacementMap}
displacementScale={2048}
depthWrite
// In debug mode, render both sides so we can see wireframe from below
side={debugMode ? DoubleSide : FrontSide}
onBeforeCompile={onBeforeCompile}
/>
);
/**
* Get visibleDistance from the Sky object, used to determine how far terrain
* tiles should render. This matches Tribes 2's terrain tiling behavior.
*/
function useVisibleDistance(): number {
const sky = useSceneObject("Sky");
if (!sky) return DEFAULT_VISIBLE_DISTANCE;
const highVisibleDistance = getFloat(sky, "high_visibleDistance");
if (highVisibleDistance != null && highVisibleDistance > 0) {
return highVisibleDistance;
}
return getFloat(sky, "visibleDistance") ?? DEFAULT_VISIBLE_DISTANCE;
}
function TerrainMaterial({
heightMap,
textureNames,
alphaMaps,
emptySquares,
}: {
heightMap: Uint16Array;
emptySquares: number[];
textureNames: string[];
alphaMaps: Uint8Array[];
}) {
const displacementMap = useMemo(() => {
const f32HeightMap = uint16ToFloat32(heightMap);
const displacementMap = new DataTexture(
f32HeightMap,
256,
256,
RedFormat,
FloatType,
);
displacementMap.colorSpace = NoColorSpace;
displacementMap.generateMipmaps = false;
displacementMap.needsUpdate = true;
return displacementMap;
}, [heightMap]);
interface TileAssignment {
tileX: number;
tileZ: number;
}
const visibilityMask: DataTexture | null = useMemo(() => {
if (!emptySquares.length) {
return null;
}
/**
* Create a visibility mask texture from emptySquares data.
*/
function createVisibilityMask(emptySquares: number[]): DataTexture {
const maskData = new Uint8Array(TERRAIN_SIZE * TERRAIN_SIZE);
maskData.fill(255); // Start with everything visible
const terrainSize = 256;
for (const squareId of emptySquares) {
const x = squareId & 0xff;
const y = (squareId >> 8) & 0xff;
const count = squareId >> 16;
const rowOffset = y * TERRAIN_SIZE;
// Create a mask texture (1 = visible, 0 = invisible)
const maskData = new Uint8Array(terrainSize * terrainSize);
maskData.fill(255); // Start with everything visible
for (const squareId of emptySquares) {
// The squareId encodes position and count:
// Bits 0-7: X position (starting position)
// Bits 8-15: Y position
// Bits 16+: Count (number of consecutive horizontal squares)
const x = squareId & 0xff;
const y = (squareId >> 8) & 0xff;
const count = squareId >> 16;
for (let i = 0; i < count; i++) {
const px = x + i;
const py = y;
const index = py * terrainSize + px;
if (index >= 0 && index < maskData.length) {
maskData[index] = 0;
}
for (let i = 0; i < count; i++) {
const index = rowOffset + x + i;
if (index < maskData.length) {
maskData[index] = 0;
}
}
}
const visibilityMask = new DataTexture(
maskData,
terrainSize,
terrainSize,
RedFormat,
UnsignedByteType,
);
visibilityMask.colorSpace = NoColorSpace;
visibilityMask.wrapS = visibilityMask.wrapT = ClampToEdgeWrapping;
visibilityMask.magFilter = NearestFilter;
visibilityMask.minFilter = NearestFilter;
visibilityMask.needsUpdate = true;
return visibilityMask;
}, [emptySquares]);
return (
<Suspense
fallback={
// Render a wireframe while the terrain textures load.
<meshStandardMaterial
color="rgb(0, 109, 56)"
displacementMap={displacementMap}
displacementScale={2048}
wireframe
/>
}
>
<BlendedTerrainTextures
displacementMap={displacementMap}
visibilityMask={visibilityMask}
textureNames={textureNames}
alphaMaps={alphaMaps}
/>
</Suspense>
const texture = new DataTexture(
maskData,
TERRAIN_SIZE,
TERRAIN_SIZE,
RedFormat,
UnsignedByteType,
);
texture.colorSpace = NoColorSpace;
texture.wrapS = texture.wrapT = ClampToEdgeWrapping;
texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter;
texture.needsUpdate = true;
return texture;
}
export const TerrainBlock = memo(function TerrainBlock({
@ -209,53 +97,163 @@ export const TerrainBlock = memo(function TerrainBlock({
}) {
const terrainFile = getProperty(object, "terrainFile");
const squareSize = getInt(object, "squareSize") ?? DEFAULT_SQUARE_SIZE;
const blockSize = squareSize * 256;
const visibleDistance = useVisibleDistance();
const camera = useThree((state) => state.camera);
const emptySquares: number[] = useMemo(() => {
const emptySquaresValue = getProperty(object, "emptySquares");
// Note: This is a space-separated string, so we split and parse each component.
return emptySquaresValue
? emptySquaresValue.split(" ").map((s: string) => parseInt(s, 10))
: [];
const basePosition = useMemo(() => {
const [x, , z] = getPosition(object);
return { x, z };
}, [object]);
const position = useMemo(() => {
// Terrain position.z is ignored in Torque - heightmap values are absolute
const [x, y, z] = getPosition(object);
return [x, 0, z] as [number, number, number];
const emptySquares = useMemo(() => {
const value = getProperty(object, "emptySquares");
return value ? value.split(" ").map((s: string) => parseInt(s, 10)) : [];
}, [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const planeGeometry = useMemo(() => {
// Shared geometry for all tiles
const sharedGeometry = useMemo(() => {
const size = squareSize * 256;
const geometry = new PlaneGeometry(size, size, 256, 256);
// PlaneGeometry starts in XY plane. Rotate to XZ plane for Y-up world.
geometry.rotateX(-Math.PI / 2);
// Also need to rotate to swap X and Z.
geometry.rotateY(-Math.PI / 2);
// Shift origin from center to corner so position offset works correctly.
// Tribes 2 terrain origin is at the corner, Three.js PlaneGeometry is centered.
// But, T2 does this before the `squareSize` scales it up or down, so it's
// essentially a fixed offset.
const defaultSize = DEFAULT_SQUARE_SIZE * 256;
geometry.translate(defaultSize / 2, 0, defaultSize / 2);
return geometry;
}, [squareSize]);
const { data: terrain } = useTerrain(terrainFile);
// Shared displacement map from heightmap - created once for all tiles
const sharedDisplacementMap = useMemo(() => {
if (!terrain) return null;
const f32HeightMap = uint16ToFloat32(terrain.heightMap);
const texture = new DataTexture(
f32HeightMap,
TERRAIN_SIZE,
TERRAIN_SIZE,
RedFormat,
FloatType,
);
texture.colorSpace = NoColorSpace;
texture.generateMipmaps = false;
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
texture.needsUpdate = true;
return texture;
}, [terrain]);
// Visibility mask for primary tile (0,0) - may have empty squares
const primaryVisibilityMask = useMemo(
() => createVisibilityMask(emptySquares),
[emptySquares],
);
// Visibility mask for pooled tiles - all visible (no empty squares)
// This is a stable reference shared by all pooled tiles
const pooledVisibilityMask = useMemo(() => createVisibilityMask([]), []);
// Shared alpha textures from terrain alphaMaps - created once for all tiles
const sharedAlphaTextures = useMemo(() => {
if (!terrain) return null;
return terrain.alphaMaps.map((data) => setupMask(data));
}, [terrain]);
// Calculate the maximum number of tiles that can be visible at once.
const poolSize = useMemo(() => {
const extent = Math.ceil(visibleDistance / blockSize);
const gridSize = 2 * extent + 1;
return gridSize * gridSize - 1; // -1 because primary tile is separate
}, [visibleDistance, blockSize]);
// Create stable pool indices for React keys
const poolIndices = useMemo(
() => Array.from({ length: poolSize }, (_, i) => i),
[poolSize],
);
// Track which tile coordinate each pool slot is assigned to
const [tileAssignments, setTileAssignments] = useState<
(TileAssignment | null)[]
>(() => Array(poolSize).fill(null));
// Track previous tile bounds to avoid unnecessary state updates
const prevBoundsRef = useRef({ xStart: 0, xEnd: 0, zStart: 0, zEnd: 0 });
useFrame(() => {
const relativeCamX = camera.position.x - basePosition.x;
const relativeCamZ = camera.position.z - basePosition.z;
const xStart = Math.floor((relativeCamX - visibleDistance) / blockSize);
const xEnd = Math.ceil((relativeCamX + visibleDistance) / blockSize);
const zStart = Math.floor((relativeCamZ - visibleDistance) / blockSize);
const zEnd = Math.ceil((relativeCamZ + visibleDistance) / blockSize);
// Early exit if bounds haven't changed
const prev = prevBoundsRef.current;
if (
xStart === prev.xStart &&
xEnd === prev.xEnd &&
zStart === prev.zStart &&
zEnd === prev.zEnd
) {
return;
}
prev.xStart = xStart;
prev.xEnd = xEnd;
prev.zStart = zStart;
prev.zEnd = zEnd;
// Build new assignments array
const newAssignments: (TileAssignment | null)[] = [];
for (let x = xStart; x < xEnd; x++) {
for (let z = zStart; z < zEnd; z++) {
if (x === 0 && z === 0) continue;
newAssignments.push({ tileX: x, tileZ: z });
}
}
while (newAssignments.length < poolSize) {
newAssignments.push(null);
}
setTileAssignments(newAssignments);
});
if (!terrain || !sharedDisplacementMap || !sharedAlphaTextures) {
return null;
}
return (
<group position={position} quaternion={q} scale={scale}>
<mesh geometry={planeGeometry} receiveShadow castShadow>
{terrain ? (
<TerrainMaterial
heightMap={terrain.heightMap}
emptySquares={emptySquares}
<>
{/* Primary tile at (0,0) with emptySquares applied */}
<TerrainTile
tileX={0}
tileZ={0}
blockSize={blockSize}
basePosition={basePosition}
textureNames={terrain.textureNames}
geometry={sharedGeometry}
displacementMap={sharedDisplacementMap}
visibilityMask={primaryVisibilityMask}
alphaTextures={sharedAlphaTextures}
/>
{/* Pooled tiles - stable keys, always mounted */}
{poolIndices.map((poolIndex) => {
const assignment = tileAssignments[poolIndex];
return (
<TerrainTile
key={poolIndex}
tileX={assignment?.tileX ?? 0}
tileZ={assignment?.tileZ ?? 0}
blockSize={blockSize}
basePosition={basePosition}
textureNames={terrain.textureNames}
alphaMaps={terrain.alphaMaps}
geometry={sharedGeometry}
displacementMap={sharedDisplacementMap}
visibilityMask={pooledVisibilityMask}
alphaTextures={sharedAlphaTextures}
visible={assignment !== null}
/>
) : null}
</mesh>
</group>
);
})}
</>
);
});

View file

@ -0,0 +1,151 @@
import { memo, Suspense, useCallback, useMemo } from "react";
import { DataTexture, DoubleSide, FrontSide, type PlaneGeometry } from "three";
import { useTexture } from "@react-three/drei";
import { terrainTextureToUrl } from "../loaders";
import { setupColor, updateTerrainTextureShader } from "../textureUtils";
import { useDebug } from "./SettingsProvider";
const DEFAULT_SQUARE_SIZE = 8;
// Texture tiling factors for each terrain layer
const TILING: Record<number, number> = {
0: 32,
1: 32,
2: 32,
3: 32,
4: 32,
5: 32,
};
interface TerrainTileProps {
tileX: number;
tileZ: number;
blockSize: number;
basePosition: { x: number; z: number };
textureNames: string[];
geometry: PlaneGeometry;
displacementMap: DataTexture;
visibilityMask: DataTexture;
alphaTextures: DataTexture[];
visible?: boolean;
}
function BlendedTerrainTextures({
displacementMap,
visibilityMask,
textureNames,
alphaTextures,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaTextures: DataTexture[];
}) {
const { debugMode } = useDebug();
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
textures.forEach((tex) => setupColor(tex));
},
);
const onBeforeCompile = useCallback(
(shader) => {
updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
tiling: TILING,
debugMode,
});
},
[baseTextures, alphaTextures, visibilityMask, debugMode],
);
return (
<meshStandardMaterial
key={debugMode ? "debug" : "normal"}
displacementMap={displacementMap}
map={displacementMap}
displacementScale={2048}
depthWrite
side={debugMode ? DoubleSide : FrontSide}
onBeforeCompile={onBeforeCompile}
/>
);
}
function TerrainMaterial({
displacementMap,
visibilityMask,
textureNames,
alphaTextures,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaTextures: DataTexture[];
}) {
return (
<Suspense
fallback={
<meshStandardMaterial
color="rgb(0, 109, 56)"
displacementMap={displacementMap}
displacementScale={2048}
wireframe
/>
}
>
<BlendedTerrainTextures
displacementMap={displacementMap}
visibilityMask={visibilityMask}
textureNames={textureNames}
alphaTextures={alphaTextures}
/>
</Suspense>
);
}
export const TerrainTile = memo(function TerrainTile({
tileX,
tileZ,
blockSize,
basePosition,
textureNames,
geometry,
displacementMap,
visibilityMask,
alphaTextures,
visible = true,
}: TerrainTileProps) {
const position = useMemo(() => {
// PlaneGeometry is centered at origin, but Tribes 2 terrain origin is at
// corner. The engine always uses the default square size (8) for positioning.
const geometryOffset = (DEFAULT_SQUARE_SIZE * 256) / 2;
return [
basePosition.x + tileX * blockSize + geometryOffset,
0,
basePosition.z + tileZ * blockSize + geometryOffset,
] as [number, number, number];
}, [tileX, tileZ, blockSize, basePosition]);
return (
<mesh
position={position}
geometry={geometry}
receiveShadow
castShadow
visible={visible}
>
<TerrainMaterial
displacementMap={displacementMap}
visibilityMask={visibilityMask}
textureNames={textureNames}
alphaTextures={alphaTextures}
/>
</mesh>
);
});

View file

@ -0,0 +1,14 @@
import type { TorqueObject } from "../torqueScript";
import { useRuntime } from "./RuntimeProvider";
/**
* Look up a scene object by name from the runtime.
*
* FIXME: This is not currently reactive! If the object is created after
* this hook runs, it won't be found. We'd need to add an event/subscription
* system to the runtime that fires when objects are created.
*/
export function useSceneObject(name: string): TorqueObject | undefined {
const runtime = useRuntime();
return runtime.getObjectByName(name);
}

View file

@ -7,7 +7,9 @@ import {
} from "./manifest";
import { parseMissionScript } from "./mission";
import { normalizePath } from "./stringUtils";
import { parseTerrainBuffer } from "./terrain";
import { parseTerrainBuffer, type TerrainFile } from "./terrain";
export type { TerrainFile };
export const BASE_URL = "/t2-mapper";
export const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;

View file

@ -1,6 +1,13 @@
const SIZE = 256;
export function parseTerrainBuffer(arrayBuffer: ArrayBufferLike) {
export interface TerrainFile {
version: number;
textureNames: string[];
heightMap: Uint16Array;
alphaMaps: Uint8Array[];
}
export function parseTerrainBuffer(arrayBuffer: ArrayBufferLike): TerrainFile {
const dataView = new DataView(arrayBuffer);
let offset = 0;
const version = dataView.getUint8(offset++);