extensive work on lighting, shadows, and fog

- use MeshLambertMaterial for interiors, terrain, and shapes
- use smooth vertex normal blending to avoid facted-looking contrasty lighting
  between adjacent surfaces
- update io_dif Blender addon to extract lightmaps
- re-export .dif files to glTF with lightmaps and without LOD
- enable sun, ensure correct direction
- adjust fog (more work to do)
- cleanup and optimization
This commit is contained in:
Brian Beck 2025-12-07 14:01:26 -08:00
parent 035812724d
commit 3ba1ce9afd
927 changed files with 632 additions and 215 deletions

View file

@ -1,18 +1,108 @@
import { memo, Suspense, useMemo, useRef, useEffect } from "react";
import { memo, Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useGLTF, useTexture } from "@react-three/drei";
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils";
import {
createAlphaAsRoughnessMaterial,
setupAlphaAsRoughnessTexture,
} from "../shapeMaterial";
import { MeshStandardMaterial } from "three";
import { setupColor } from "../textureUtils";
MeshStandardMaterial,
MeshBasicMaterial,
MeshLambertMaterial,
AdditiveBlending,
Texture,
BufferGeometry,
} from "three";
import { setupColor, setupAlphaTestedTexture } from "../textureUtils";
import { useDebug } from "./SettingsProvider";
import { useShapeInfo } from "./ShapeInfoProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { FloatingLabel } from "./FloatingLabel";
import { useIflTexture } from "./useIflTexture";
/** Shared props for texture rendering components */
interface TextureProps {
material: MeshStandardMaterial;
shapeName?: string;
geometry?: BufferGeometry;
backGeometry?: BufferGeometry;
castShadow?: boolean;
receiveShadow?: boolean;
}
/**
* DTS Material Flags (from tsShape.h):
* - Translucent: Material has alpha transparency (smooth blending)
* - Additive: Additive blending mode
* - Subtractive: Subtractive blending mode
* - SelfIlluminating: Fullbright, no lighting applied
* - NeverEnvMap: Don't apply environment mapping
*/
type SingleMaterial =
| MeshStandardMaterial
| MeshBasicMaterial
| MeshLambertMaterial;
type MaterialResult =
| SingleMaterial
| [MeshLambertMaterial, MeshLambertMaterial];
function createMaterialFromFlags(
baseMaterial: MeshStandardMaterial,
texture: Texture,
flagNames: Set<string>,
isOrganic: boolean,
): MaterialResult {
const isTranslucent = flagNames.has("Translucent");
const isAdditive = flagNames.has("Additive");
const isSelfIlluminating = flagNames.has("SelfIlluminating");
const neverEnvMap = flagNames.has("NeverEnvMap");
// SelfIlluminating materials are unlit (use MeshBasicMaterial)
if (isSelfIlluminating) {
const mat = new MeshBasicMaterial({
map: texture,
side: 2, // DoubleSide
transparent: isAdditive,
alphaTest: isAdditive ? 0 : 0.5,
blending: isAdditive ? AdditiveBlending : undefined,
fog: true,
});
return mat;
}
// For organic shapes or Translucent flag, use alpha cutout with Lambert shading
// Tribes 2 used fixed-function GL with specular disabled - purely diffuse lighting
// MeshLambertMaterial gives us the diffuse-only look that matches the original
// Return [BackSide, FrontSide] materials to render in two passes - avoids z-fighting
if (isOrganic || isTranslucent) {
const baseProps = {
map: texture,
transparent: false,
alphaTest: 0.5,
reflectivity: 0,
};
const backMat = new MeshLambertMaterial({
...baseProps,
side: 1, // BackSide
// Push back faces slightly behind in depth to avoid z-fighting with front
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
});
const frontMat = new MeshLambertMaterial({
...baseProps,
side: 0, // FrontSide
});
return [backMat, frontMat];
}
// Default: use Lambert for diffuse-only lighting (matches Tribes 2)
// Tribes 2 used fixed-function GL with specular disabled
const mat = new MeshLambertMaterial({
map: texture,
side: 2, // DoubleSide
reflectivity: 0,
});
return mat;
}
/**
* Load a .glb file that was converted from a .dts, used for static shapes.
*/
@ -25,41 +115,70 @@ export function useStaticShape(shapeName: string) {
* Animated IFL (Image File List) material component. Creates a sprite sheet
* from all frames and animates via texture offset.
*/
export function IflTexture({
const IflTexture = memo(function IflTexture({
material,
shapeName,
}: {
material: MeshStandardMaterial;
shapeName?: string;
}) {
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
}: TextureProps) {
const resourcePath = material.userData.resource_path;
// Convert resource_path (e.g., "skins/blue00") to IFL path
const flagNames = new Set<string>(material.userData.flag_names ?? []);
const iflPath = `textures/${resourcePath}.ifl`;
const texture = useIflTexture(iflPath);
const isOrganic = shapeName && isOrganicShape(shapeName);
const isOrganic = shapeName && /borg|xorg|porg|dorg/i.test(shapeName);
const customMaterial = useMemo(
() => createMaterialFromFlags(material, texture, flagNames, isOrganic),
[material, texture, flagNames, isOrganic],
);
const customMaterial = useMemo(() => {
if (!isOrganic) {
const shaderMaterial = createAlphaAsRoughnessMaterial();
shaderMaterial.map = texture;
return shaderMaterial;
}
// Two-pass rendering for organic/translucent materials
// Render BackSide first (with flipped normals), then FrontSide
if (Array.isArray(customMaterial)) {
return (
<>
<mesh
geometry={backGeometry || geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial[0]} attach="material" />
</mesh>
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial[1]} attach="material" />
</mesh>
</>
);
}
const clonedMaterial = material.clone();
clonedMaterial.map = texture;
clonedMaterial.transparent = true;
clonedMaterial.alphaTest = 0.9;
clonedMaterial.side = 2; // DoubleSide
return clonedMaterial;
}, [material, texture, isOrganic]);
return (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
return <primitive object={customMaterial} attach="material" />;
}
function StaticTexture({ material, shapeName }) {
const StaticTexture = memo(function StaticTexture({
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
}: TextureProps) {
const resourcePath = material.userData.resource_path;
const flagNames = new Set<string>(material.userData.flag_names ?? []);
const url = useMemo(() => {
if (!resourcePath) {
@ -67,61 +186,99 @@ function StaticTexture({ material, shapeName }) {
`No resource_path was found on "${shapeName}" - rendering fallback.`,
);
}
return resourcePath
? // Use custom `resource_path` added by forked io_dts3d Blender add-on
textureToUrl(resourcePath)
: FALLBACK_TEXTURE_URL;
}, [material, resourcePath, shapeName]);
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
}, [resourcePath, shapeName]);
const isOrganic = shapeName && /borg|xorg|porg|dorg/i.test(shapeName);
const isOrganic = shapeName && isOrganicShape(shapeName);
const isTranslucent = flagNames.has("Translucent");
const texture = useTexture(url, (texture) => {
if (!isOrganic) {
setupAlphaAsRoughnessTexture(texture);
// Organic/alpha-tested textures need special handling to avoid mipmap artifacts
if (isOrganic || isTranslucent) {
return setupAlphaTestedTexture(texture);
}
// Standard color texture setup for diffuse-only materials
return setupColor(texture);
});
const customMaterial = useMemo(() => {
// Only use alpha-as-roughness material for borg shapes
if (!isOrganic) {
const shaderMaterial = createAlphaAsRoughnessMaterial();
shaderMaterial.map = texture;
return shaderMaterial;
}
const customMaterial = useMemo(
() => createMaterialFromFlags(material, texture, flagNames, isOrganic),
[material, texture, flagNames, isOrganic],
);
// For non-borg shapes, use the original GLTF material with updated texture
const clonedMaterial = material.clone();
clonedMaterial.map = texture;
clonedMaterial.transparent = true;
clonedMaterial.alphaTest = 0.9;
clonedMaterial.side = 2; // DoubleSide
return clonedMaterial;
}, [material, texture, isOrganic]);
// Two-pass rendering for organic/translucent materials
// Render BackSide first (with flipped normals), then FrontSide
if (Array.isArray(customMaterial)) {
return (
<>
<mesh
geometry={backGeometry || geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial[0]} attach="material" />
</mesh>
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial[1]} attach="material" />
</mesh>
</>
);
}
return <primitive object={customMaterial} attach="material" />;
}
return (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
export function ShapeTexture({
export const ShapeTexture = memo(function ShapeTexture({
material,
shapeName,
}: {
material?: MeshStandardMaterial;
shapeName?: string;
}) {
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
}: TextureProps) {
const flagNames = new Set(material.userData.flag_names ?? []);
const isIflMaterial = flagNames.has("IflMaterial");
const resourcePath = material.userData.resource_path;
// Use IflTexture for animated materials
if (isIflMaterial && resourcePath) {
return <IflTexture material={material} shapeName={shapeName} />;
return (
<IflTexture
material={material}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
);
} else if (material.name) {
return <StaticTexture material={material} shapeName={shapeName} />;
return (
<StaticTexture
material={material}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
);
} else {
return null;
}
}
});
export function ShapePlaceholder({
color,
@ -150,8 +307,37 @@ export function DebugPlaceholder({
return debugMode ? <ShapePlaceholder color={color} label={label} /> : null;
}
/**
* Wrapper component that handles the common ErrorBoundary + Suspense + ShapeModel
* pattern used across shape-rendering components.
*/
export function ShapeRenderer({
shapeName,
loadingColor = "yellow",
children,
}: {
shapeName: string | undefined;
loadingColor?: string;
children?: React.ReactNode;
}) {
if (!shapeName) {
return <DebugPlaceholder color="orange" />;
}
return (
<ErrorBoundary
fallback={<DebugPlaceholder color="red" label={shapeName} />}
>
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
<ShapeModel />
{children}
</Suspense>
</ErrorBoundary>
);
}
export const ShapeModel = memo(function ShapeModel() {
const { shapeName } = useShapeInfo();
const { shapeName, isOrganic } = useShapeInfo();
const { debugMode } = useDebug();
const { nodes } = useStaticShape(shapeName);
@ -176,43 +362,122 @@ export const ShapeModel = memo(function ShapeModel() {
!node.name.match(/^Hulk/i),
)
.map(([name, node]: [string, any]) => {
const geometry = filterGeometryByVertexGroups(
let geometry = filterGeometryByVertexGroups(
node.geometry,
hullBoneIndices,
);
return { node, geometry };
let backGeometry = null;
// Compute smooth vertex normals for ALL shapes to match Tribes 2's lighting
if (geometry) {
geometry = geometry.clone();
// First compute face normals
geometry.computeVertexNormals();
// Then smooth normals across vertices at the same position
// This handles split vertices (for UV seams) that computeVertexNormals misses
const posAttr = geometry.attributes.position;
const normAttr = geometry.attributes.normal;
const positions = posAttr.array as Float32Array;
const normals = normAttr.array as Float32Array;
// Build a map of position -> list of vertex indices at that position
const positionMap = new Map<string, number[]>();
for (let i = 0; i < posAttr.count; i++) {
// Round to avoid floating point precision issues
const key = `${positions[i * 3].toFixed(4)},${positions[i * 3 + 1].toFixed(4)},${positions[i * 3 + 2].toFixed(4)}`;
if (!positionMap.has(key)) {
positionMap.set(key, []);
}
positionMap.get(key)!.push(i);
}
// Average normals for vertices at the same position
for (const indices of positionMap.values()) {
if (indices.length > 1) {
// Sum all normals at this position
let nx = 0,
ny = 0,
nz = 0;
for (const idx of indices) {
nx += normals[idx * 3];
ny += normals[idx * 3 + 1];
nz += normals[idx * 3 + 2];
}
// Normalize the sum
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 0) {
nx /= len;
ny /= len;
nz /= len;
}
// Apply averaged normal to all vertices at this position
for (const idx of indices) {
normals[idx * 3] = nx;
normals[idx * 3 + 1] = ny;
normals[idx * 3 + 2] = nz;
}
}
}
normAttr.needsUpdate = true;
// For organic shapes, also create back geometry with flipped normals
if (isOrganic) {
backGeometry = geometry.clone();
const backNormAttr = backGeometry.attributes.normal;
const backNormals = backNormAttr.array;
for (let i = 0; i < backNormals.length; i++) {
backNormals[i] = -backNormals[i];
}
backNormAttr.needsUpdate = true;
}
}
return { node, geometry, backGeometry };
});
}, [nodes, hullBoneIndices]);
}, [nodes, hullBoneIndices, isOrganic]);
// Disable shadows for organic shapes to avoid artifacts with alpha-tested materials
// Shadow maps don't properly handle alpha transparency, causing checkerboard patterns
const enableShadows = !isOrganic;
return (
<group rotation={[0, Math.PI / 2, 0]}>
{processedNodes.map(({ node, geometry }) => (
<mesh key={node.id} geometry={geometry} castShadow receiveShadow>
{processedNodes.map(({ node, geometry, backGeometry }) => (
<Suspense
key={node.id}
fallback={
<mesh geometry={geometry}>
<meshStandardMaterial color="gray" wireframe />
</mesh>
}
>
{node.material ? (
<Suspense
fallback={
// Allow the mesh to render while the texture is still loading;
// show a wireframe placeholder.
<meshStandardMaterial color="gray" wireframe />
}
>
{Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<ShapeTexture
key={index}
material={mat as MeshStandardMaterial}
shapeName={shapeName}
/>
))
) : (
Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<ShapeTexture
material={node.material as MeshStandardMaterial}
key={index}
material={mat as MeshStandardMaterial}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={enableShadows}
receiveShadow={enableShadows}
/>
)}
</Suspense>
))
) : (
<ShapeTexture
material={node.material as MeshStandardMaterial}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={enableShadows}
receiveShadow={enableShadows}
/>
)
) : null}
</mesh>
</Suspense>
))}
{debugMode ? <FloatingLabel>{shapeName}</FloatingLabel> : null}
</group>

View file

@ -1,6 +1,6 @@
import { memo, Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Mesh } from "three";
import { Mesh, Material, MeshStandardMaterial, Texture } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { textureToUrl, interiorToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
@ -9,6 +9,8 @@ import { setupColor } from "../textureUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
const LIGHTMAP_INTENSITY = 4;
/**
* Load a .gltf file that was converted from a .dif, used for "interior" models.
*/
@ -17,14 +19,62 @@ function useInterior(interiorFile: string) {
return useGLTF(url);
}
function InteriorTexture({ materialName }: { materialName: string }) {
function InteriorTexture({
materialName,
material,
lightMap,
}: {
materialName: string;
material?: Material;
lightMap?: Texture | null;
}) {
const url = textureToUrl(materialName);
const texture = useTexture(url, (texture) => setupColor(texture));
return <meshStandardMaterial map={texture} side={2} />;
// Check for self-illuminating flag in material userData
// Note: The io_dif Blender add-on needs to be updated to export material flags
const flagNames = new Set<string>(material?.userData?.flag_names ?? []);
const isSelfIlluminating = flagNames.has("SelfIlluminating");
// Self-illuminating materials are fullbright (unlit)
if (isSelfIlluminating) {
return <meshBasicMaterial map={texture} side={2} toneMapped={false} />;
}
// Use lightMap if available (baked lighting from DIF files)
// Three.js MeshLambertMaterial automatically uses uv2 for lightMap
return (
<meshLambertMaterial
map={texture}
lightMap={lightMap ?? undefined}
lightMapIntensity={lightMap ? LIGHTMAP_INTENSITY : undefined}
side={2}
/>
);
}
/**
* Extract lightmap texture from a glTF material.
* The io_dif Blender addon stores lightmaps in the emissive channel for transport.
*/
function getLightMap(material: Material | null): Texture | null {
if (!material) return null;
// glTF materials come through as MeshStandardMaterial
const stdMat = material as MeshStandardMaterial;
// Lightmap is stored in emissiveMap with 0 strength (just for glTF transport)
return stdMat.emissiveMap ?? null;
}
function InteriorMesh({ node }: { node: Mesh }) {
// Extract lightmaps from original materials (stored in emissiveMap for glTF transport)
const lightMaps = useMemo(() => {
if (!node.material) return [];
if (Array.isArray(node.material)) {
return node.material.map(getLightMap);
}
return [getLightMap(node.material)];
}, [node.material]);
return (
<mesh geometry={node.geometry} castShadow receiveShadow>
{node.material ? (
@ -40,11 +90,15 @@ function InteriorMesh({ node }: { node: Mesh }) {
<InteriorTexture
key={index}
materialName={mat.userData.resource_path}
material={mat}
lightMap={lightMaps[index]}
/>
))
) : (
<InteriorTexture
materialName={node.material.userData.resource_path}
material={node.material}
lightMap={lightMaps[0]}
/>
)}
</Suspense>
@ -61,10 +115,7 @@ export const InteriorModel = memo(
return (
<group rotation={[0, -Math.PI / 2, 0]}>
{Object.entries(nodes)
.filter(
([name, node]: [string, any]) =>
!node.material || !node.material.name.match(/\.\d+$/),
)
.filter(([, node]: [string, any]) => node.isMesh)
.map(([name, node]: [string, any]) => (
<InteriorMesh key={name} node={node} />
))}

View file

@ -1,14 +1,13 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import { useSimGroup } from "./SimGroup";
import { FloatingLabel } from "./FloatingLabel";
import { useDatablock } from "./useDatablock";
const TEAM_NAMES = {
const TEAM_NAMES: Record<number, string> = {
1: "Storm",
2: "Inferno",
};
@ -30,26 +29,15 @@ export function Item({ object }: { object: TorqueObject }) {
const isFlag = datablockName?.toLowerCase() === "flag";
const team = simGroup?.team ?? null;
const teamName = team > 0 ? TEAM_NAMES[team] : null;
const teamName = team && team > 0 ? TEAM_NAMES[team] : null;
const label = isFlag && teamName ? `${teamName} Flag` : null;
return (
<ShapeInfoProvider shapeName={shapeName} type="Item">
<group position={position} quaternion={q} scale={scale}>
{shapeName ? (
<ErrorBoundary
fallback={<DebugPlaceholder color="red" label={shapeName} />}
>
<Suspense fallback={<ShapePlaceholder color="pink" />}>
<ShapeModel />
{label ? (
<FloatingLabel opacity={0.6}>{label}</FloatingLabel>
) : null}
</Suspense>
</ErrorBoundary>
) : (
<DebugPlaceholder color="orange" />
)}
<ShapeRenderer shapeName={shapeName} loadingColor="pink">
{label ? <FloatingLabel opacity={0.6}>{label}</FloatingLabel> : null}
</ShapeRenderer>
</group>
</ShapeInfoProvider>
);

View file

@ -2,6 +2,21 @@ import { createContext, ReactNode, useContext, useMemo } from "react";
export type StaticShapeType = "TSStatic" | "StaticShape" | "Item" | "Turret";
/**
* Detect organic/vegetation shapes that use alpha for transparency.
* These need special handling for materials and shadows.
*
* Pattern matches:
* - borg/xorg/porg/dorg: Tribes 2 organic environment types
* - plant/tree/bush/fern/vine/grass/leaf/flower: common vegetation names
*/
const ORGANIC_PATTERN =
/borg|xorg|porg|dorg|plant|tree|bush|fern|vine|grass|leaf|flower|frond|palm|foliage/i;
export function isOrganicShape(shapeName: string): boolean {
return ORGANIC_PATTERN.test(shapeName);
}
const ShapeInfoContext = createContext(null);
export function useShapeInfo() {
@ -17,7 +32,11 @@ export function ShapeInfoProvider({
shapeName: string;
type: StaticShapeType;
}) {
const context = useMemo(() => ({ shapeName, type }), [shapeName, type]);
const isOrganic = useMemo(() => isOrganicShape(shapeName), [shapeName]);
const context = useMemo(
() => ({ shapeName, type, isOrganic }),
[shapeName, type, isOrganic],
);
return (
<ShapeInfoContext.Provider value={context}>

View file

@ -1,4 +1,4 @@
import { Suspense, useMemo } from "react";
import { Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei";
import { Color, ShaderMaterial, BackSide, ShaderChunk } from "three";
@ -103,6 +103,8 @@ export function SkyBox({
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
const materialRef = useRef<ShaderMaterial>(null!);
const shaderMaterial = useMemo(() => {
// Always use a shader to apply the X-axis mirror transformation.
// Optionally blend fog toward the horizon.
@ -151,10 +153,20 @@ export function SkyBox({
});
}, [skyBox, fogColor]);
// Update uniforms when props change (ensures reactivity)
useEffect(() => {
if (materialRef.current) {
materialRef.current.uniforms.skybox.value = skyBox;
materialRef.current.uniforms.fogColor.value =
fogColor ?? new Color(0, 0, 0);
materialRef.current.uniforms.enableFog.value = !!fogColor;
}
}, [skyBox, fogColor]);
return (
<mesh scale={5000} frustumCulled={false}>
<sphereGeometry args={[1, 60, 40]} />
<primitive object={shaderMaterial} attach="material" />
<primitive ref={materialRef} object={shaderMaterial} attach="material" />
</mesh>
);
}
@ -179,16 +191,43 @@ export function Sky({ object }: { object: TorqueObject }) {
const highFogDistance = getFloat(object, "high_fogDistance");
const highVisibleDistance = getFloat(object, "high_visibleDistance");
// Parse fog volumes - format: "visibleDistance minHeight maxHeight"
// These define height-based fog bands with different densities
const fogVolume1 = useMemo(() => {
const value = getProperty(object, "fogVolume1");
if (value) {
const [visibleDistance, minHeight, maxHeight] = value
.split(" ")
.map((s: string) => parseFloat(s));
// Only valid if visibleDistance > 0 and has a height range
if (visibleDistance > 0 && maxHeight > minHeight) {
return { visibleDistance, minHeight, maxHeight };
}
}
return null;
}, [object]);
// Use high quality values if available and valid (> 0)
const fogNear =
const baseFogNear =
highFogDistance != null && highFogDistance > 0
? highFogDistance
: fogDistanceBase;
const fogFar =
const baseFogFar =
highVisibleDistance != null && highVisibleDistance > 0
? highVisibleDistance
: visibleDistanceBase;
// If fogVolume1 is defined, use denser fog
// Torque's fog volumes ADD density on top of base fog - objects inside
// a fog volume get significantly more haze. We approximate this by
// using a fraction of the volume's visibleDistance.
const fogNear = fogVolume1
? Math.min(baseFogNear ?? Infinity, fogVolume1.visibleDistance * 0.25)
: baseFogNear;
const fogFar = fogVolume1
? Math.min(baseFogFar ?? Infinity, fogVolume1.visibleDistance * 0.9)
: baseFogFar;
const fogColor = useMemo(
() => parseColorString(getProperty(object, "fogColor")),
[object],

View file

@ -1,8 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import { useDatablock } from "./useDatablock";
@ -25,17 +24,7 @@ export function StaticShape({ object }: { object: TorqueObject }) {
return (
<ShapeInfoProvider shapeName={shapeName} type="StaticShape">
<group position={position} quaternion={q} scale={scale}>
{shapeName ? (
<ErrorBoundary
fallback={<DebugPlaceholder color="red" label={shapeName} />}
>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel />
</Suspense>
</ErrorBoundary>
) : (
<DebugPlaceholder color="orange" />
)}
<ShapeRenderer shapeName={shapeName} />
</group>
</ShapeInfoProvider>
);

View file

@ -1,52 +1,75 @@
import { useMemo } from "react";
import { Color } from "three";
import { Color, Vector3 } from "three";
import type { TorqueObject } from "../torqueScript";
import { getProperty } from "../mission";
export function Sun({ object }: { object: TorqueObject }) {
// Parse sun direction - points FROM sun TO scene
// Torque uses Z-up, Three.js uses Y-up
const direction = useMemo(() => {
const directionStr = getProperty(object, "direction") ?? "0 0 -1";
// Note: This is a space-separated string, so we split and parse each component.
const [x, y, z] = directionStr.split(" ").map((s: string) => parseFloat(s));
// Scale the direction vector to position the light far from the scene
const scale = 5000;
return [x * scale, y * scale, z * scale] as [number, number, number];
const directionStr =
getProperty(object, "direction") ?? "0.57735 0.57735 -0.57735";
const [tx, ty, tz] = directionStr
.split(" ")
.map((s: string) => parseFloat(s));
// Convert Torque (X, Y, Z) to Three.js:
// Swap Y/Z for coordinate system: (tx, ty, tz) -> (tx, tz, ty)
const x = tx;
const y = tz;
const z = ty;
const len = Math.sqrt(x * x + y * y + z * z);
return new Vector3(x / len, y / len, z / len);
}, [object]);
// Position light far away, opposite to direction (light shines FROM position)
const lightPosition = useMemo(() => {
const distance = 5000;
return new Vector3(
-direction.x * distance,
-direction.y * distance,
-direction.z * distance,
);
}, [direction]);
const color = useMemo(() => {
const colorStr = getProperty(object, "color") ?? "1 1 1 1";
// Note: This is a space-separated string, so we split and parse each component.
const colorStr = getProperty(object, "color") ?? "0.7 0.7 0.7 1";
const [r, g, b] = colorStr.split(" ").map((s: string) => parseFloat(s));
return [r, g, b] as [number, number, number];
return new Color(r, g, b);
}, [object]);
const ambient = useMemo(() => {
const ambientStr = getProperty(object, "ambient") ?? "0.5 0.5 0.5 1";
// Note: This is a space-separated string, so we split and parse each component.
const [r, g, b] = ambientStr.split(" ").map((s: string) => parseFloat(s));
return [r, g, b] as [number, number, number];
return new Color(r, g, b);
}, [object]);
// Lighting intensities - terrain and shapes need good directional + ambient balance
const directionalIntensity = 1.8;
const ambientIntensity = 1.0;
// Shadow camera covers the entire terrain (Tribes 2 terrains are typically 2048+ units)
const shadowCameraSize = 4096;
return (
<>
{/* Directional light for the sun */}
{/* <directionalLight
position={[500, 500, 500]}
target-position={direction}
{/* Directional sun light - illuminates surfaces facing the sun */}
<directionalLight
position={lightPosition}
color={color}
intensity={2}
intensity={directionalIntensity}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-2000}
shadow-camera-right={2000}
shadow-camera-top={2000}
shadow-camera-bottom={-2000}
shadow-camera-near={0.5}
shadow-camera-far={5000}
shadow-mapSize-width={4096}
shadow-mapSize-height={4096}
shadow-camera-left={-shadowCameraSize}
shadow-camera-right={shadowCameraSize}
shadow-camera-top={shadowCameraSize}
shadow-camera-bottom={-shadowCameraSize}
shadow-camera-near={100}
shadow-camera-far={12000}
shadow-bias={-0.001}
/> */}
{/* Ambient light component */}
<hemisphereLight args={[new Color(...color), new Color(...ambient), 2]} />
/>
{/* Ambient fill light - prevents pure black shadows */}
<ambientLight color={ambient} intensity={ambientIntensity} />
</>
);
}

View file

@ -1,8 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
export function TSStatic({ object }: { object: TorqueObject }) {
@ -19,13 +18,7 @@ export function TSStatic({ object }: { object: TorqueObject }) {
return (
<ShapeInfoProvider shapeName={shapeName} type="TSStatic">
<group position={position} quaternion={q} scale={scale}>
<ErrorBoundary
fallback={<DebugPlaceholder color="red" label={shapeName} />}
>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel />
</Suspense>
</ErrorBoundary>
<ShapeRenderer shapeName={shapeName} />
</group>
</ShapeInfoProvider>
);

View file

@ -1,5 +1,11 @@
import { memo, Suspense, useCallback, useMemo } from "react";
import { DataTexture, DoubleSide, FrontSide, type PlaneGeometry } from "three";
import {
DataTexture,
DoubleSide,
FrontSide,
MeshLambertMaterial,
type PlaneGeometry,
} from "three";
import { useTexture } from "@react-three/drei";
import {
FALLBACK_TEXTURE_URL,
@ -98,7 +104,7 @@ function BlendedTerrainTextures({
const materialKey = `${debugMode ? "debug" : "normal"}-${detailTextureUrl ? "detail" : "nodetail"}`;
return (
<meshStandardMaterial
<meshLambertMaterial
key={materialKey}
displacementMap={displacementMap}
map={displacementMap}
@ -126,7 +132,7 @@ function TerrainMaterial({
return (
<Suspense
fallback={
<meshStandardMaterial
<meshLambertMaterial
color="rgb(0, 109, 56)"
displacementMap={displacementMap}
displacementScale={2048}

View file

@ -1,8 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import { useDatablock } from "./useDatablock";
@ -33,29 +32,11 @@ export function Turret({ object }: { object: TorqueObject }) {
return (
<ShapeInfoProvider shapeName={shapeName} type="Turret">
<group position={position} quaternion={q} scale={scale}>
{shapeName ? (
<ErrorBoundary
fallback={<DebugPlaceholder color="red" label={shapeName} />}
>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel />
</Suspense>
</ErrorBoundary>
) : (
<DebugPlaceholder color="orange" />
)}
<ShapeRenderer shapeName={shapeName} />
{barrelShapeName ? (
<ShapeInfoProvider shapeName={barrelShapeName} type="Turret">
<group position={[0, 1.5, 0]}>
<ErrorBoundary
fallback={
<DebugPlaceholder color="red" label={barrelShapeName} />
}
>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel />
</Suspense>
</ErrorBoundary>
<ShapeRenderer shapeName={barrelShapeName} />
</group>
</ShapeInfoProvider>
) : null}

View file

@ -1,12 +1,14 @@
import { Skeleton, Bone, BufferGeometry } from "three";
/**
* Extract hull bone indices from a skeleton
* @param skeleton - The Three.js skeleton to scan
* @returns Set of bone indices for bones matching the hull pattern (starts with "Hulk")
*/
export function getHullBoneIndices(skeleton: any): Set<number> {
export function getHullBoneIndices(skeleton: Skeleton): Set<number> {
const hullBoneIndices = new Set<number>();
skeleton.bones.forEach((bone: any, index: number) => {
skeleton.bones.forEach((bone: Bone, index: number) => {
if (bone.name.match(/^Hulk/i)) {
hullBoneIndices.add(index);
}
@ -22,9 +24,9 @@ export function getHullBoneIndices(skeleton: any): Set<number> {
* @returns Filtered geometry with hull-influenced faces removed
*/
export function filterGeometryByVertexGroups(
geometry: any,
geometry: BufferGeometry,
hullBoneIndices: Set<number>,
): any {
): BufferGeometry {
// If no hull bones or no skinning data, return original geometry
if (hullBoneIndices.size === 0 || !geometry.attributes.skinIndex) {
return geometry;

View file

@ -101,10 +101,14 @@ uniform float tiling4;
uniform float tiling5;
uniform float debugMode;
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
${detailTexture ? `uniform sampler2D detailTexture;
${
detailTexture
? `uniform sampler2D detailTexture;
uniform float detailTiling;
uniform float detailFadeDistance;
varying vec3 vTerrainWorldPos;` : ""}
varying vec3 vTerrainWorldPos;`
: ""
}
// Wireframe edge detection for debug mode
float getWireframe(vec2 uv, float gridSize, float lineWidth) {

View file

@ -9,17 +9,45 @@ import {
RedFormat,
RepeatWrapping,
SRGBColorSpace,
Texture,
UnsignedByteType,
} from "three";
export function setupColor(tex, repeat = [1, 1]) {
export interface TextureSetupOptions {
/** Texture repeat values [x, y]. Default: [1, 1] */
repeat?: [number, number];
/** Disable mipmaps (for alpha-tested textures to prevent artifacts). Default: false */
disableMipmaps?: boolean;
}
/**
* Setup a color texture with standard settings for the viewer.
*
* @param tex - The texture to configure
* @param options - Optional configuration
* @returns The configured texture
*/
export function setupTexture<T extends Texture>(
tex: T,
options: TextureSetupOptions = {},
): T {
const { repeat = [1, 1], disableMipmaps = false } = options;
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.colorSpace = SRGBColorSpace;
tex.repeat.set(...repeat);
tex.flipY = false; // DDS/DIF textures are already flipped
tex.anisotropy = 16;
tex.generateMipmaps = true;
tex.minFilter = LinearMipmapLinearFilter;
if (disableMipmaps) {
// Disable mipmaps - prevents checkerboard artifacts on alpha-tested materials
// because alpha values get averaged at lower mip levels
tex.generateMipmaps = false;
tex.minFilter = LinearFilter;
} else {
tex.generateMipmaps = true;
tex.minFilter = LinearMipmapLinearFilter;
}
tex.magFilter = LinearFilter;
tex.needsUpdate = true;
@ -27,7 +55,34 @@ export function setupColor(tex, repeat = [1, 1]) {
return tex;
}
export function setupMask(data) {
/**
* Setup a color texture with standard settings.
* @deprecated Use setupTexture() instead
*/
export function setupColor<T extends Texture>(
tex: T,
repeat: [number, number] = [1, 1],
): T {
return setupTexture(tex, { repeat });
}
/**
* Setup for alpha-tested textures (vegetation, etc).
* Disables mipmaps to prevent checkerboard artifacts from alpha averaging.
* @deprecated Use setupTexture(tex, { disableMipmaps: true }) instead
*/
export function setupAlphaTestedTexture<T extends Texture>(
tex: T,
repeat: [number, number] = [1, 1],
): T {
return setupTexture(tex, { repeat, disableMipmaps: true });
}
/**
* Setup a mask texture (single channel, linear color space).
* Used for terrain blend masks and similar data textures.
*/
export function setupMask(data: Uint8Array): DataTexture {
const tex = new DataTexture(
data,
256,