t2-mapper/src/components/GenericShape.tsx

508 lines
15 KiB
TypeScript
Raw Normal View History

import { memo, Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
2025-11-15 03:33:44 +00:00
import { useGLTF, useTexture } from "@react-three/drei";
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
2025-11-15 07:43:31 +00:00
import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils";
2025-11-15 07:59:35 +00:00
import {
MeshStandardMaterial,
MeshBasicMaterial,
MeshLambertMaterial,
AdditiveBlending,
Texture,
BufferGeometry,
} from "three";
import { setupColor, setupAlphaTestedTexture } from "../textureUtils";
2025-11-26 07:44:37 +00:00
import { useDebug } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { FloatingLabel } from "./FloatingLabel";
2025-12-02 06:33:12 +00:00
import { useIflTexture } from "./useIflTexture";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting } from "../shapeMaterial";
2025-11-15 03:33:44 +00:00
/** 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];
/**
* Helper to apply volumetric fog and lighting multipliers to a material
*/
function applyShapeShaderModifications(
mat: MeshBasicMaterial | MeshLambertMaterial,
): void {
mat.onBeforeCompile = (shader) => {
injectCustomFog(shader, globalFogUniforms);
// Only inject lighting for Lambert materials (Basic materials are unlit)
if (mat instanceof MeshLambertMaterial) {
injectShapeLighting(shader);
}
};
}
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,
});
applyShapeShaderModifications(mat);
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
});
applyShapeShaderModifications(backMat);
applyShapeShaderModifications(frontMat);
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,
});
applyShapeShaderModifications(mat);
return mat;
}
2025-11-15 03:33:44 +00:00
/**
* Load a .glb file that was converted from a .dts, used for static shapes.
*/
export function useStaticShape(shapeName: string) {
const url = shapeToUrl(shapeName);
return useGLTF(url);
}
2025-12-02 06:33:12 +00:00
/**
* Animated IFL (Image File List) material component. Creates a sprite sheet
* from all frames and animates via texture offset.
*/
const IflTexture = memo(function IflTexture({
2025-11-15 07:43:31 +00:00
material,
2025-11-15 09:42:32 +00:00
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
}: TextureProps) {
2025-12-02 06:33:12 +00:00
const resourcePath = material.userData.resource_path;
const flagNames = new Set<string>(material.userData.flag_names ?? []);
2025-12-02 06:33:12 +00:00
const iflPath = `textures/${resourcePath}.ifl`;
const texture = useIflTexture(iflPath);
const isOrganic = shapeName && isOrganicShape(shapeName);
2025-12-02 06:33:12 +00:00
const customMaterial = useMemo(
() => createMaterialFromFlags(material, texture, flagNames, isOrganic),
[material, texture, flagNames, isOrganic],
);
2025-12-02 06:33:12 +00:00
// 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>
</>
);
}
2025-12-02 06:33:12 +00:00
return (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
2025-12-02 06:33:12 +00:00
const StaticTexture = memo(function StaticTexture({
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
}: TextureProps) {
2025-12-02 06:33:12 +00:00
const resourcePath = material.userData.resource_path;
const flagNames = new Set<string>(material.userData.flag_names ?? []);
2025-12-02 06:33:12 +00:00
const url = useMemo(() => {
if (!resourcePath) {
console.warn(
`No resource_path was found on "${shapeName}" - rendering fallback.`,
);
}
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
}, [resourcePath, shapeName]);
const isOrganic = shapeName && isOrganicShape(shapeName);
const isTranslucent = flagNames.has("Translucent");
2025-11-15 09:42:32 +00:00
const texture = useTexture(url, (texture) => {
// Organic/alpha-tested textures need special handling to avoid mipmap artifacts
if (isOrganic || isTranslucent) {
return setupAlphaTestedTexture(texture);
2025-11-15 09:42:32 +00:00
}
// Standard color texture setup for diffuse-only materials
2025-11-15 09:42:32 +00:00
return setupColor(texture);
});
2025-11-15 07:59:35 +00:00
const customMaterial = useMemo(
() => createMaterialFromFlags(material, texture, flagNames, isOrganic),
[material, texture, flagNames, isOrganic],
);
2025-11-15 07:43:31 +00:00
// 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>
</>
);
}
2025-11-15 07:43:31 +00:00
return (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
2025-11-15 03:33:44 +00:00
export const ShapeTexture = memo(function ShapeTexture({
2025-12-02 06:33:12 +00:00
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
}: TextureProps) {
2025-12-02 06:33:12 +00:00
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}
geometry={geometry}
backGeometry={backGeometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
);
} else if (material.name) {
return (
<StaticTexture
material={material}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
);
} else {
return null;
2025-12-02 06:33:12 +00:00
}
});
2025-12-02 06:33:12 +00:00
2025-12-01 01:42:59 +00:00
export function ShapePlaceholder({
color,
label,
}: {
color: string;
label?: string;
}) {
2025-11-15 03:33:44 +00:00
return (
<mesh>
<boxGeometry args={[10, 10, 10]} />
<meshStandardMaterial color={color} wireframe />
2025-12-01 01:42:59 +00:00
{label ? <FloatingLabel color={color}>{label}</FloatingLabel> : null}
2025-11-15 03:33:44 +00:00
</mesh>
);
}
2025-12-01 01:42:59 +00:00
export function DebugPlaceholder({
color,
label,
}: {
color: string;
label?: string;
}) {
const { debugMode } = useDebug();
2025-12-01 01:42:59 +00:00
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, isOrganic } = useShapeInfo();
2025-11-26 07:44:37 +00:00
const { debugMode } = useDebug();
const { nodes } = useStaticShape(shapeName);
const hullBoneIndices = useMemo(() => {
const skeletonsFound = Object.values(nodes).filter(
2025-11-29 17:08:20 +00:00
(node: any) => node.skeleton,
);
if (skeletonsFound.length > 0) {
const skeleton = (skeletonsFound[0] as any).skeleton;
return getHullBoneIndices(skeleton);
}
return new Set<number>();
}, [nodes]);
const processedNodes = useMemo(() => {
return Object.entries(nodes)
.filter(
([name, node]: [string, any]) =>
node.material &&
node.material.name !== "Unassigned" &&
2025-11-29 17:08:20 +00:00
!node.name.match(/^Hulk/i),
)
.map(([name, node]: [string, any]) => {
let geometry = filterGeometryByVertexGroups(
node.geometry,
2025-11-29 17:08:20 +00:00
hullBoneIndices,
);
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, 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, backGeometry }) => (
<Suspense
key={node.id}
fallback={
<mesh geometry={geometry}>
<meshStandardMaterial color="gray" wireframe />
</mesh>
}
>
{node.material ? (
Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<ShapeTexture
key={index}
material={mat as MeshStandardMaterial}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={enableShadows}
receiveShadow={enableShadows}
/>
))
) : (
<ShapeTexture
material={node.material as MeshStandardMaterial}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={enableShadows}
receiveShadow={enableShadows}
/>
)
) : null}
</Suspense>
))}
{debugMode ? <FloatingLabel>{shapeName}</FloatingLabel> : null}
</group>
);
});