t2-mapper/src/components/GenericShape.tsx
2026-03-16 18:16:34 -07:00

1097 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useGLTF, useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { createLogger } from "../logger";
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
import {
MeshStandardMaterial,
MeshBasicMaterial,
MeshLambertMaterial,
AdditiveBlending,
AnimationMixer,
AnimationClip,
LoopOnce,
LoopRepeat,
Texture,
BufferGeometry,
Group,
} from "three";
import type { AnimationAction, Material } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils";
import { useAnisotropy } from "./useAnisotropy";
import { useDebug, useSettings } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import {
useEngineSelector,
effectNow,
engineStore,
} from "../state/engineStore";
import { FloatingLabel } from "./FloatingLabel";
import {
useIflTexture,
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting } from "../shapeMaterial";
import {
processShapeScene,
replaceWithShapeMaterial,
disposeClonedScene,
} from "../stream/playbackUtils";
import type { ThreadState as StreamThreadState } from "../stream/types";
const log = createLogger("GenericShape");
/** Returns pausable time in seconds for demo mode, real time otherwise. */
function shapeNowSec(): number {
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;
/** 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];
// Stable onBeforeCompile callbacks — using shared function references lets
// Three.js's program cache match by identity rather than toString().
const lambertBeforeCompile: Material["onBeforeCompile"] = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
};
const basicBeforeCompile: Material["onBeforeCompile"] = (shader) => {
injectCustomFog(shader, globalFogUniforms);
};
/**
* Helper to apply volumetric fog and lighting multipliers to a material.
*/
export function applyShapeShaderModifications(
mat: MeshBasicMaterial | MeshLambertMaterial,
): void {
mat.onBeforeCompile =
mat instanceof MeshLambertMaterial
? lambertBeforeCompile
: basicBeforeCompile;
}
export function createMaterialFromFlags(
baseMaterial: MeshStandardMaterial,
texture: Texture | null,
flagNames: Set<string>,
isOrganic: boolean,
vis: number = 1,
animated: boolean = false,
): MaterialResult {
const isTranslucent = flagNames.has("Translucent");
const isAdditive = flagNames.has("Additive");
const isSelfIlluminating = flagNames.has("SelfIlluminating");
// 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;
// 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) {
const isBlended = isAdditive || isTranslucent || isFaded;
const mat = new MeshBasicMaterial({
map: texture,
side: 2, // DoubleSide
transparent: isBlended,
depthWrite: !isBlended,
alphaTest: 0,
fog: true,
...(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,
// 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,
...(isFaded && {
transparent: true,
opacity: vis,
depthWrite: false,
}),
});
applyShapeShaderModifications(mat);
return mat;
}
/**
* Load a .glb file that was converted from a .dts, used for static shapes.
*/
export function useStaticShape(shapeName: string) {
const url = shapeToUrl(shapeName);
return useGLTF(url);
}
/**
* Animated IFL (Image File List) material component. Creates a sprite sheet
* from all frames and animates via texture offset.
*/
const IflTexture = memo(function IflTexture({
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
vis = 1,
animated = false,
}: TextureProps) {
const resourcePath = material.userData.resource_path;
const flagNames = useMemo(
() =>
material.userData.flag_names
? new Set<string>(material.userData.flag_names)
: EMPTY_FLAG_NAMES,
[material.userData.flag_names],
);
const iflPath = `textures/${resourcePath}.ifl`;
const texture = useIflTexture(iflPath);
const isOrganic = !!(shapeName && isOrganicShape(shapeName));
const customMaterial = useMemo(
() =>
createMaterialFromFlags(
material,
texture,
flagNames,
isOrganic,
vis,
animated,
),
[material, texture, flagNames, isOrganic, vis, animated],
);
useDisposeMaterial(customMaterial);
// 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 (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
function useDisposeMaterial(material: MaterialResult) {
useEffect(() => {
return () => {
if (Array.isArray(material)) {
material.forEach((m) => m.dispose());
} else {
material.dispose();
}
};
}, [material]);
}
const EMPTY_FLAG_NAMES = new Set<string>();
const StaticTexture = memo(function StaticTexture({
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
vis = 1,
animated = false,
}: TextureProps) {
const resourcePath = material.userData.resource_path;
const flagNames = useMemo(
() =>
material.userData.flag_names
? new Set<string>(material.userData.flag_names)
: EMPTY_FLAG_NAMES,
[material.userData.flag_names],
);
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");
const anisotropy = useAnisotropy();
const texture = useTexture(url, (texture) => {
// Organic/alpha-tested textures need special handling to avoid mipmap artifacts
if (isOrganic || isTranslucent) {
return setupTexture(texture, { disableMipmaps: true, anisotropy });
}
// Standard color texture setup for diffuse-only materials
return setupTexture(texture, { anisotropy });
});
const customMaterial = useMemo(
() =>
createMaterialFromFlags(
material,
texture,
flagNames,
isOrganic,
vis,
animated,
),
[material, texture, flagNames, isOrganic, vis, animated],
);
useDisposeMaterial(customMaterial);
// 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 (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
export const ShapeTexture = memo(function ShapeTexture({
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
vis = 1,
animated = 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}
geometry={geometry}
backGeometry={backGeometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
vis={vis}
animated={animated}
/>
);
} else if (material.name) {
return (
<StaticTexture
material={material}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
vis={vis}
animated={animated}
/>
);
} else {
return null;
}
});
export function ShapePlaceholder({
color,
label,
}: {
color: string;
label?: string;
}) {
return (
<mesh>
<boxGeometry args={[10, 10, 10]} />
<meshStandardMaterial color={color} wireframe />
{label ? <FloatingLabel color={color}>{label}</FloatingLabel> : null}
</mesh>
);
}
export function DebugPlaceholder({
color,
label,
}: {
color: string;
label?: string;
}) {
const { debugMode } = useDebug();
return debugMode ? <ShapePlaceholder color={color} label={label} /> : null;
}
/** 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.
*/
export const ShapeRenderer = memo(function ShapeRenderer({
loadingColor = "yellow",
streamEntity,
children,
}: {
loadingColor?: string;
/** Stable entity reference whose `.threads` field is mutated in-place. */
streamEntity?: { threads?: StreamThreadState[] };
children?: React.ReactNode;
}) {
const { object, shapeName } = useShapeInfo();
if (!shapeName) {
return (
<DebugPlaceholder color="orange" label={`${object?._id}: <missing>`} />
);
}
if (HARDCODED_SHAPES.has(shapeName.toLowerCase())) {
return <HardcodedShape label={`${object?._id}: ${shapeName}`} />;
}
return (
<ErrorBoundary
fallback={
<DebugPlaceholder color="red" label={`${object?._id}: ${shapeName}`} />
}
>
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
<ShapeModelLoader streamEntity={streamEntity} />
{children}
</Suspense>
</ErrorBoundary>
);
});
/** 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;
}
/**
* 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).
*/
export const ShapeModel = memo(function ShapeModel({
gltf,
streamEntity,
}: {
gltf: ReturnType<typeof useStaticShape>;
/** Stable entity reference whose `.threads` field is mutated in-place. */
streamEntity?: { threads?: StreamThreadState[] };
}) {
const { object, shapeName } = useShapeInfo();
const { debugMode } = useDebug();
const { animationEnabled } = useSettings();
const runtime = useEngineSelector((state) => state.runtime.runtime);
const anisotropy = useAnisotropy();
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,
});
}
});
processShapeScene(scene, shapeName ?? undefined, { anisotropy });
// 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);
}
// 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, anisotropy]);
// Dispose cloned geometries and materials when the scene is replaced or
// the component unmounts, to prevent GPU memory from accumulating.
useEffect(() => {
return () => {
disposeClonedScene(clonedScene);
mixer?.uncacheRoot(clonedScene);
};
}, [clonedScene, mixer]);
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;
// 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);
const handleStopThreadRef = useRef<((slot: number) => void) | null>(null);
const prevDemoThreadsRef = useRef<StreamThreadState[] | 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;
}
const iflInfo = {
atlas,
sequenceName: info.iflSequence,
sequenceDuration: info.iflDuration,
cyclic: info.iflCyclic,
toolBegin: info.iflToolBegin,
};
iflAnimInfosRef.current.push(iflInfo);
iflMeshAtlasRef.current.set(info.mesh, atlas);
})
.catch((err) => {
log.warn("Failed to load IFL atlas for %s: %o", info.iflPath, err);
});
}
}, [iflMeshes]);
// 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.
useEffect(() => {
const threads = threadsRef.current;
const isMissionMode = streamEntityRef.current == null;
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);
v.mesh.material = result.material;
}
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(),
};
if (clip && mixer) {
const action = mixer.clipAction(clip);
const cyclic = seqCyclicByName.get(seqLower) ?? true;
if (cyclic) {
action.setLoop(LoopRepeat, Infinity);
} else {
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);
}
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];
}
}
}
threads.delete(slot);
}
handlePlayThreadRef.current = handlePlayThread;
handleStopThreadRef.current = handleStopThread;
// ── Demo/live mode: no auto-play, useFrame drives from ghost data ──
if (!isMissionMode) {
return () => {
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
prevDemoThreadsRef.current = undefined;
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}
// ── Mission mode ──
const unsubs: (() => void)[] = [];
// Subscribe to TorqueScript playThread/stopThread/pauseThread so
// scripts can control animations at runtime.
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;
}
},
),
);
}
// 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);
}
}
return () => {
unsubs.forEach((fn) => fn());
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
prevDemoThreadsRef.current = undefined;
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}, [
mixer,
clipsByName,
visNodesBySequence,
seqCyclicByName,
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 {
/* expected */
}
}
return gltf.animations.map((a) => a.name.toLowerCase());
}, [gltf]);
useFrame((_, delta) => {
const threads = threadsRef.current;
// 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;
const effectDelta = !inDemo
? delta
: playbackState.status === "playing"
? delta * playbackState.rate
: 0;
// 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 = streamEntityRef.current?.threads;
const prevDemoThreads = prevDemoThreadsRef.current;
if (currentDemoThreads !== prevDemoThreads) {
const playThread = handlePlayThreadRef.current;
const stopThread = handleStopThreadRef.current;
// Don't consume thread data until handlers are ready — leave
// prevDemoThreadsRef unchanged so the change is re-detected next frame.
if (playThread && stopThread) {
prevDemoThreadsRef.current = currentDemoThreads;
// Use sparse arrays instead of Maps — thread indices are 0-3.
const currentBySlot: Array<StreamThreadState | undefined> = [];
if (currentDemoThreads) {
for (const t of currentDemoThreads) currentBySlot[t.index] = t;
}
const prevBySlot: Array<StreamThreadState | 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;
// 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;
}
const seqName = seqIndexToName[t.sequence];
if (!seqName) continue;
if (t.state === 0) {
playThread(slot, seqName);
} else {
stopThread(slot);
}
} else if (prev) {
// Thread disappeared — stop it.
stopThread(slot);
}
}
}
}
if (mixer && animationEnabled) {
mixer.update(effectDelta);
}
// 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;
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;
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;
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),
);
}
}
}
});
return (
<group rotation={[0, Math.PI / 2, 0]}>
<primitive object={clonedScene} />
{debugMode ? (
<FloatingLabel>
{object?._id}: {shapeName}
</FloatingLabel>
) : null}
</group>
);
});
function ShapeModelLoader({
streamEntity,
}: {
streamEntity?: { threads?: StreamThreadState[] };
}) {
const { shapeName } = useShapeInfo();
const gltf = useStaticShape(shapeName);
return <ShapeModel gltf={gltf} streamEntity={streamEntity} />;
}