t2-mapper/src/components/GenericShape.tsx

1048 lines
33 KiB
TypeScript
Raw Normal View History

2026-03-02 22:57:58 -08:00
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
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";
import { createLogger } from "../logger";
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
2025-11-14 23:59:35 -08:00
import {
MeshStandardMaterial,
MeshBasicMaterial,
MeshLambertMaterial,
AdditiveBlending,
2026-03-02 22:57:58 -08:00
AnimationMixer,
AnimationClip,
LoopOnce,
LoopRepeat,
Texture,
BufferGeometry,
2026-02-28 17:58:09 -08:00
Group,
} 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";
import { setupTexture } from "../textureUtils";
2026-02-28 17:58:09 -08:00
import { useDebug, useSettings } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { useEngineSelector, effectNow, engineStore } from "../state/engineStore";
import { FloatingLabel } from "./FloatingLabel";
2026-03-02 22:57:58 -08:00
import {
useIflTexture,
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting } from "../shapeMaterial";
2026-03-02 22:57:58 -08:00
import {
processShapeScene,
replaceWithShapeMaterial,
2026-03-09 12:38:40 -07:00
} from "../stream/playbackUtils";
import type { ThreadState as StreamThreadState } from "../stream/types";
2025-11-14 19:33:44 -08:00
const log = createLogger("GenericShape");
/** Returns pausable time in seconds for demo mode, real time otherwise. */
function shapeNowSec(): number {
2026-03-09 12:38:40 -07:00
const { recording } = engineStore.getState().playback;
return recording != null ? effectNow() / 1000 : performance.now() / 1000;
}
/** 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 (01). Values < 1 enable alpha blending. */
vis?: number;
/** When true, material is created transparent for vis keyframe animation. */
animated?: 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
*/
2026-02-28 17:58:09 -08:00
export 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);
}
};
}
2026-02-28 17:58:09 -08:00
export function createMaterialFromFlags(
baseMaterial: MeshStandardMaterial,
2026-03-04 12:15:24 -08:00
texture: Texture | null,
flagNames: Set<string>,
isOrganic: boolean,
2026-02-28 17:58:09 -08:00
vis: number = 1,
animated: boolean = false,
): 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;
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;
const mat = new MeshBasicMaterial({
map: texture,
side: 2, // DoubleSide
2026-02-28 17:58:09 -08:00
transparent: isBlended,
depthWrite: !isBlended,
alphaTest: 0,
fog: true,
2026-02-28 17:58:09 -08:00
...(isFaded && { opacity: vis }),
...(isAdditive && { blending: AdditiveBlending }),
});
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,
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 }),
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,
2026-02-28 17:58:09 -08:00
...(isFaded && {
transparent: true,
opacity: vis,
depthWrite: false,
}),
});
applyShapeShaderModifications(mat);
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.
*/
const IflTexture = memo(function IflTexture({
2025-11-14 23:43:31 -08:00
material,
2025-11-15 01:42:32 -08:00
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
2026-02-28 17:58:09 -08:00
vis = 1,
animated = false,
}: TextureProps) {
2025-12-01 22:33:12 -08:00
const resourcePath = material.userData.resource_path;
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);
const isOrganic = shapeName && isOrganicShape(shapeName);
2025-12-01 22:33:12 -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-01 22:33:12 -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
return (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
2025-12-01 22:33:12 -08:00
2026-03-09 12:38:40 -07:00
const EMPTY_FLAG_NAMES = new Set<string>();
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,
}: TextureProps) {
2025-12-01 22:33:12 -08:00
const resourcePath = material.userData.resource_path;
2026-03-09 12:38:40 -07:00
const flagNames = useMemo(
() =>
material.userData.flag_names
? new Set<string>(material.userData.flag_names)
: EMPTY_FLAG_NAMES,
[material.userData.flag_names],
);
2025-12-01 22:33:12 -08:00
const url = useMemo(() => {
if (!resourcePath) {
log.warn("No resource_path found on \"%s\" — rendering fallback", shapeName);
}
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
}, [resourcePath, shapeName]);
const isOrganic = shapeName && isOrganicShape(shapeName);
const isTranslucent = flagNames.has("Translucent");
2025-11-15 01:42:32 -08:00
const texture = useTexture(url, (texture) => {
// Organic/alpha-tested textures need special handling to avoid mipmap artifacts
if (isOrganic || isTranslucent) {
return setupTexture(texture, { disableMipmaps: true });
2025-11-15 01:42:32 -08:00
}
// Standard color texture setup for diffuse-only materials
return setupTexture(texture);
2025-11-15 01:42:32 -08:00
});
2025-11-14 23:59:35 -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-11-14 23:43:31 -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
return (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
2025-11-14 19:33:44 -08:00
export const ShapeTexture = memo(function ShapeTexture({
2025-12-01 22:33:12 -08:00
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
2026-02-28 17:58:09 -08:00
vis = 1,
animated = false,
}: 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) {
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}
/>
);
} else if (material.name) {
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}
/>
);
} else {
return null;
2025-12-01 22:33:12 -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-30 17:42:59 -08:00
export function DebugPlaceholder({
color,
label,
}: {
color: string;
label?: string;
}) {
const { debugMode } = useDebug();
2025-11-30 17:42:59 -08:00
return debugMode ? <ShapePlaceholder color={color} label={label} /> : null;
}
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>
);
}
/**
* Wrapper component that handles the common ErrorBoundary + Suspense + ShapeModel
* pattern used across shape-rendering components.
*/
2026-03-09 12:38:40 -07:00
export const ShapeRenderer = memo(function ShapeRenderer({
loadingColor = "yellow",
2026-03-09 12:38:40 -07:00
streamEntity,
children,
}: {
loadingColor?: string;
2026-03-09 12:38:40 -07:00
/** Stable entity reference whose `.threads` field is mutated in-place. */
streamEntity?: { threads?: StreamThreadState[] };
children?: React.ReactNode;
}) {
2025-12-14 11:06:57 -08:00
const { object, shapeName } = useShapeInfo();
if (!shapeName) {
2025-12-14 11:06:57 -08:00
return (
2026-03-09 12:38:40 -07:00
<DebugPlaceholder color="orange" label={`${object?._id}: <missing>`} />
2025-12-14 11:06:57 -08:00
);
}
2026-02-28 17:58:09 -08:00
if (HARDCODED_SHAPES.has(shapeName.toLowerCase())) {
2026-03-09 12:38:40 -07:00
return <HardcodedShape label={`${object?._id}: ${shapeName}`} />;
2026-02-28 17:58:09 -08:00
}
return (
<ErrorBoundary
2025-12-14 11:06:57 -08:00
fallback={
2026-03-09 12:38:40 -07:00
<DebugPlaceholder color="red" label={`${object?._id}: ${shapeName}`} />
2025-12-14 11:06:57 -08:00
}
>
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
2026-03-09 12:38:40 -07:00
<ShapeModelLoader streamEntity={streamEntity} />
{children}
</Suspense>
</ErrorBoundary>
);
2026-03-09 12:38:40 -07:00
});
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
* 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,
2026-03-09 12:38:40 -07:00
streamEntity,
2026-02-28 17:58:09 -08:00
}: {
2026-03-02 22:57:58 -08:00
gltf: ReturnType<typeof useStaticShape>;
2026-03-09 12:38:40 -07:00
/** Stable entity reference whose `.threads` field is mutated in-place. */
streamEntity?: { threads?: StreamThreadState[] };
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);
2026-03-09 12:38:40 -07:00
const { clonedScene, mixer, clipsByName, visNodesBySequence, iflMeshes } =
useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
// 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,
});
}
});
2026-03-02 22:57:58 -08:00
2026-03-09 12:38:40 -07:00
processShapeScene(scene, shapeName ?? undefined);
2026-03-02 22:57:58 -08:00
2026-03-09 12:38:40 -07:00
// 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;
}
2026-03-02 22:57:58 -08:00
}
2026-03-09 12:38:40 -07:00
// 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,
});
2026-03-02 22:57:58 -08:00
});
2026-03-09 12:38:40 -07:00
// 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-09 12:38:40 -07:00
// Only create a mixer if there are skeleton animation clips.
const mix = clips.size > 0 ? new AnimationMixer(scene) : null;
2026-03-02 22:57:58 -08:00
2026-03-09 12:38:40 -07:00
return {
clonedScene: scene,
mixer: mix,
clipsByName: clips,
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
};
}, [gltf]);
2026-03-02 22:57:58 -08:00
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;
2026-03-09 12:38:40 -07:00
// Stream entity reference for imperative thread reads in useFrame.
// The entity is mutated in-place, so reading streamEntity?.threads
// always returns the latest value without requiring React re-renders.
const streamEntityRef = useRef(streamEntity);
streamEntityRef.current = streamEntity;
const handlePlayThreadRef = useRef<
((slot: number, seq: string) => void) | null
>(null);
2026-03-02 22:57:58 -08:00
const handleStopThreadRef = useRef<((slot: number) => void) | null>(null);
2026-03-09 12:38:40 -07:00
const prevDemoThreadsRef = useRef<StreamThreadState[] | undefined>(undefined);
2026-03-02 22:57:58 -08:00
// 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;
}
2026-03-09 12:38:40 -07:00
const iflInfo = {
2026-03-02 22:57:58 -08:00
atlas,
sequenceName: info.iflSequence,
sequenceDuration: info.iflDuration,
cyclic: info.iflCyclic,
toolBegin: info.iflToolBegin,
2026-03-09 12:38:40 -07:00
};
iflAnimInfosRef.current.push(iflInfo);
2026-03-02 22:57:58 -08:00
iflMeshAtlasRef.current.set(info.mesh, atlas);
})
2026-03-09 12:38:40 -07:00
.catch((err) => {
log.warn("Failed to load IFL atlas for %s: %o", info.iflPath, err);
2026-03-09 12:38:40 -07:00
});
2026-03-02 22:57:58 -08:00
}
}, [iflMeshes]);
2026-03-09 12:38:40 -07:00
// DTS cyclic flags by sequence name. Cyclic sequences loop; non-cyclic
// play once and clamp. Falls back to assuming cyclic if data is absent.
const seqCyclicByName = useMemo(() => {
const map = new Map<string, boolean>();
const rawNames = gltf.scene.userData?.dts_sequence_names;
const rawCyclic = gltf.scene.userData?.dts_sequence_cyclic;
if (typeof rawNames === "string" && typeof rawCyclic === "string") {
try {
const names: string[] = JSON.parse(rawNames);
const cyclic: boolean[] = JSON.parse(rawCyclic);
for (let i = 0; i < names.length; i++) {
map.set(names[i].toLowerCase(), cyclic[i] ?? true);
}
} catch {
/* expected */
}
}
return map;
}, [gltf]);
// Animation setup.
//
// Mission mode (streamEntity absent): auto-play default looping sequences
// (power, ambient) so static shapes look alive. TorqueScript playThread/
// stopThread/pauseThread events can override if scripts are loaded.
//
// Demo/live mode (streamEntity present): no auto-play. The useFrame
// handler reads ghost ThreadMask data and drives everything.
2026-03-02 22:57:58 -08:00
useEffect(() => {
const threads = threadsRef.current;
2026-03-09 12:38:40 -07:00
const isMissionMode = streamEntityRef.current == null;
2026-03-02 22:57:58 -08:00
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,
startTime: shapeNowSec(),
2026-03-02 22:57:58 -08:00
};
if (clip && mixer) {
const action = mixer.clipAction(clip);
2026-03-09 12:38:40 -07:00
const cyclic = seqCyclicByName.get(seqLower) ?? true;
if (cyclic) {
action.setLoop(LoopRepeat, Infinity);
} else {
2026-03-02 22:57:58 -08:00
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
}
action.reset().play();
thread.action = action;
}
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-09 12:38:40 -07:00
// ── Demo/live mode: no auto-play, useFrame drives from ghost data ──
if (!isMissionMode) {
2026-03-02 22:57:58 -08:00
return () => {
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
2026-03-09 12:38:40 -07:00
prevDemoThreadsRef.current = undefined;
2026-03-02 22:57:58 -08:00
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}
2026-03-09 12:38:40 -07:00
// ── Mission mode ──
2026-03-02 22:57:58 -08:00
const unsubs: (() => void)[] = [];
2026-03-09 12:38:40 -07:00
// Subscribe to TorqueScript playThread/stopThread/pauseThread so
// scripts can control animations at runtime.
2026-03-02 22:57:58 -08:00
if (runtime) {
unsubs.push(
runtime.$.onMethodCalled(
"ShapeBase",
"playThread",
(thisObj, slot, sequence) => {
2026-03-09 12:38:40 -07:00
if (thisObj._id !== object?._id) return;
2026-03-02 22:57:58 -08:00
handlePlayThread(Number(slot), String(sequence));
},
),
);
unsubs.push(
2026-03-09 12:38:40 -07:00
runtime.$.onMethodCalled("ShapeBase", "stopThread", (thisObj, slot) => {
if (thisObj._id !== object?._id) return;
handleStopThread(Number(slot));
}),
2026-03-02 22:57:58 -08:00
);
unsubs.push(
runtime.$.onMethodCalled(
"ShapeBase",
"pauseThread",
(thisObj, slot) => {
2026-03-09 12:38:40 -07:00
if (thisObj._id !== object?._id) return;
2026-03-02 22:57:58 -08:00
const thread = threads.get(Number(slot));
if (thread?.action) {
thread.action.paused = true;
}
},
),
);
}
2026-03-09 12:38:40 -07:00
// Start default looping sequences immediately. Thread slots match
// power.cs globals: $PowerThread=0, $AmbientThread=1.
const defaults: Array<[number, string]> = [
[0, "power"],
[1, "ambient"],
];
for (const [slot, seqName] of defaults) {
if (clipsByName.has(seqName) || visNodesBySequence.has(seqName)) {
handlePlayThread(slot, seqName);
}
2026-03-02 22:57:58 -08:00
}
return () => {
unsubs.forEach((fn) => fn());
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
2026-03-09 12:38:40 -07:00
prevDemoThreadsRef.current = undefined;
2026-03-02 22:57:58 -08:00
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
2026-03-09 12:38:40 -07:00
}, [
mixer,
clipsByName,
visNodesBySequence,
seqCyclicByName,
object,
runtime,
]);
2026-03-02 22:57:58 -08:00
// 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());
2026-03-09 12:38:40 -07:00
} catch {
/* expected */
}
2026-03-02 22:57:58 -08:00
}
return gltf.animations.map((a) => a.name.toLowerCase());
}, [gltf]);
useFrame((_, delta) => {
const threads = threadsRef.current;
2026-03-09 12:38:40 -07:00
// In demo/live mode, scale animation by playback rate; freeze when paused.
// Check streamEntity existence (not .threads) so shapes without thread
// data (e.g. Items) also freeze correctly when paused.
const inDemo = streamEntityRef.current != null;
const playbackState = engineStore.getState().playback;
2026-03-09 12:38:40 -07:00
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.
2026-03-09 12:38:40 -07:00
const currentDemoThreads = streamEntityRef.current?.threads;
2026-03-02 22:57:58 -08:00
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.
2026-03-09 12:38:40 -07:00
const currentBySlot: Array<StreamThreadState | undefined> = [];
2026-03-02 22:57:58 -08:00
if (currentDemoThreads) {
for (const t of currentDemoThreads) currentBySlot[t.index] = t;
}
2026-03-09 12:38:40 -07:00
const prevBySlot: Array<StreamThreadState | undefined> = [];
2026-03-02 22:57:58 -08:00
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) {
2026-03-09 12:38:40 -07:00
const changed =
!prev ||
prev.sequence !== t.sequence ||
prev.state !== t.state ||
prev.atEnd !== t.atEnd;
2026-03-02 22:57:58 -08:00
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.
2026-03-09 12:38:40 -07:00
const onlyAtEndChanged =
prev &&
prev.sequence === t.sequence &&
prev.state === t.state &&
t.state === 0 &&
!prev.atEnd &&
t.atEnd;
2026-03-04 12:15:24 -08:00
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);
}
2026-03-02 22:57:58 -08:00
} else if (prev) {
// Thread disappeared — stop it.
stopThread(slot);
}
}
2026-03-02 22:57:58 -08:00
}
}
2026-03-09 12:38:40 -07:00
if (mixer && animationEnabled) {
mixer.update(effectDelta);
2026-03-02 22:57:58 -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;
}
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) {
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) {
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;
}
}
2026-03-09 12:38:40 -07:00
updateAtlasFrame(
info.atlas,
getFrameIndexForTime(info.atlas, iflTime),
);
2026-03-02 22:57:58 -08:00
} 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>
2026-03-09 12:38:40 -07:00
{object?._id}: {shapeName}
2025-12-14 11:06:57 -08:00
</FloatingLabel>
) : null}
</group>
);
});
2026-03-02 22:57:58 -08:00
2026-03-09 12:38:40 -07:00
function ShapeModelLoader({
streamEntity,
}: {
streamEntity?: { threads?: StreamThreadState[] };
}) {
2026-03-02 22:57:58 -08:00
const { shapeName } = useShapeInfo();
const gltf = useStaticShape(shapeName);
2026-03-09 12:38:40 -07:00
return <ShapeModel gltf={gltf} streamEntity={streamEntity} />;
2026-03-02 22:57:58 -08:00
}