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 { MeshStandardMaterial, MeshBasicMaterial, MeshLambertMaterial, AdditiveBlending, Texture, BufferGeometry, } from "three"; import { setupColor, setupAlphaTestedTexture } from "../textureUtils"; import { useDebug } from "./SettingsProvider"; import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider"; import { FloatingLabel } from "./FloatingLabel"; import { useIflTexture } from "./useIflTexture"; import { injectCustomFog } from "../fogShader"; import { globalFogUniforms } from "../globalFogUniforms"; import { injectShapeLighting } from "../shapeMaterial"; /** 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, 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; } /** * 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); } /** * Animated IFL (Image File List) material component. Creates a sprite sheet * from all frames and animates via texture offset. */ const IflTexture = memo(function IflTexture({ material, shapeName, geometry, backGeometry, castShadow = false, receiveShadow = false, }: TextureProps) { const resourcePath = material.userData.resource_path; const flagNames = new Set(material.userData.flag_names ?? []); const iflPath = `textures/${resourcePath}.ifl`; const texture = useIflTexture(iflPath); const isOrganic = shapeName && isOrganicShape(shapeName); const customMaterial = useMemo( () => createMaterialFromFlags(material, texture, flagNames, isOrganic), [material, texture, flagNames, isOrganic], ); // Two-pass rendering for organic/translucent materials // Render BackSide first (with flipped normals), then FrontSide if (Array.isArray(customMaterial)) { return ( <> ); } return ( ); }); const StaticTexture = memo(function StaticTexture({ material, shapeName, geometry, backGeometry, castShadow = false, receiveShadow = false, }: TextureProps) { const resourcePath = material.userData.resource_path; const flagNames = new Set(material.userData.flag_names ?? []); 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"); const texture = useTexture(url, (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( () => createMaterialFromFlags(material, texture, flagNames, isOrganic), [material, texture, flagNames, isOrganic], ); // Two-pass rendering for organic/translucent materials // Render BackSide first (with flipped normals), then FrontSide if (Array.isArray(customMaterial)) { return ( <> ); } return ( ); }); export const ShapeTexture = memo(function ShapeTexture({ material, shapeName, 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 ( ); } else if (material.name) { return ( ); } else { return null; } }); export function ShapePlaceholder({ color, label, }: { color: string; label?: string; }) { return ( {label ? {label} : null} ); } export function DebugPlaceholder({ color, label, }: { color: string; label?: string; }) { const { debugMode } = useDebug(); return debugMode ? : 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 ; } return ( } > }> {children} ); } export const ShapeModel = memo(function ShapeModel() { const { shapeName, isOrganic } = useShapeInfo(); const { debugMode } = useDebug(); const { nodes } = useStaticShape(shapeName); const hullBoneIndices = useMemo(() => { const skeletonsFound = Object.values(nodes).filter( (node: any) => node.skeleton, ); if (skeletonsFound.length > 0) { const skeleton = (skeletonsFound[0] as any).skeleton; return getHullBoneIndices(skeleton); } return new Set(); }, [nodes]); const processedNodes = useMemo(() => { return Object.entries(nodes) .filter( ([name, node]: [string, any]) => node.material && node.material.name !== "Unassigned" && !node.name.match(/^Hulk/i), ) .map(([name, node]: [string, any]) => { let geometry = filterGeometryByVertexGroups( node.geometry, 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(); 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 ( {processedNodes.map(({ node, geometry, backGeometry }) => ( } > {node.material ? ( Array.isArray(node.material) ? ( node.material.map((mat, index) => ( )) ) : ( ) ) : null} ))} {debugMode ? {shapeName} : null} ); });