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 (0–1). 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, 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(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 ( <> ); } return ( ); }); 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(); 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(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 ( <> ); } return ( ); }); 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 ( ); } else if (material.name) { return ( ); } else { return null; } }); export function ShapePlaceholder({ color, label, }: { color: string; label?: string; }) { return ( {label ? {label} : null} ); } export function DebugPlaceholder({ color, label, }: { color: string; label?: string; }) { const { debugMode } = useDebug(); return debugMode ? : null; } /** 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 ( {label ? {label} : null} ); } /** * 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 ( `} /> ); } if (HARDCODED_SHAPES.has(shapeName.toLowerCase())) { return ; } return ( } > }> {children} ); }); /** 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; /** 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(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(); 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(); 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()); const iflMeshAtlasRef = useRef(new Map()); 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([]); 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(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(); 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 = []; if (currentDemoThreads) { for (const t of currentDemoThreads) currentBySlot[t.index] = t; } const prevBySlot: Array = []; 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 ( {debugMode ? ( {object?._id}: {shapeName} ) : null} ); }); function ShapeModelLoader({ streamEntity, }: { streamEntity?: { threads?: StreamThreadState[] }; }) { const { shapeName } = useShapeInfo(); const gltf = useStaticShape(shapeName); return ; }