mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-01-19 12:14:47 +00:00
add terrain tiling, tweak fog
This commit is contained in:
parent
2a730b8a44
commit
d320fbd694
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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"}]]
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
151
src/components/TerrainTile.tsx
Normal file
151
src/components/TerrainTile.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
14
src/components/useSceneObject.ts
Normal file
14
src/components/useSceneObject.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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/`;
|
||||
|
|
|
|||
|
|
@ -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++);
|
||||
|
|
|
|||
Loading…
Reference in a new issue