2026-03-02 22:57:58 -08:00
|
|
|
|
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
|
2025-12-07 14:01:26 -08:00
|
|
|
|
import { ErrorBoundary } from "react-error-boundary";
|
2025-11-14 19:33:44 -08:00
|
|
|
|
import { useGLTF, useTexture } from "@react-three/drei";
|
2026-02-28 17:58:09 -08:00
|
|
|
|
import { useFrame } from "@react-three/fiber";
|
2025-12-03 05:57:16 -08:00
|
|
|
|
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
|
2025-11-14 23:59:35 -08:00
|
|
|
|
import {
|
2025-12-07 14:01:26 -08:00
|
|
|
|
MeshStandardMaterial,
|
|
|
|
|
|
MeshBasicMaterial,
|
|
|
|
|
|
MeshLambertMaterial,
|
|
|
|
|
|
AdditiveBlending,
|
2026-03-02 22:57:58 -08:00
|
|
|
|
AnimationMixer,
|
|
|
|
|
|
AnimationClip,
|
|
|
|
|
|
LoopOnce,
|
|
|
|
|
|
LoopRepeat,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
Texture,
|
|
|
|
|
|
BufferGeometry,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
Group,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
} from "three";
|
2026-03-02 22:57:58 -08:00
|
|
|
|
import type { AnimationAction } from "three";
|
|
|
|
|
|
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
2025-12-12 14:16:21 -08:00
|
|
|
|
import { setupTexture } from "../textureUtils";
|
2026-02-28 17:58:09 -08:00
|
|
|
|
import { useDebug, useSettings } from "./SettingsProvider";
|
2025-12-07 14:01:26 -08:00
|
|
|
|
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
|
2026-03-05 15:00:05 -08:00
|
|
|
|
import { useEngineSelector, demoEffectNow, engineStore } from "../state";
|
2025-11-23 21:47:49 -08:00
|
|
|
|
import { FloatingLabel } from "./FloatingLabel";
|
2026-03-02 22:57:58 -08:00
|
|
|
|
import {
|
|
|
|
|
|
useIflTexture,
|
|
|
|
|
|
loadIflAtlas,
|
|
|
|
|
|
getFrameIndexForTime,
|
|
|
|
|
|
updateAtlasFrame,
|
|
|
|
|
|
} from "./useIflTexture";
|
|
|
|
|
|
import type { IflAtlas } from "./useIflTexture";
|
2025-12-09 14:59:47 -08:00
|
|
|
|
import { injectCustomFog } from "../fogShader";
|
|
|
|
|
|
import { globalFogUniforms } from "../globalFogUniforms";
|
|
|
|
|
|
import { injectShapeLighting } from "../shapeMaterial";
|
2026-03-02 22:57:58 -08:00
|
|
|
|
import {
|
|
|
|
|
|
processShapeScene,
|
|
|
|
|
|
replaceWithShapeMaterial,
|
|
|
|
|
|
} from "../demo/demoPlaybackUtils";
|
|
|
|
|
|
import type { DemoThreadState } from "../demo/types";
|
2025-11-14 19:33:44 -08:00
|
|
|
|
|
2026-03-05 15:00:05 -08:00
|
|
|
|
/** Returns pausable time in seconds for demo mode, real time otherwise. */
|
|
|
|
|
|
function shapeNowSec(): number {
|
|
|
|
|
|
const status = engineStore.getState().playback.status;
|
|
|
|
|
|
return status !== "stopped"
|
|
|
|
|
|
? demoEffectNow() / 1000
|
|
|
|
|
|
: performance.now() / 1000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
/** Shared props for texture rendering components */
|
|
|
|
|
|
interface TextureProps {
|
|
|
|
|
|
material: MeshStandardMaterial;
|
|
|
|
|
|
shapeName?: string;
|
|
|
|
|
|
geometry?: BufferGeometry;
|
|
|
|
|
|
backGeometry?: BufferGeometry;
|
|
|
|
|
|
castShadow?: boolean;
|
|
|
|
|
|
receiveShadow?: boolean;
|
2026-02-28 17:58:09 -08:00
|
|
|
|
/** DTS object visibility (0–1). Values < 1 enable alpha blending. */
|
|
|
|
|
|
vis?: number;
|
|
|
|
|
|
/** When true, material is created transparent for vis keyframe animation. */
|
|
|
|
|
|
animated?: boolean;
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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];
|
|
|
|
|
|
|
2025-12-09 14:59:47 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Helper to apply volumetric fog and lighting multipliers to a material
|
|
|
|
|
|
*/
|
2026-02-28 17:58:09 -08:00
|
|
|
|
export function applyShapeShaderModifications(
|
2025-12-09 14:59:47 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 17:58:09 -08:00
|
|
|
|
export function createMaterialFromFlags(
|
2025-12-07 14:01:26 -08:00
|
|
|
|
baseMaterial: MeshStandardMaterial,
|
2026-03-04 12:15:24 -08:00
|
|
|
|
texture: Texture | null,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
flagNames: Set<string>,
|
|
|
|
|
|
isOrganic: boolean,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
vis: number = 1,
|
|
|
|
|
|
animated: boolean = false,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
): MaterialResult {
|
|
|
|
|
|
const isTranslucent = flagNames.has("Translucent");
|
|
|
|
|
|
const isAdditive = flagNames.has("Additive");
|
|
|
|
|
|
const isSelfIlluminating = flagNames.has("SelfIlluminating");
|
2026-02-28 17:58:09 -08:00
|
|
|
|
// DTS per-object visibility: when vis < 1, the engine sets fadeSet=true which
|
|
|
|
|
|
// forces the Translucent flag and renders with GL_SRC_ALPHA/GL_ONE_MINUS_SRC_ALPHA.
|
|
|
|
|
|
// Animated vis also needs transparent materials so opacity can be updated per frame.
|
|
|
|
|
|
const isFaded = vis < 1 || animated;
|
2025-12-07 14:01:26 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// SelfIlluminating or Additive materials are unlit (use MeshBasicMaterial).
|
|
|
|
|
|
// Additive materials without SelfIlluminating (e.g. explosion shells) must
|
|
|
|
|
|
// also be unlit, otherwise they render black with no scene lighting.
|
|
|
|
|
|
if (isSelfIlluminating || isAdditive) {
|
2026-02-28 17:58:09 -08:00
|
|
|
|
const isBlended = isAdditive || isTranslucent || isFaded;
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const mat = new MeshBasicMaterial({
|
|
|
|
|
|
map: texture,
|
|
|
|
|
|
side: 2, // DoubleSide
|
2026-02-28 17:58:09 -08:00
|
|
|
|
transparent: isBlended,
|
|
|
|
|
|
depthWrite: !isBlended,
|
|
|
|
|
|
alphaTest: 0,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
fog: true,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
...(isFaded && { opacity: vis }),
|
2025-12-11 22:07:29 -08:00
|
|
|
|
...(isAdditive && { blending: AdditiveBlending }),
|
2025-12-07 14:01:26 -08:00
|
|
|
|
});
|
2025-12-09 14:59:47 -08:00
|
|
|
|
applyShapeShaderModifications(mat);
|
2025-12-07 14:01:26 -08:00
|
|
|
|
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,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
// When vis < 1, switch from alpha cutout to alpha blend (matching the engine's
|
|
|
|
|
|
// fadeSet behavior which forces GL_BLEND with no alpha test)
|
|
|
|
|
|
transparent: isFaded,
|
|
|
|
|
|
alphaTest: isFaded ? 0 : 0.5,
|
|
|
|
|
|
...(isFaded && { opacity: vis, depthWrite: false }),
|
2025-12-07 14:01:26 -08:00
|
|
|
|
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
|
|
|
|
|
|
});
|
2025-12-09 14:59:47 -08:00
|
|
|
|
applyShapeShaderModifications(backMat);
|
|
|
|
|
|
applyShapeShaderModifications(frontMat);
|
2025-12-07 14:01:26 -08:00
|
|
|
|
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,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
...(isFaded && {
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: vis,
|
|
|
|
|
|
depthWrite: false,
|
|
|
|
|
|
}),
|
2025-12-07 14:01:26 -08:00
|
|
|
|
});
|
2025-12-09 14:59:47 -08:00
|
|
|
|
applyShapeShaderModifications(mat);
|
2025-12-07 14:01:26 -08:00
|
|
|
|
return mat;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-14 19:33:44 -08: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-01 22:33:12 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Animated IFL (Image File List) material component. Creates a sprite sheet
|
|
|
|
|
|
* from all frames and animates via texture offset.
|
|
|
|
|
|
*/
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const IflTexture = memo(function IflTexture({
|
2025-11-14 23:43:31 -08:00
|
|
|
|
material,
|
2025-11-15 01:42:32 -08:00
|
|
|
|
shapeName,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
geometry,
|
|
|
|
|
|
backGeometry,
|
|
|
|
|
|
castShadow = false,
|
|
|
|
|
|
receiveShadow = false,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
vis = 1,
|
|
|
|
|
|
animated = false,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}: TextureProps) {
|
2025-12-01 22:33:12 -08:00
|
|
|
|
const resourcePath = material.userData.resource_path;
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const flagNames = new Set<string>(material.userData.flag_names ?? []);
|
2025-12-01 22:33:12 -08:00
|
|
|
|
const iflPath = `textures/${resourcePath}.ifl`;
|
|
|
|
|
|
|
|
|
|
|
|
const texture = useIflTexture(iflPath);
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const isOrganic = shapeName && isOrganicShape(shapeName);
|
2025-12-01 22:33:12 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const customMaterial = useMemo(
|
2026-02-28 17:58:09 -08:00
|
|
|
|
() =>
|
|
|
|
|
|
createMaterialFromFlags(
|
|
|
|
|
|
material,
|
|
|
|
|
|
texture,
|
|
|
|
|
|
flagNames,
|
|
|
|
|
|
isOrganic,
|
|
|
|
|
|
vis,
|
|
|
|
|
|
animated,
|
|
|
|
|
|
),
|
|
|
|
|
|
[material, texture, flagNames, isOrganic, vis, animated],
|
2025-12-07 14:01:26 -08:00
|
|
|
|
);
|
2025-12-01 22:33:12 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08: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-01 22:33:12 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
return (
|
|
|
|
|
|
<mesh
|
|
|
|
|
|
geometry={geometry}
|
|
|
|
|
|
castShadow={castShadow}
|
|
|
|
|
|
receiveShadow={receiveShadow}
|
|
|
|
|
|
>
|
|
|
|
|
|
<primitive object={customMaterial} attach="material" />
|
|
|
|
|
|
</mesh>
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2025-12-01 22:33:12 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const StaticTexture = memo(function StaticTexture({
|
|
|
|
|
|
material,
|
|
|
|
|
|
shapeName,
|
|
|
|
|
|
geometry,
|
|
|
|
|
|
backGeometry,
|
|
|
|
|
|
castShadow = false,
|
|
|
|
|
|
receiveShadow = false,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
vis = 1,
|
|
|
|
|
|
animated = false,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}: TextureProps) {
|
2025-12-01 22:33:12 -08:00
|
|
|
|
const resourcePath = material.userData.resource_path;
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const flagNames = new Set<string>(material.userData.flag_names ?? []);
|
2025-12-01 22:33:12 -08:00
|
|
|
|
|
2025-12-01 00:17:27 -08:00
|
|
|
|
const url = useMemo(() => {
|
|
|
|
|
|
if (!resourcePath) {
|
|
|
|
|
|
console.warn(
|
2025-12-04 14:16:52 -08:00
|
|
|
|
`No resource_path was found on "${shapeName}" - rendering fallback.`,
|
2025-12-01 00:17:27 -08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
|
|
|
|
|
|
}, [resourcePath, shapeName]);
|
2025-12-01 00:17:27 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const isOrganic = shapeName && isOrganicShape(shapeName);
|
|
|
|
|
|
const isTranslucent = flagNames.has("Translucent");
|
2025-11-15 01:42:32 -08:00
|
|
|
|
|
|
|
|
|
|
const texture = useTexture(url, (texture) => {
|
2025-12-07 14:01:26 -08:00
|
|
|
|
// Organic/alpha-tested textures need special handling to avoid mipmap artifacts
|
|
|
|
|
|
if (isOrganic || isTranslucent) {
|
2025-12-12 14:16:21 -08:00
|
|
|
|
return setupTexture(texture, { disableMipmaps: true });
|
2025-11-15 01:42:32 -08:00
|
|
|
|
}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
// Standard color texture setup for diffuse-only materials
|
2025-12-12 14:16:21 -08:00
|
|
|
|
return setupTexture(texture);
|
2025-11-15 01:42:32 -08:00
|
|
|
|
});
|
2025-11-14 23:59:35 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
const customMaterial = useMemo(
|
2026-02-28 17:58:09 -08:00
|
|
|
|
() =>
|
|
|
|
|
|
createMaterialFromFlags(
|
|
|
|
|
|
material,
|
|
|
|
|
|
texture,
|
|
|
|
|
|
flagNames,
|
|
|
|
|
|
isOrganic,
|
|
|
|
|
|
vis,
|
|
|
|
|
|
animated,
|
|
|
|
|
|
),
|
|
|
|
|
|
[material, texture, flagNames, isOrganic, vis, animated],
|
2025-12-07 14:01:26 -08:00
|
|
|
|
);
|
2025-11-14 23:43:31 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08: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-14 23:43:31 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
return (
|
|
|
|
|
|
<mesh
|
|
|
|
|
|
geometry={geometry}
|
|
|
|
|
|
castShadow={castShadow}
|
|
|
|
|
|
receiveShadow={receiveShadow}
|
|
|
|
|
|
>
|
|
|
|
|
|
<primitive object={customMaterial} attach="material" />
|
|
|
|
|
|
</mesh>
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2025-11-14 19:33:44 -08:00
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
export const ShapeTexture = memo(function ShapeTexture({
|
2025-12-01 22:33:12 -08:00
|
|
|
|
material,
|
|
|
|
|
|
shapeName,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
geometry,
|
|
|
|
|
|
backGeometry,
|
|
|
|
|
|
castShadow = false,
|
|
|
|
|
|
receiveShadow = false,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
vis = 1,
|
|
|
|
|
|
animated = false,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}: TextureProps) {
|
2025-12-01 22:33:12 -08: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) {
|
2025-12-07 14:01:26 -08:00
|
|
|
|
return (
|
|
|
|
|
|
<IflTexture
|
|
|
|
|
|
material={material}
|
|
|
|
|
|
shapeName={shapeName}
|
|
|
|
|
|
geometry={geometry}
|
|
|
|
|
|
backGeometry={backGeometry}
|
|
|
|
|
|
castShadow={castShadow}
|
|
|
|
|
|
receiveShadow={receiveShadow}
|
2026-02-28 17:58:09 -08:00
|
|
|
|
vis={vis}
|
|
|
|
|
|
animated={animated}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
2025-12-04 14:16:52 -08:00
|
|
|
|
} else if (material.name) {
|
2025-12-07 14:01:26 -08:00
|
|
|
|
return (
|
|
|
|
|
|
<StaticTexture
|
|
|
|
|
|
material={material}
|
|
|
|
|
|
shapeName={shapeName}
|
|
|
|
|
|
geometry={geometry}
|
|
|
|
|
|
backGeometry={backGeometry}
|
|
|
|
|
|
castShadow={castShadow}
|
|
|
|
|
|
receiveShadow={receiveShadow}
|
2026-02-28 17:58:09 -08:00
|
|
|
|
vis={vis}
|
|
|
|
|
|
animated={animated}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
2025-12-04 14:16:52 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
return null;
|
2025-12-01 22:33:12 -08:00
|
|
|
|
}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
});
|
2025-12-01 22:33:12 -08:00
|
|
|
|
|
2025-11-30 17:42:59 -08:00
|
|
|
|
export function ShapePlaceholder({
|
|
|
|
|
|
color,
|
|
|
|
|
|
label,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
color: string;
|
|
|
|
|
|
label?: string;
|
|
|
|
|
|
}) {
|
2025-11-14 19:33:44 -08:00
|
|
|
|
return (
|
|
|
|
|
|
<mesh>
|
|
|
|
|
|
<boxGeometry args={[10, 10, 10]} />
|
|
|
|
|
|
<meshStandardMaterial color={color} wireframe />
|
2025-11-30 17:42:59 -08:00
|
|
|
|
{label ? <FloatingLabel color={color}>{label}</FloatingLabel> : null}
|
2025-11-14 19:33:44 -08:00
|
|
|
|
</mesh>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-11-23 21:47:49 -08:00
|
|
|
|
|
2025-11-30 17:42:59 -08:00
|
|
|
|
export function DebugPlaceholder({
|
|
|
|
|
|
color,
|
|
|
|
|
|
label,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
color: string;
|
|
|
|
|
|
label?: string;
|
|
|
|
|
|
}) {
|
2025-11-26 17:19:17 -08:00
|
|
|
|
const { debugMode } = useDebug();
|
2025-11-30 17:42:59 -08:00
|
|
|
|
return debugMode ? <ShapePlaceholder color={color} label={label} /> : null;
|
2025-11-26 17:19:17 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 17:58:09 -08:00
|
|
|
|
/** Shapes that don't have a .glb conversion and are rendered with built-in
|
|
|
|
|
|
* Three.js geometry instead. These are editor-only markers in Tribes 2. */
|
|
|
|
|
|
const HARDCODED_SHAPES = new Set(["octahedron.dts"]);
|
|
|
|
|
|
|
|
|
|
|
|
function HardcodedShape({ label }: { label?: string }) {
|
|
|
|
|
|
const { debugMode } = useDebug();
|
|
|
|
|
|
if (!debugMode) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<mesh>
|
|
|
|
|
|
<icosahedronGeometry args={[1, 1]} />
|
|
|
|
|
|
<meshBasicMaterial color="cyan" wireframe />
|
|
|
|
|
|
{label ? <FloatingLabel color="cyan">{label}</FloatingLabel> : null}
|
|
|
|
|
|
</mesh>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Wrapper component that handles the common ErrorBoundary + Suspense + ShapeModel
|
|
|
|
|
|
* pattern used across shape-rendering components.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function ShapeRenderer({
|
|
|
|
|
|
loadingColor = "yellow",
|
2026-03-02 22:57:58 -08:00
|
|
|
|
demoThreads,
|
2025-12-07 14:01:26 -08:00
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
loadingColor?: string;
|
2026-03-02 22:57:58 -08:00
|
|
|
|
demoThreads?: DemoThreadState[];
|
2025-12-07 14:01:26 -08:00
|
|
|
|
children?: React.ReactNode;
|
|
|
|
|
|
}) {
|
2025-12-14 11:06:57 -08:00
|
|
|
|
const { object, shapeName } = useShapeInfo();
|
|
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
if (!shapeName) {
|
2025-12-14 11:06:57 -08:00
|
|
|
|
return (
|
|
|
|
|
|
<DebugPlaceholder color="orange" label={`${object._id}: <missing>`} />
|
|
|
|
|
|
);
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 17:58:09 -08:00
|
|
|
|
if (HARDCODED_SHAPES.has(shapeName.toLowerCase())) {
|
|
|
|
|
|
return <HardcodedShape label={`${object._id}: ${shapeName}`} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-07 14:01:26 -08:00
|
|
|
|
return (
|
|
|
|
|
|
<ErrorBoundary
|
2025-12-14 11:06:57 -08:00
|
|
|
|
fallback={
|
|
|
|
|
|
<DebugPlaceholder color="red" label={`${object._id}: ${shapeName}`} />
|
|
|
|
|
|
}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
>
|
|
|
|
|
|
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
|
2026-03-02 22:57:58 -08:00
|
|
|
|
<ShapeModelLoader demoThreads={demoThreads} />
|
2025-12-07 14:01:26 -08:00
|
|
|
|
{children}
|
|
|
|
|
|
</Suspense>
|
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
/** Vis node info collected from the scene for vis opacity animation. */
|
|
|
|
|
|
interface VisNode {
|
|
|
|
|
|
mesh: any;
|
|
|
|
|
|
keyframes: number[];
|
|
|
|
|
|
duration: number;
|
|
|
|
|
|
cyclic: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Active animation thread state, keyed by thread slot number. */
|
|
|
|
|
|
interface ThreadState {
|
|
|
|
|
|
sequence: string;
|
|
|
|
|
|
action?: AnimationAction;
|
|
|
|
|
|
visNodes?: VisNode[];
|
|
|
|
|
|
startTime: number;
|
2026-02-28 17:58:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// Thread slot constants matching power.cs globals
|
|
|
|
|
|
const DEPLOY_THREAD = 3;
|
|
|
|
|
|
|
2026-02-28 17:58:09 -08:00
|
|
|
|
/**
|
2026-03-02 22:57:58 -08:00
|
|
|
|
* Unified shape renderer. Clones the full scene graph (preserving skeleton
|
|
|
|
|
|
* bindings), applies Tribes 2 materials via processShapeScene, and drives
|
|
|
|
|
|
* animation threads either through TorqueScript (for deployable shapes with
|
|
|
|
|
|
* a runtime) or directly (ambient/power vis sequences).
|
2026-02-28 17:58:09 -08:00
|
|
|
|
*/
|
2026-03-02 22:57:58 -08:00
|
|
|
|
export const ShapeModel = memo(function ShapeModel({
|
|
|
|
|
|
gltf,
|
|
|
|
|
|
demoThreads,
|
2026-02-28 17:58:09 -08:00
|
|
|
|
}: {
|
2026-03-02 22:57:58 -08:00
|
|
|
|
gltf: ReturnType<typeof useStaticShape>;
|
|
|
|
|
|
demoThreads?: DemoThreadState[];
|
2026-02-28 17:58:09 -08:00
|
|
|
|
}) {
|
2026-03-02 22:57:58 -08:00
|
|
|
|
const { object, shapeName } = useShapeInfo();
|
|
|
|
|
|
const { debugMode } = useDebug();
|
2026-02-28 17:58:09 -08:00
|
|
|
|
const { animationEnabled } = useSettings();
|
2026-03-02 22:57:58 -08:00
|
|
|
|
const runtime = useEngineSelector((state) => state.runtime.runtime);
|
|
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
clonedScene,
|
|
|
|
|
|
mixer,
|
|
|
|
|
|
clipsByName,
|
|
|
|
|
|
visNodesBySequence,
|
|
|
|
|
|
iflMeshes,
|
|
|
|
|
|
} = useMemo(() => {
|
|
|
|
|
|
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
2026-02-28 17:58:09 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// Detect IFL materials BEFORE processShapeScene replaces them, since the
|
|
|
|
|
|
// replacement materials lose the original userData (flag_names, resource_path).
|
|
|
|
|
|
const iflInfos: Array<{
|
|
|
|
|
|
mesh: any;
|
|
|
|
|
|
iflPath: string;
|
|
|
|
|
|
hasVisSequence: boolean;
|
|
|
|
|
|
iflSequence?: string;
|
|
|
|
|
|
iflDuration?: number;
|
|
|
|
|
|
iflCyclic?: boolean;
|
|
|
|
|
|
iflToolBegin?: number;
|
|
|
|
|
|
}> = [];
|
|
|
|
|
|
scene.traverse((node: any) => {
|
|
|
|
|
|
if (!node.isMesh || !node.material) return;
|
|
|
|
|
|
const mat = Array.isArray(node.material)
|
|
|
|
|
|
? node.material[0]
|
|
|
|
|
|
: node.material;
|
|
|
|
|
|
if (!mat?.userData) return;
|
|
|
|
|
|
const flags = new Set<string>(mat.userData.flag_names ?? []);
|
|
|
|
|
|
const rp: string | undefined = mat.userData.resource_path;
|
|
|
|
|
|
if (flags.has("IflMaterial") && rp) {
|
|
|
|
|
|
const ud = node.userData;
|
|
|
|
|
|
// ifl_sequence is set by the addon when ifl_matters links this IFL to
|
|
|
|
|
|
// a controlling sequence. vis_sequence is a separate system (opacity
|
|
|
|
|
|
// animation) and must NOT be used as a fallback — the two are independent.
|
|
|
|
|
|
const iflSeq = ud?.ifl_sequence
|
|
|
|
|
|
? String(ud.ifl_sequence).toLowerCase()
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const iflDur = ud?.ifl_duration
|
|
|
|
|
|
? Number(ud.ifl_duration)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const iflCyclic = ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined;
|
|
|
|
|
|
const iflToolBegin = ud?.ifl_tool_begin != null
|
|
|
|
|
|
? Number(ud.ifl_tool_begin)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
iflInfos.push({
|
|
|
|
|
|
mesh: node,
|
|
|
|
|
|
iflPath: `textures/${rp}.ifl`,
|
|
|
|
|
|
hasVisSequence: !!(ud?.vis_sequence),
|
|
|
|
|
|
iflSequence: iflSeq,
|
|
|
|
|
|
iflDuration: iflDur,
|
|
|
|
|
|
iflCyclic,
|
|
|
|
|
|
iflToolBegin,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
processShapeScene(scene);
|
|
|
|
|
|
|
|
|
|
|
|
// Un-hide IFL meshes that don't have a vis sequence — they should always
|
|
|
|
|
|
// be visible. IFL meshes WITH vis sequences stay hidden until their
|
|
|
|
|
|
// sequence is activated by playThread.
|
|
|
|
|
|
for (const { mesh, hasVisSequence } of iflInfos) {
|
|
|
|
|
|
if (!hasVisSequence) {
|
|
|
|
|
|
mesh.visible = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Collect ALL vis-animated nodes, grouped by sequence name.
|
|
|
|
|
|
const visBySeq = new Map<string, VisNode[]>();
|
|
|
|
|
|
scene.traverse((node: any) => {
|
|
|
|
|
|
if (!node.isMesh) return;
|
|
|
|
|
|
const ud = node.userData;
|
|
|
|
|
|
if (!ud) return;
|
|
|
|
|
|
const kf = ud.vis_keyframes;
|
|
|
|
|
|
const dur = ud.vis_duration;
|
|
|
|
|
|
const seqName = (ud.vis_sequence ?? "").toLowerCase();
|
|
|
|
|
|
if (!seqName || !Array.isArray(kf) || kf.length <= 1 || !dur || dur <= 0)
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
|
|
let list = visBySeq.get(seqName);
|
|
|
|
|
|
if (!list) {
|
|
|
|
|
|
list = [];
|
|
|
|
|
|
visBySeq.set(seqName, list);
|
|
|
|
|
|
}
|
|
|
|
|
|
list.push({
|
|
|
|
|
|
mesh: node,
|
|
|
|
|
|
keyframes: kf,
|
|
|
|
|
|
duration: dur,
|
|
|
|
|
|
cyclic: !!ud.vis_cyclic,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Build clips by name (case-insensitive)
|
|
|
|
|
|
const clips = new Map<string, AnimationClip>();
|
|
|
|
|
|
for (const clip of gltf.animations) {
|
|
|
|
|
|
clips.set(clip.name.toLowerCase(), clip);
|
|
|
|
|
|
}
|
2026-02-28 17:58:09 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// Only create a mixer if there are skeleton animation clips.
|
|
|
|
|
|
const mix = clips.size > 0 ? new AnimationMixer(scene) : null;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
clonedScene: scene,
|
|
|
|
|
|
mixer: mix,
|
|
|
|
|
|
clipsByName: clips,
|
|
|
|
|
|
visNodesBySequence: visBySeq,
|
|
|
|
|
|
iflMeshes: iflInfos,
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [gltf]);
|
|
|
|
|
|
|
|
|
|
|
|
const threadsRef = useRef(new Map<number, ThreadState>());
|
|
|
|
|
|
const iflMeshAtlasRef = useRef(new Map<any, IflAtlas>());
|
|
|
|
|
|
|
|
|
|
|
|
interface IflAnimInfo {
|
|
|
|
|
|
atlas: IflAtlas;
|
|
|
|
|
|
sequenceName?: string;
|
|
|
|
|
|
/** Controlling sequence duration in seconds. */
|
|
|
|
|
|
sequenceDuration?: number;
|
|
|
|
|
|
cyclic?: boolean;
|
|
|
|
|
|
/** Torque `toolBegin`: offset into IFL timeline (seconds). */
|
|
|
|
|
|
toolBegin?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
const iflAnimInfosRef = useRef<IflAnimInfo[]>([]);
|
|
|
|
|
|
const iflTimeRef = useRef(0);
|
|
|
|
|
|
const animationEnabledRef = useRef(animationEnabled);
|
|
|
|
|
|
animationEnabledRef.current = animationEnabled;
|
|
|
|
|
|
|
|
|
|
|
|
// Stable ref for the deploy-end callback so useFrame can advance the
|
|
|
|
|
|
// lifecycle when animation is toggled off mid-deploy.
|
|
|
|
|
|
const onDeployEndRef = useRef<((slot: number) => void) | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Refs for demo thread-driven animation (exposed from the main animation effect).
|
|
|
|
|
|
const demoThreadsRef = useRef(demoThreads);
|
|
|
|
|
|
demoThreadsRef.current = demoThreads;
|
|
|
|
|
|
const handlePlayThreadRef = useRef<((slot: number, seq: string) => void) | null>(null);
|
|
|
|
|
|
const handleStopThreadRef = useRef<((slot: number) => void) | null>(null);
|
|
|
|
|
|
const prevDemoThreadsRef = useRef<DemoThreadState[] | undefined>(undefined);
|
|
|
|
|
|
|
|
|
|
|
|
// Load IFL texture atlases imperatively (processShapeScene can't resolve
|
|
|
|
|
|
// .ifl paths since they require async loading of the frame list).
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
iflAnimInfosRef.current = [];
|
|
|
|
|
|
iflMeshAtlasRef.current.clear();
|
|
|
|
|
|
for (const info of iflMeshes) {
|
|
|
|
|
|
loadIflAtlas(info.iflPath)
|
|
|
|
|
|
.then((atlas) => {
|
|
|
|
|
|
const mat = Array.isArray(info.mesh.material)
|
|
|
|
|
|
? info.mesh.material[0]
|
|
|
|
|
|
: info.mesh.material;
|
|
|
|
|
|
if (mat) {
|
|
|
|
|
|
mat.map = atlas.texture;
|
|
|
|
|
|
mat.needsUpdate = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
iflAnimInfosRef.current.push({
|
|
|
|
|
|
atlas,
|
|
|
|
|
|
sequenceName: info.iflSequence,
|
|
|
|
|
|
sequenceDuration: info.iflDuration,
|
|
|
|
|
|
cyclic: info.iflCyclic,
|
|
|
|
|
|
toolBegin: info.iflToolBegin,
|
|
|
|
|
|
});
|
|
|
|
|
|
iflMeshAtlasRef.current.set(info.mesh, atlas);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [iflMeshes]);
|
|
|
|
|
|
|
|
|
|
|
|
// Animation setup. Shared helpers (handlePlayThread, handleStopThread) are
|
|
|
|
|
|
// used by both mission rendering and demo playback. The lifecycle that
|
|
|
|
|
|
// decides WHEN to call them differs: mission mode auto-plays deploy and
|
|
|
|
|
|
// subscribes to TorqueScript; demo mode does nothing on mount and lets
|
|
|
|
|
|
// the useFrame handler drive everything from ghost thread data.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const threads = threadsRef.current;
|
|
|
|
|
|
|
|
|
|
|
|
function prepareVisNode(v: VisNode) {
|
|
|
|
|
|
v.mesh.visible = true;
|
|
|
|
|
|
if (v.mesh.material?.isMeshStandardMaterial) {
|
|
|
|
|
|
const mat = v.mesh.material as MeshStandardMaterial;
|
|
|
|
|
|
const result = replaceWithShapeMaterial(mat, v.mesh.userData?.vis ?? 0);
|
2026-03-04 12:15:24 -08:00
|
|
|
|
v.mesh.material = result.material;
|
2026-03-02 22:57:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
|
|
|
|
|
|
v.mesh.material.transparent = true;
|
|
|
|
|
|
v.mesh.material.depthWrite = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
const atlas = iflMeshAtlasRef.current.get(v.mesh);
|
|
|
|
|
|
if (atlas && v.mesh.material && !Array.isArray(v.mesh.material)) {
|
|
|
|
|
|
v.mesh.material.map = atlas.texture;
|
|
|
|
|
|
v.mesh.material.needsUpdate = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handlePlayThread(slot: number, sequenceName: string) {
|
|
|
|
|
|
const seqLower = sequenceName.toLowerCase();
|
|
|
|
|
|
handleStopThread(slot);
|
|
|
|
|
|
|
|
|
|
|
|
const clip = clipsByName.get(seqLower);
|
|
|
|
|
|
const vNodes = visNodesBySequence.get(seqLower);
|
|
|
|
|
|
const thread: ThreadState = {
|
|
|
|
|
|
sequence: seqLower,
|
2026-03-05 15:00:05 -08:00
|
|
|
|
startTime: shapeNowSec(),
|
2026-03-02 22:57:58 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (clip && mixer) {
|
|
|
|
|
|
const action = mixer.clipAction(clip);
|
|
|
|
|
|
if (seqLower === "deploy") {
|
|
|
|
|
|
action.setLoop(LoopOnce, 1);
|
|
|
|
|
|
action.clampWhenFinished = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
action.setLoop(LoopRepeat, Infinity);
|
|
|
|
|
|
}
|
|
|
|
|
|
action.reset().play();
|
|
|
|
|
|
thread.action = action;
|
|
|
|
|
|
|
|
|
|
|
|
// When animations are disabled, snap deploy to its end pose.
|
|
|
|
|
|
if (!animationEnabledRef.current && seqLower === "deploy") {
|
|
|
|
|
|
action.time = clip.duration;
|
|
|
|
|
|
mixer.update(0);
|
|
|
|
|
|
// In mission mode, onDeployEndRef advances the lifecycle.
|
|
|
|
|
|
// In demo mode it's null — the ghost data drives what's next.
|
|
|
|
|
|
if (onDeployEndRef.current) {
|
|
|
|
|
|
queueMicrotask(() => onDeployEndRef.current?.(slot));
|
2026-02-28 17:58:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (vNodes) {
|
|
|
|
|
|
for (const v of vNodes) prepareVisNode(v);
|
|
|
|
|
|
thread.visNodes = vNodes;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
threads.set(slot, thread);
|
2026-02-28 17:58:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
function handleStopThread(slot: number) {
|
|
|
|
|
|
const thread = threads.get(slot);
|
|
|
|
|
|
if (!thread) return;
|
|
|
|
|
|
if (thread.action) thread.action.stop();
|
|
|
|
|
|
if (thread.visNodes) {
|
|
|
|
|
|
for (const v of thread.visNodes) {
|
|
|
|
|
|
v.mesh.visible = false;
|
|
|
|
|
|
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
|
|
|
|
|
|
v.mesh.material.opacity = v.keyframes[0];
|
|
|
|
|
|
}
|
2026-02-28 17:58:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
threads.delete(slot);
|
|
|
|
|
|
}
|
2026-02-28 17:58:09 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
handlePlayThreadRef.current = handlePlayThread;
|
|
|
|
|
|
handleStopThreadRef.current = handleStopThread;
|
2026-02-28 17:58:09 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// ── Demo playback: all animation driven by ghost thread data ──
|
|
|
|
|
|
// No deploy lifecycle, no auto-play, no TorqueScript. The useFrame
|
|
|
|
|
|
// handler reads demoThreads and calls handlePlayThread/handleStopThread.
|
|
|
|
|
|
if (demoThreadsRef.current != null) {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
handlePlayThreadRef.current = null;
|
|
|
|
|
|
handleStopThreadRef.current = null;
|
|
|
|
|
|
for (const slot of [...threads.keys()]) handleStopThread(slot);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2025-11-23 21:47:49 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// ── Mission rendering: deploy lifecycle + TorqueScript ──
|
|
|
|
|
|
const hasDeployClip = clipsByName.has("deploy");
|
|
|
|
|
|
const useTorqueDeploy = !!(runtime && hasDeployClip && object.datablock);
|
2025-11-23 21:47:49 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
function fireOnEndSequence(slot: number) {
|
|
|
|
|
|
if (!runtime) return;
|
|
|
|
|
|
const dbName = object.datablock;
|
|
|
|
|
|
if (!dbName) return;
|
|
|
|
|
|
const datablock = runtime.getObjectByName(String(dbName));
|
|
|
|
|
|
if (datablock) {
|
|
|
|
|
|
runtime.$.call(datablock, "onEndSequence", object, slot);
|
|
|
|
|
|
}
|
2025-11-23 21:47:49 -08:00
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
|
|
|
|
|
|
onDeployEndRef.current = useTorqueDeploy
|
|
|
|
|
|
? fireOnEndSequence
|
|
|
|
|
|
: () => startPostDeployThreads();
|
|
|
|
|
|
|
|
|
|
|
|
function startPostDeployThreads() {
|
|
|
|
|
|
const autoPlaySequences = ["ambient", "power"];
|
|
|
|
|
|
for (const seqName of autoPlaySequences) {
|
|
|
|
|
|
const vNodes = visNodesBySequence.get(seqName);
|
|
|
|
|
|
if (vNodes) {
|
2026-03-05 15:00:05 -08:00
|
|
|
|
const startTime = shapeNowSec();
|
2026-03-02 22:57:58 -08:00
|
|
|
|
for (const v of vNodes) prepareVisNode(v);
|
|
|
|
|
|
const slot = seqName === "power" ? 0 : 1;
|
|
|
|
|
|
threads.set(slot, { sequence: seqName, visNodes: vNodes, startTime });
|
|
|
|
|
|
}
|
|
|
|
|
|
const clip = clipsByName.get(seqName);
|
|
|
|
|
|
if (clip && mixer) {
|
|
|
|
|
|
const action = mixer.clipAction(clip);
|
|
|
|
|
|
action.setLoop(LoopRepeat, Infinity);
|
|
|
|
|
|
action.reset().play();
|
|
|
|
|
|
const slot = seqName === "power" ? 0 : 1;
|
|
|
|
|
|
const existing = threads.get(slot);
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
existing.action = action;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
threads.set(slot, {
|
|
|
|
|
|
sequence: seqName,
|
|
|
|
|
|
action,
|
2026-03-05 15:00:05 -08:00
|
|
|
|
startTime: shapeNowSec(),
|
2026-03-02 22:57:58 -08:00
|
|
|
|
});
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
const unsubs: (() => void)[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
const onFinished = mixer
|
|
|
|
|
|
? (e: { action: AnimationAction }) => {
|
|
|
|
|
|
for (const [slot, thread] of threads) {
|
|
|
|
|
|
if (thread.action === e.action) {
|
|
|
|
|
|
if (useTorqueDeploy) {
|
|
|
|
|
|
fireOnEndSequence(slot);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startPostDeployThreads();
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
break;
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
if (onFinished && mixer) {
|
|
|
|
|
|
mixer.addEventListener("finished", onFinished);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (runtime) {
|
|
|
|
|
|
unsubs.push(
|
|
|
|
|
|
runtime.$.onMethodCalled(
|
|
|
|
|
|
"ShapeBase",
|
|
|
|
|
|
"playThread",
|
|
|
|
|
|
(thisObj, slot, sequence) => {
|
|
|
|
|
|
if (thisObj._id !== object._id) return;
|
|
|
|
|
|
handlePlayThread(Number(slot), String(sequence));
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
unsubs.push(
|
|
|
|
|
|
runtime.$.onMethodCalled(
|
|
|
|
|
|
"ShapeBase",
|
|
|
|
|
|
"stopThread",
|
|
|
|
|
|
(thisObj, slot) => {
|
|
|
|
|
|
if (thisObj._id !== object._id) return;
|
|
|
|
|
|
handleStopThread(Number(slot));
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
unsubs.push(
|
|
|
|
|
|
runtime.$.onMethodCalled(
|
|
|
|
|
|
"ShapeBase",
|
|
|
|
|
|
"pauseThread",
|
|
|
|
|
|
(thisObj, slot) => {
|
|
|
|
|
|
if (thisObj._id !== object._id) return;
|
|
|
|
|
|
const thread = threads.get(Number(slot));
|
|
|
|
|
|
if (thread?.action) {
|
|
|
|
|
|
thread.action.paused = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (useTorqueDeploy) {
|
|
|
|
|
|
runtime.$.call(object, "deploy");
|
|
|
|
|
|
} else if (hasDeployClip) {
|
|
|
|
|
|
handlePlayThread(DEPLOY_THREAD, "deploy");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startPostDeployThreads();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (onFinished && mixer) {
|
|
|
|
|
|
mixer.removeEventListener("finished", onFinished);
|
|
|
|
|
|
}
|
|
|
|
|
|
unsubs.forEach((fn) => fn());
|
|
|
|
|
|
onDeployEndRef.current = null;
|
|
|
|
|
|
handlePlayThreadRef.current = null;
|
|
|
|
|
|
handleStopThreadRef.current = null;
|
|
|
|
|
|
for (const slot of [...threads.keys()]) handleStopThread(slot);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [mixer, clipsByName, visNodesBySequence, object, runtime]);
|
|
|
|
|
|
|
|
|
|
|
|
// Build DTS sequence index → animation name lookup. If the glTF has the
|
|
|
|
|
|
// dts_sequence_names extra (set by the addon), use it for an exact mapping
|
|
|
|
|
|
// from ghost ThreadMask indices to animation names. Otherwise fall back to
|
|
|
|
|
|
// positional indexing (which only works if no sequences were filtered).
|
|
|
|
|
|
const seqIndexToName = useMemo(() => {
|
|
|
|
|
|
const raw = gltf.scene.userData?.dts_sequence_names;
|
|
|
|
|
|
if (typeof raw === "string") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const names: string[] = JSON.parse(raw);
|
|
|
|
|
|
return names.map((n) => n.toLowerCase());
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
return gltf.animations.map((a) => a.name.toLowerCase());
|
|
|
|
|
|
}, [gltf]);
|
|
|
|
|
|
|
|
|
|
|
|
useFrame((_, delta) => {
|
|
|
|
|
|
const threads = threadsRef.current;
|
|
|
|
|
|
|
2026-03-05 15:00:05 -08:00
|
|
|
|
// In demo mode, scale animation by playback rate; freeze when paused.
|
|
|
|
|
|
const inDemo = demoThreadsRef.current != null;
|
|
|
|
|
|
const playbackState = engineStore.getState().playback;
|
|
|
|
|
|
const effectDelta = !inDemo ? delta
|
|
|
|
|
|
: playbackState.status === "playing" ? delta * playbackState.rate
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// React to demo thread state changes. The ghost ThreadMask data tells us
|
|
|
|
|
|
// exactly which DTS sequences are playing/stopped on each of 4 thread slots.
|
|
|
|
|
|
const currentDemoThreads = demoThreadsRef.current;
|
|
|
|
|
|
const prevDemoThreads = prevDemoThreadsRef.current;
|
|
|
|
|
|
if (currentDemoThreads !== prevDemoThreads) {
|
|
|
|
|
|
const playThread = handlePlayThreadRef.current;
|
|
|
|
|
|
const stopThread = handleStopThreadRef.current;
|
2026-03-04 12:15:24 -08:00
|
|
|
|
// Don't consume thread data until handlers are ready — leave
|
|
|
|
|
|
// prevDemoThreadsRef unchanged so the change is re-detected next frame.
|
2026-03-02 22:57:58 -08:00
|
|
|
|
if (playThread && stopThread) {
|
2026-03-04 12:15:24 -08:00
|
|
|
|
prevDemoThreadsRef.current = currentDemoThreads;
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// Use sparse arrays instead of Maps — thread indices are 0-3.
|
|
|
|
|
|
const currentBySlot: Array<DemoThreadState | undefined> = [];
|
|
|
|
|
|
if (currentDemoThreads) {
|
|
|
|
|
|
for (const t of currentDemoThreads) currentBySlot[t.index] = t;
|
|
|
|
|
|
}
|
|
|
|
|
|
const prevBySlot: Array<DemoThreadState | undefined> = [];
|
|
|
|
|
|
if (prevDemoThreads) {
|
|
|
|
|
|
for (const t of prevDemoThreads) prevBySlot[t.index] = t;
|
|
|
|
|
|
}
|
|
|
|
|
|
const maxSlot = Math.max(currentBySlot.length, prevBySlot.length);
|
|
|
|
|
|
for (let slot = 0; slot < maxSlot; slot++) {
|
|
|
|
|
|
const t = currentBySlot[slot];
|
|
|
|
|
|
const prev = prevBySlot[slot];
|
|
|
|
|
|
if (t) {
|
|
|
|
|
|
const changed = !prev
|
|
|
|
|
|
|| prev.sequence !== t.sequence
|
|
|
|
|
|
|| prev.state !== t.state
|
|
|
|
|
|
|| prev.atEnd !== t.atEnd;
|
|
|
|
|
|
if (!changed) continue;
|
2026-03-04 12:15:24 -08:00
|
|
|
|
|
|
|
|
|
|
// When only atEnd changed (false→true) on a playing thread with
|
|
|
|
|
|
// the same sequence, the animation has finished on the server.
|
|
|
|
|
|
// Don't restart it — snap to the end pose so one-shot animations
|
|
|
|
|
|
// like "deploy" stay clamped instead of collapsing back.
|
|
|
|
|
|
const onlyAtEndChanged = prev
|
|
|
|
|
|
&& prev.sequence === t.sequence
|
|
|
|
|
|
&& prev.state === t.state
|
|
|
|
|
|
&& t.state === 0
|
|
|
|
|
|
&& !prev.atEnd && t.atEnd;
|
|
|
|
|
|
if (onlyAtEndChanged) {
|
|
|
|
|
|
const thread = threads.get(slot);
|
|
|
|
|
|
if (thread?.action) {
|
|
|
|
|
|
const clip = thread.action.getClip();
|
|
|
|
|
|
thread.action.time = t.forward ? clip.duration : 0;
|
|
|
|
|
|
thread.action.setLoop(LoopOnce, 1);
|
|
|
|
|
|
thread.action.clampWhenFinished = true;
|
|
|
|
|
|
thread.action.paused = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
const seqName = seqIndexToName[t.sequence];
|
|
|
|
|
|
if (!seqName) continue;
|
|
|
|
|
|
if (t.state === 0) {
|
|
|
|
|
|
playThread(slot, seqName);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stopThread(slot);
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
} else if (prev) {
|
|
|
|
|
|
// Thread disappeared — stop it.
|
|
|
|
|
|
stopThread(slot);
|
2025-12-07 14:01:26 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
if (mixer) {
|
|
|
|
|
|
// If animation is disabled and deploy is still mid-animation,
|
|
|
|
|
|
// snap to the fully-deployed pose and fire onEndSequence.
|
|
|
|
|
|
if (!animationEnabled) {
|
|
|
|
|
|
const deployThread = threads.get(DEPLOY_THREAD);
|
|
|
|
|
|
if (deployThread?.action) {
|
|
|
|
|
|
const clip = deployThread.action.getClip();
|
|
|
|
|
|
if (deployThread.action.time < clip.duration - 0.001) {
|
|
|
|
|
|
deployThread.action.time = clip.duration;
|
|
|
|
|
|
mixer.update(0);
|
|
|
|
|
|
onDeployEndRef.current?.(DEPLOY_THREAD);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-07 14:01:26 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
if (animationEnabled) {
|
2026-03-05 15:00:05 -08:00
|
|
|
|
mixer.update(effectDelta);
|
2026-03-02 22:57:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-23 21:47:49 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
// Drive vis opacity animations for active threads.
|
|
|
|
|
|
for (const [, thread] of threads) {
|
|
|
|
|
|
if (!thread.visNodes) continue;
|
|
|
|
|
|
|
|
|
|
|
|
for (const { mesh, keyframes, duration, cyclic } of thread.visNodes) {
|
|
|
|
|
|
const mat = mesh.material;
|
|
|
|
|
|
if (!mat || Array.isArray(mat)) continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (!animationEnabled) {
|
|
|
|
|
|
mat.opacity = keyframes[0];
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 15:00:05 -08:00
|
|
|
|
const elapsed = shapeNowSec() - thread.startTime;
|
2026-03-02 22:57:58 -08:00
|
|
|
|
const t = cyclic
|
|
|
|
|
|
? (elapsed % duration) / duration
|
|
|
|
|
|
: Math.min(elapsed / duration, 1);
|
|
|
|
|
|
|
|
|
|
|
|
const n = keyframes.length;
|
|
|
|
|
|
const pos = t * n;
|
|
|
|
|
|
const lo = Math.floor(pos) % n;
|
|
|
|
|
|
const hi = (lo + 1) % n;
|
|
|
|
|
|
const frac = pos - Math.floor(pos);
|
|
|
|
|
|
mat.opacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Advance IFL texture atlases.
|
|
|
|
|
|
// Matches Torque's animateIfls():
|
|
|
|
|
|
// time = th->pos * th->sequence->duration + th->sequence->toolBegin
|
|
|
|
|
|
// where pos is [0,1) cyclic or [0,1] clamped, then frame is looked up in
|
|
|
|
|
|
// cumulative iflFrameOffTimes (seconds, at 1/30s per IFL tick).
|
|
|
|
|
|
// toolBegin offsets into the IFL timeline so the sequence window aligns
|
|
|
|
|
|
// with the desired frames (e.g. skipping a long "off" period).
|
|
|
|
|
|
const iflAnimInfos = iflAnimInfosRef.current;
|
|
|
|
|
|
if (iflAnimInfos.length > 0) {
|
2026-03-05 15:00:05 -08:00
|
|
|
|
iflTimeRef.current += effectDelta;
|
2026-03-02 22:57:58 -08:00
|
|
|
|
for (const info of iflAnimInfos) {
|
|
|
|
|
|
if (!animationEnabled) {
|
|
|
|
|
|
updateAtlasFrame(info.atlas, 0);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (info.sequenceName && info.sequenceDuration) {
|
|
|
|
|
|
// Sequence-driven IFL: find the thread playing this sequence and
|
|
|
|
|
|
// compute time = pos * duration + toolBegin (matching the engine).
|
|
|
|
|
|
let iflTime = 0;
|
|
|
|
|
|
for (const [, thread] of threads) {
|
|
|
|
|
|
if (thread.sequence === info.sequenceName) {
|
2026-03-05 15:00:05 -08:00
|
|
|
|
const elapsed = shapeNowSec() - thread.startTime;
|
2026-03-02 22:57:58 -08:00
|
|
|
|
const dur = info.sequenceDuration;
|
|
|
|
|
|
// Reproduce th->pos: cyclic wraps [0,1), non-cyclic clamps [0,1]
|
|
|
|
|
|
const pos = info.cyclic
|
|
|
|
|
|
? (elapsed / dur) % 1
|
|
|
|
|
|
: Math.min(elapsed / dur, 1);
|
|
|
|
|
|
iflTime = pos * dur + (info.toolBegin ?? 0);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
updateAtlasFrame(info.atlas, getFrameIndexForTime(info.atlas, iflTime));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// No controlling sequence: use accumulated real time.
|
|
|
|
|
|
// (In the engine, these would stay at frame 0, but cycling is more
|
|
|
|
|
|
// useful for display purposes.)
|
|
|
|
|
|
updateAtlasFrame(
|
|
|
|
|
|
info.atlas,
|
|
|
|
|
|
getFrameIndexForTime(info.atlas, iflTimeRef.current),
|
2026-02-28 17:58:09 -08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-02 22:57:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-02-28 17:58:09 -08:00
|
|
|
|
|
2026-03-02 22:57:58 -08:00
|
|
|
|
return (
|
|
|
|
|
|
<group rotation={[0, Math.PI / 2, 0]}>
|
|
|
|
|
|
<primitive object={clonedScene} />
|
2025-12-14 11:06:57 -08:00
|
|
|
|
{debugMode ? (
|
|
|
|
|
|
<FloatingLabel>
|
|
|
|
|
|
{object._id}: {shapeName}
|
|
|
|
|
|
</FloatingLabel>
|
|
|
|
|
|
) : null}
|
2025-11-25 16:56:54 -08:00
|
|
|
|
</group>
|
2025-11-23 21:47:49 -08:00
|
|
|
|
);
|
2025-11-25 17:36:41 -08:00
|
|
|
|
});
|
2026-03-02 22:57:58 -08:00
|
|
|
|
|
|
|
|
|
|
function ShapeModelLoader({ demoThreads }: { demoThreads?: DemoThreadState[] }) {
|
|
|
|
|
|
const { shapeName } = useShapeInfo();
|
|
|
|
|
|
const gltf = useStaticShape(shapeName);
|
|
|
|
|
|
return <ShapeModel gltf={gltf} demoThreads={demoThreads} />;
|
|
|
|
|
|
}
|