remove as many transforms as possible, render Z-up axes

This commit is contained in:
Brian Beck 2025-11-25 16:56:54 -08:00
parent b2404a90af
commit 60a46e708b
424 changed files with 383 additions and 256882 deletions

View file

@ -50,7 +50,7 @@ export function AudioEmitter({ object }: { object: ConsoleObject }) {
);
const is3D = parseInt(getProperty(object, "is3D")?.value ?? "0");
const [z, y, x] = getPosition(object);
const [x, y, z] = getPosition(object);
const { scene, camera } = useThree();
const { audioLoader, audioListener } = useAudio();
const { audioEnabled } = useSettings();
@ -60,7 +60,7 @@ export function AudioEmitter({ object }: { object: ConsoleObject }) {
const loopGapIntervalRef = useRef<NodeJS.Timeout | null>(null);
const isLoadedRef = useRef(false);
const isInRangeRef = useRef(false);
const emitterPosRef = useRef(new Vector3(x - 1024, y, z - 1024));
const emitterPosRef = useRef(new Vector3(x, y, z));
// Create sound object on mount
useEffect(() => {
@ -208,10 +208,9 @@ export function AudioEmitter({ object }: { object: ConsoleObject }) {
<meshBasicMaterial
color="#00ff00"
wireframe
opacity={0.2}
opacity={0.05}
transparent
toneMapped={false}
fog={false}
/>
<FloatingLabel color="#00ff00" position={[0, minDistance + 1, 0]}>
{fileName}

View file

@ -1,12 +1,46 @@
import { Stats } from "@react-three/drei";
import { Stats, Html } from "@react-three/drei";
import { useSettings } from "./SettingsProvider";
import { useEffect, useRef } from "react";
import { AxesHelper } from "three";
export function DebugElements() {
const { debugMode } = useSettings();
const axesRef = useRef<AxesHelper>(null);
useEffect(() => {
const axes = axesRef.current;
if (!axes) {
return;
}
axes.setColors("rgb(153, 255, 0)", "rgb(0, 153, 255)", "rgb(255, 153, 0)");
});
return debugMode ? (
<>
<Stats className="StatsPanel" />
<axesHelper ref={axesRef} args={[70]} renderOrder={999}>
<lineBasicMaterial
depthTest={false}
depthWrite={false}
fog={false}
vertexColors
/>
</axesHelper>
<Html position={[80, 0, 0]} center>
<span className="AxisLabel" data-axis="y">
Y
</span>
</Html>
<Html position={[0, 80, 0]} center>
<span className="AxisLabel" data-axis="z">
Z
</span>
</Html>
<Html position={[0, 0, 80]} center>
<span className="AxisLabel" data-axis="x">
X
</span>
</Html>
</>
) : null;
}

View file

@ -104,7 +104,7 @@ export function ShapeModel() {
}, [nodes, hullBoneIndices]);
return (
<>
<group rotation={[0, Math.PI / 2, 0]}>
{processedNodes.map(({ node, geometry }) => (
<mesh key={node.id} geometry={geometry} castShadow receiveShadow>
{node.material ? (
@ -134,6 +134,6 @@ export function ShapeModel() {
</mesh>
))}
{debugMode ? <FloatingLabel>{shapeName}</FloatingLabel> : null}
</>
</group>
);
}

View file

@ -57,7 +57,7 @@ export const InteriorModel = memo(
const { nodes } = useInterior(interiorFile);
return (
<>
<group rotation={[0, -Math.PI / 2, 0]}>
{Object.entries(nodes)
.filter(
([name, node]: [string, any]) =>
@ -66,7 +66,7 @@ export const InteriorModel = memo(
.map(([name, node]: [string, any]) => (
<InteriorMesh key={name} node={node} />
))}
</>
</group>
);
}
);
@ -83,16 +83,12 @@ function InteriorPlaceholder() {
export const InteriorInstance = memo(
({ object }: { object: ConsoleObject }) => {
const interiorFile = getProperty(object, "interiorFile").value;
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
return (
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[-scaleX, scaleY, -scaleZ]}
>
<group position={position} quaternion={q} scale={scale}>
<Suspense fallback={<InteriorPlaceholder />}>
<InteriorModel interiorFile={interiorFile} />
</Suspense>

View file

@ -57,9 +57,9 @@ function getDataBlockShape(dataBlock: string) {
export function Item({ object }: { object: ConsoleObject }) {
const dataBlock = getProperty(object, "dataBlock").value;
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const shapeName = getDataBlockShape(dataBlock);
@ -69,11 +69,7 @@ export function Item({ object }: { object: ConsoleObject }) {
return (
<ShapeInfoProvider shapeName={shapeName} type="Item">
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
<group position={position} quaternion={q} scale={scale}>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="pink" />}>

View file

@ -16,5 +16,5 @@ export function Mission({ name }: { name: string }) {
return null;
}
return <>{mission.objects.map((object, i) => renderObject(object, i))}</>;
return mission.objects.map((object, i) => renderObject(object, i));
}

View file

@ -4,7 +4,5 @@ import { useSettings } from "./SettingsProvider";
export function ObserverCamera() {
const { fov } = useSettings();
return (
<PerspectiveCamera makeDefault position={[-512, 256, -512]} fov={fov} />
);
return <PerspectiveCamera makeDefault position={[0, 256, 0]} fov={fov} />;
}

View file

@ -14,7 +14,7 @@ enum Controls {
down = "down",
}
const BASE_SPEED = 80; // units per second
const BASE_SPEED = 80;
const MIN_SPEED_ADJUSTMENT = 0.05;
const MAX_SPEED_ADJUSTMENT = 0.5;
@ -78,7 +78,7 @@ function CameraMovement() {
};
}, [gl]);
useFrame((_, delta) => {
useFrame((state, delta) => {
const { forward, backward, left, right, up, down } = getKeys();
if (!forward && !backward && !left && !right && !up && !down) {

View file

@ -47,9 +47,9 @@ function getDataBlockShape(dataBlock: string) {
export function StaticShape({ object }: { object: ConsoleObject }) {
const dataBlock = getProperty(object, "dataBlock").value;
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const shapeName = getDataBlockShape(dataBlock);
@ -59,11 +59,7 @@ export function StaticShape({ object }: { object: ConsoleObject }) {
return (
<ShapeInfoProvider shapeName={shapeName} type="StaticShape">
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
<group position={position} quaternion={q} scale={scale}>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>

View file

@ -13,9 +13,9 @@ import { ShapeInfoProvider } from "./ShapeInfoProvider";
export function TSStatic({ object }: { object: ConsoleObject }) {
const shapeName = getProperty(object, "shapeName").value;
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
if (!shapeName) {
console.error("<TSStatic> missing shapeName for object", object);
@ -23,11 +23,7 @@ export function TSStatic({ object }: { object: ConsoleObject }) {
return (
<ShapeInfoProvider shapeName={shapeName} type="TSStatic">
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
<group position={position} quaternion={q} scale={scale}>
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel />

View file

@ -9,6 +9,8 @@ import {
ClampToEdgeWrapping,
UnsignedByteType,
PlaneGeometry,
DoubleSide,
FrontSide,
} from "three";
import { useTexture } from "@react-three/drei";
import { uint16ToFloat32 } from "../arrayUtils";
@ -25,6 +27,9 @@ import {
setupMask,
updateTerrainTextureShader,
} from "../textureUtils";
import { useSettings } from "./SettingsProvider";
const DEFAULT_SQUARE_SIZE = 8;
/**
* Load a .ter file, used for terrain heightmap and texture info.
@ -47,6 +52,8 @@ function BlendedTerrainTextures({
textureNames: string[];
alphaMaps: Uint8Array[];
}) {
const { debugMode } = useSettings();
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
@ -79,19 +86,22 @@ function BlendedTerrainTextures({
alphaTextures,
visibilityMask,
tiling,
debugMode,
});
},
[baseTextures, alphaTextures, visibilityMask, tiling]
[baseTextures, alphaTextures, visibilityMask, tiling, debugMode]
);
return (
<meshStandardMaterial
// For testing tiling values; forces recompile.
key={JSON.stringify(tiling)}
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}
/>
);
@ -199,7 +209,9 @@ export function TerrainBlock({ object }: { object: ConsoleObject }) {
object,
"squareSize"
)?.value;
return squareSizeString ? parseInt(squareSizeString, 10) : 8;
return squareSizeString
? parseInt(squareSizeString, 10)
: DEFAULT_SQUARE_SIZE;
}, [object]);
const emptySquares: number[] = useMemo(() => {
@ -213,37 +225,44 @@ export function TerrainBlock({ object }: { object: ConsoleObject }) {
: [];
}, [object]);
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [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];
}, [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const planeGeometry = 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);
return (
<mesh
quaternion={q}
position={[position[0], 0, position[2]]} // Y up is unused for terrain
scale={scale}
geometry={planeGeometry}
receiveShadow
castShadow
>
{terrain ? (
<TerrainMaterial
heightMap={terrain.heightMap}
emptySquares={emptySquares}
textureNames={terrain.textureNames}
alphaMaps={terrain.alphaMaps}
/>
) : null}
</mesh>
<group position={position} quaternion={q} scale={scale}>
<mesh geometry={planeGeometry} receiveShadow castShadow>
{terrain ? (
<TerrainMaterial
heightMap={terrain.heightMap}
emptySquares={emptySquares}
textureNames={terrain.textureNames}
alphaMaps={terrain.alphaMaps}
/>
) : null}
</mesh>
</group>
);
}

View file

@ -38,9 +38,9 @@ export function Turret({ object }: { object: ConsoleObject }) {
const dataBlock = getProperty(object, "dataBlock").value;
const initialBarrel = getProperty(object, "initialBarrel").value;
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const shapeName = getDataBlockShape(dataBlock);
const barrelShapeName = getDataBlockShape(initialBarrel);
@ -56,11 +56,7 @@ export function Turret({ object }: { object: ConsoleObject }) {
return (
<ShapeInfoProvider shapeName={shapeName} type="Turret">
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[-scaleX, scaleY, scaleZ]}
>
<group position={position} quaternion={q} scale={scale}>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>

View file

@ -1,5 +1,6 @@
import { Suspense, useMemo } from "react";
import { Suspense, useEffect, useMemo } from "react";
import { useTexture } from "@react-three/drei";
import { BoxGeometry, DoubleSide } from "three";
import { textureToUrl } from "../loaders";
import {
ConsoleObject,
@ -10,34 +11,92 @@ import {
} from "../mission";
import { setupColor } from "../textureUtils";
export function WaterMaterial({ surfaceTexture }: { surfaceTexture: string }) {
export function WaterMaterial({
surfaceTexture,
attach,
}: {
surfaceTexture: string;
attach?: string;
}) {
const url = textureToUrl(surfaceTexture);
const texture = useTexture(url, (texture) => setupColor(texture, [8, 8]));
const texture = useTexture(url, (texture) => setupColor(texture));
return <meshStandardMaterial map={texture} transparent opacity={0.8} />;
return (
<meshStandardMaterial
attach={attach}
map={texture}
transparent
opacity={0.8}
side={DoubleSide}
/>
);
}
export function WaterBlock({ object }: { object: ConsoleObject }) {
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleZ, scaleY, scaleX] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const surfaceTexture =
getProperty(object, "surfaceTexture")?.value ?? "liquidTiles/BlueWater";
const geometry = useMemo(() => {
const geom = new BoxGeometry(scaleX, scaleY, scaleZ);
geom.translate(scaleX / 2, scaleY / 2, scaleZ / 2);
const uvAttr = geom.getAttribute("uv");
const uv = uvAttr.array as Float32Array;
const faceRepeats: [number, number][] = [
// +x, -x (depth x height)
[scaleX / 32, scaleY / 32],
[scaleX / 32, scaleY / 32],
// +y, -y (width x depth)
[scaleZ / 32, scaleX / 32],
[scaleZ / 32, scaleX / 32],
// +z, -z (width x height)
[scaleZ / 32, scaleY / 32],
[scaleZ / 32, scaleY / 32],
];
for (let face = 0; face < 6; face++) {
const [uRepeat, vRepeat] = faceRepeats[face];
const offset = face * 4 * 2; // 4 verts per face, 2 components per vert
for (let i = 0; i < 4; i++) {
uv[offset + i * 2] *= uRepeat;
uv[offset + i * 2 + 1] *= vRepeat;
}
}
uvAttr.needsUpdate = true;
return geom;
}, [scaleX, scaleY, scaleZ]);
useEffect(() => {
return () => {
geometry.dispose();
};
}, [geometry]);
return (
<mesh
position={[x - 1024 + scaleX / 2, y + scaleY / 2, z - 1024 + scaleZ / 2]}
quaternion={q}
>
<boxGeometry args={[scaleZ, scaleY, scaleX]} />
<mesh position={position} quaternion={q} geometry={geometry}>
<meshStandardMaterial attach="material-0" transparent opacity={0} />
<meshStandardMaterial attach="material-1" transparent opacity={0} />
<Suspense
fallback={
<meshStandardMaterial color="blue" transparent opacity={0.3} />
<meshStandardMaterial
attach="material-2"
color="blue"
transparent
opacity={0.3}
side={DoubleSide}
/>
}
>
<WaterMaterial surfaceTexture={surfaceTexture} />
<WaterMaterial attach="material-2" surfaceTexture={surfaceTexture} />
</Suspense>
<meshStandardMaterial attach="material-3" transparent opacity={0} />
<meshStandardMaterial attach="material-4" transparent opacity={0} />
<meshStandardMaterial attach="material-5" transparent opacity={0} />
</mesh>
);
}

View file

@ -28,7 +28,7 @@ export function getUrlForPath(resourcePath: string, fallbackUrl?: string) {
export function interiorToUrl(name: string) {
const difUrl = getUrlForPath(`interiors/${name}`);
return difUrl.replace(/\.dif$/i, ".gltf");
return difUrl.replace(/\.dif$/i, ".glb");
}
export function shapeToUrl(name: string) {

View file

@ -212,38 +212,25 @@ export function getProperty(obj: ConsoleObject, name: string) {
export function getPosition(obj: ConsoleObject): [number, number, number] {
const position = getProperty(obj, "position")?.value ?? "0 0 0";
const [x, z, y] = position.split(" ").map((s) => parseFloat(s));
return [x || 0, y || 0, z || 0];
const [x, y, z] = position.split(" ").map((s) => parseFloat(s));
// Convert Torque3D coordinates to Three.js: XYZ -> YZX
return [y || 0, z || 0, x || 0];
}
export function getScale(obj: ConsoleObject): [number, number, number] {
const scale = getProperty(obj, "scale")?.value ?? "1 1 1";
const [scaleX, scaleZ, scaleY] = scale.split(" ").map((s) => parseFloat(s));
return [scaleX, scaleY, scaleZ];
const [sx, sy, sz] = scale.split(" ").map((s) => parseFloat(s));
// Convert Torque3D coordinates to Three.js: XYZ -> YZX
return [sy || 0, sz || 0, sx || 0];
}
export function getRotation(obj: ConsoleObject, isInterior = false) {
export function getRotation(obj: ConsoleObject): Quaternion {
const rotation = getProperty(obj, "rotation")?.value ?? "1 0 0 0";
const [ax, az, ay, angle] = rotation.split(" ").map((s) => parseFloat(s));
if (isInterior) {
// For interiors: Apply coordinate system transformation
// 1. Convert rotation axis from source coords (ax, az, ay) to Three.js coords
// 2. Apply -90 Y rotation to align coordinate systems
const sourceRotation = new Quaternion().setFromAxisAngle(
new Vector3(az, ay, ax),
-angle * (Math.PI / 180)
);
const coordSystemFix = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
Math.PI / 2
);
return sourceRotation.multiply(coordSystemFix);
} else {
// For other objects (terrain, etc)
return new Quaternion().setFromAxisAngle(
new Vector3(ax, ay, -az),
angle * (Math.PI / 180)
);
}
const [ax, ay, az, angleDegrees] = rotation
.split(" ")
.map((s) => parseFloat(s));
// Convert Torque3D coordinates to Three.js: XYZ -> YZX
const axis = new Vector3(ay, az, ax).normalize();
const angleRadians = -angleDegrees * (Math.PI / 180);
return new Quaternion().setFromAxisAngle(axis, angleRadians);
}

View file

@ -1,5 +1,4 @@
const SIZE = 256;
const SCALE = 8;
export function parseTerrainBuffer(arrayBuffer: ArrayBufferLike) {
const dataView = new DataView(arrayBuffer);

View file

@ -53,6 +53,14 @@ export function updateTerrainTextureShader({
alphaTextures,
visibilityMask,
tiling,
debugMode = false,
}: {
shader: any;
baseTextures: any[];
alphaTextures: any[];
visibilityMask: any;
tiling: Record<number, number>;
debugMode?: boolean;
}) {
const layerCount = baseTextures.length;
@ -78,6 +86,9 @@ export function updateTerrainTextureShader({
};
});
// Add debug mode uniform
shader.uniforms.debugMode = { value: debugMode ? 1.0 : 0.0 };
// Declare our uniforms at the top of the fragment shader
shader.fragmentShader =
`
@ -98,7 +109,17 @@ uniform float tiling2;
uniform float tiling3;
uniform float tiling4;
uniform float tiling5;
uniform float debugMode;
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
// Wireframe edge detection for debug mode
float getWireframe(vec2 uv, float gridSize, float lineWidth) {
vec2 gridUv = uv * gridSize;
vec2 grid = abs(fract(gridUv - 0.5) - 0.5);
vec2 deriv = fwidth(gridUv);
vec2 edge = smoothstep(vec2(0.0), deriv * lineWidth, grid);
return 1.0 - min(edge.x, edge.y);
}
` + shader.fragmentShader;
if (visibilityMask) {
@ -164,7 +185,27 @@ ${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
${layerCount > 5 ? `blended = mix(blended, c5, clamp(a5, 0.0, 1.0));` : ""}
// Assign to diffuseColor before lighting
diffuseColor.rgb = ${layerCount > 1 ? "blended" : "c0"};
vec3 textureColor = ${layerCount > 1 ? "blended" : "c0"};
// Debug mode wireframe handling
if (debugMode > 0.5) {
// 256 grid cells across the terrain (matches terrain resolution)
float wireframe = getWireframe(baseUv, 256.0, 1.0);
vec3 wireColor = vec3(0.0, 0.8, 0.4); // Green wireframe
if (gl_FrontFacing) {
// Front face: show textures with barely visible wireframe overlay
diffuseColor.rgb = mix(textureColor, wireColor, wireframe * 0.05);
} else {
// Back face: show only wireframe, discard non-wireframe pixels
if (wireframe < 0.1) {
discard;
}
diffuseColor.rgb = mix(vec3(0.0), wireColor, 0.25);
}
} else {
diffuseColor.rgb = textureColor;
}
`
);
}