mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-26 07:39:31 +00:00
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:
parent
035812724d
commit
3ba1ce9afd
927 changed files with 632 additions and 215 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue