2026-03-05 15:00:05 -08:00
|
|
|
|
import { useEffect, useMemo, useRef } from "react";
|
|
|
|
|
|
import { useFrame } from "@react-three/fiber";
|
2026-03-12 16:25:04 -07:00
|
|
|
|
import { AnimationMixer, LoopOnce, Quaternion, Vector3 } from "three";
|
2026-03-05 15:00:05 -08:00
|
|
|
|
import type { Group, Material } from "three";
|
2026-03-12 16:25:04 -07:00
|
|
|
|
import { effectNow, engineStore } from "../state/engineStore";
|
2026-03-05 15:00:05 -08:00
|
|
|
|
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
2026-03-01 08:33:38 -08:00
|
|
|
|
import {
|
|
|
|
|
|
_r90,
|
|
|
|
|
|
_r90inv,
|
|
|
|
|
|
getPosedNodeTransform,
|
2026-03-05 15:00:05 -08:00
|
|
|
|
processShapeScene,
|
2026-03-09 12:38:40 -07:00
|
|
|
|
} from "../stream/playbackUtils";
|
2026-03-05 15:00:05 -08:00
|
|
|
|
import {
|
|
|
|
|
|
loadIflAtlas,
|
|
|
|
|
|
getFrameIndexForTime,
|
|
|
|
|
|
updateAtlasFrame,
|
|
|
|
|
|
} from "./useIflTexture";
|
|
|
|
|
|
import type { IflAtlas } from "./useIflTexture";
|
2026-03-12 16:25:04 -07:00
|
|
|
|
import { ShapeRenderer, useStaticShape } from "./GenericShape";
|
2026-03-01 08:33:38 -08:00
|
|
|
|
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
|
|
|
|
|
import type { TorqueObject } from "../torqueScript";
|
2026-03-12 16:34:43 -07:00
|
|
|
|
import type { ExplosionEntity, ShapeEntity } from "../state/gameEntityTypes";
|
2026-03-12 16:25:04 -07:00
|
|
|
|
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
2026-03-01 08:33:38 -08:00
|
|
|
|
|
2026-03-05 15:00:05 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Map weapon shape to the arm blend animation (armThread).
|
|
|
|
|
|
* Only missile launcher and sniper rifle have custom arm poses; all others
|
|
|
|
|
|
* use the default `lookde`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getArmThread(weaponShape: string | undefined): string {
|
|
|
|
|
|
if (!weaponShape) return "lookde";
|
|
|
|
|
|
const lower = weaponShape.toLowerCase();
|
|
|
|
|
|
if (lower.includes("missile")) return "lookms";
|
|
|
|
|
|
if (lower.includes("sniper")) return "looksn";
|
|
|
|
|
|
return "lookde";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 08:33:38 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Renders a mounted weapon using the Torque engine's mount system.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The weapon's `Mountpoint` node is aligned to the player's `Mount0` node
|
|
|
|
|
|
* (right hand). Both nodes come from the GLB skeleton in its idle ("Root"
|
2026-03-05 15:00:05 -08:00
|
|
|
|
* animation) pose, with the weapon-specific arm animation applied additively.
|
|
|
|
|
|
* The mount transform is conjugated by ShapeRenderer's 90° Y rotation:
|
|
|
|
|
|
* T_mount = R90 * M0 * MP^(-1) * R90^(-1).
|
2026-03-01 08:33:38 -08:00
|
|
|
|
*/
|
2026-03-12 16:34:43 -07:00
|
|
|
|
export function WeaponModel({ entity }: { entity: ShapeEntity }) {
|
|
|
|
|
|
const shapeName = entity.weaponShape;
|
|
|
|
|
|
const playerShapeName = entity.shapeName;
|
2026-03-01 08:33:38 -08:00
|
|
|
|
const playerGltf = useStaticShape(playerShapeName);
|
|
|
|
|
|
const weaponGltf = useStaticShape(shapeName);
|
|
|
|
|
|
|
2026-03-09 12:38:40 -07:00
|
|
|
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
2026-03-01 08:33:38 -08:00
|
|
|
|
const mountTransform = useMemo(() => {
|
2026-03-05 15:00:05 -08:00
|
|
|
|
// Get Mount0 from the player's posed skeleton with arm animation applied.
|
|
|
|
|
|
const armThread = getArmThread(shapeName);
|
2026-03-01 08:33:38 -08:00
|
|
|
|
const m0 = getPosedNodeTransform(
|
|
|
|
|
|
playerGltf.scene,
|
|
|
|
|
|
playerGltf.animations,
|
|
|
|
|
|
"Mount0",
|
2026-03-05 15:00:05 -08:00
|
|
|
|
[armThread],
|
2026-03-01 08:33:38 -08:00
|
|
|
|
);
|
|
|
|
|
|
if (!m0) return { position: undefined, quaternion: undefined };
|
|
|
|
|
|
|
|
|
|
|
|
// Get Mountpoint from weapon (may not be animated).
|
|
|
|
|
|
const mp = getPosedNodeTransform(
|
|
|
|
|
|
weaponGltf.scene,
|
|
|
|
|
|
weaponGltf.animations,
|
|
|
|
|
|
"Mountpoint",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Compute T_mount = R90 * M0 * MP^(-1) * R90^(-1)
|
|
|
|
|
|
// This conjugates the GLB-space mount transform by ShapeRenderer's 90° Y
|
|
|
|
|
|
// rotation so the weapon is correctly oriented in entity space.
|
|
|
|
|
|
let combinedPos: Vector3;
|
|
|
|
|
|
let combinedQuat: Quaternion;
|
|
|
|
|
|
|
|
|
|
|
|
if (mp) {
|
|
|
|
|
|
// MP^(-1)
|
|
|
|
|
|
const mpInvQuat = mp.quaternion.clone().invert();
|
|
|
|
|
|
const mpInvPos = mp.position.clone().negate().applyQuaternion(mpInvQuat);
|
|
|
|
|
|
|
|
|
|
|
|
// M0 * MP^(-1)
|
|
|
|
|
|
combinedQuat = m0.quaternion.clone().multiply(mpInvQuat);
|
|
|
|
|
|
combinedPos = mpInvPos
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.applyQuaternion(m0.quaternion)
|
|
|
|
|
|
.add(m0.position);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
combinedPos = m0.position.clone();
|
|
|
|
|
|
combinedQuat = m0.quaternion.clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// R90 * combined * R90^(-1)
|
|
|
|
|
|
const mountPos = combinedPos.applyQuaternion(_r90);
|
|
|
|
|
|
const mountQuat = _r90.clone().multiply(combinedQuat).multiply(_r90inv);
|
|
|
|
|
|
|
|
|
|
|
|
return { position: mountPos, quaternion: mountQuat };
|
|
|
|
|
|
}, [playerGltf, weaponGltf]);
|
|
|
|
|
|
|
|
|
|
|
|
const torqueObject = useMemo<TorqueObject>(
|
|
|
|
|
|
() => ({
|
|
|
|
|
|
_class: "weapon",
|
|
|
|
|
|
_className: "Weapon",
|
|
|
|
|
|
_id: 0,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ShapeInfoProvider object={torqueObject} shapeName={shapeName} type="Item">
|
|
|
|
|
|
<group
|
|
|
|
|
|
position={mountTransform.position}
|
|
|
|
|
|
quaternion={mountTransform.quaternion}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ShapeRenderer loadingColor="#4488ff" />
|
|
|
|
|
|
</group>
|
|
|
|
|
|
</ShapeInfoProvider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-05 15:00:05 -08:00
|
|
|
|
|
|
|
|
|
|
// ── Explosion shape rendering ──
|
|
|
|
|
|
//
|
|
|
|
|
|
// Explosion DTS shapes are flat billboard planes with IFL-animated textures,
|
|
|
|
|
|
// vis-keyframed opacity, and size keyframe interpolation. They use
|
|
|
|
|
|
// useStaticShape (shared GLTF cache via drei's useGLTF) but render directly
|
|
|
|
|
|
// rather than through ShapeRenderer, because:
|
|
|
|
|
|
// - faceViewer billboarding needs to control the shape orientation
|
|
|
|
|
|
// - ShapeModel's fixed 90° Y rotation conflicts with billboard orientation
|
|
|
|
|
|
// - Explosion shapes need LoopOnce animation, not the deploy/ambient lifecycle
|
|
|
|
|
|
|
|
|
|
|
|
interface VisNode {
|
|
|
|
|
|
mesh: any;
|
|
|
|
|
|
keyframes: number[];
|
|
|
|
|
|
duration: number;
|
|
|
|
|
|
cyclic: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface IflInfo {
|
|
|
|
|
|
mesh: any;
|
|
|
|
|
|
iflPath: string;
|
|
|
|
|
|
sequenceName?: string;
|
|
|
|
|
|
duration?: number;
|
|
|
|
|
|
cyclic?: boolean;
|
|
|
|
|
|
toolBegin?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractSizeKeyframes(expBlock: Record<string, unknown>): {
|
|
|
|
|
|
times: number[];
|
|
|
|
|
|
sizes: [number, number, number][];
|
|
|
|
|
|
} {
|
|
|
|
|
|
const rawSizes = expBlock.sizes as
|
|
|
|
|
|
| Array<{ x: number; y: number; z: number }>
|
|
|
|
|
|
| undefined;
|
|
|
|
|
|
const rawTimes = expBlock.times as number[] | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(rawSizes) || rawSizes.length === 0) {
|
2026-03-12 16:25:04 -07:00
|
|
|
|
return {
|
|
|
|
|
|
times: [0, 1],
|
|
|
|
|
|
sizes: [
|
|
|
|
|
|
[1, 1, 1],
|
|
|
|
|
|
[1, 1, 1],
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
2026-03-05 15:00:05 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sizes are packed as value*100 integers on the wire; divide by 100.
|
|
|
|
|
|
const sizes: [number, number, number][] = rawSizes.map((s) => [
|
|
|
|
|
|
s.x / 100,
|
|
|
|
|
|
s.y / 100,
|
|
|
|
|
|
s.z / 100,
|
|
|
|
|
|
]);
|
|
|
|
|
|
// times are written via writeFloat(8) and are already [0,1] floats.
|
|
|
|
|
|
const times = Array.isArray(rawTimes)
|
|
|
|
|
|
? rawTimes
|
|
|
|
|
|
: sizes.map((_, i) => i / Math.max(sizes.length - 1, 1));
|
|
|
|
|
|
|
|
|
|
|
|
return { times, sizes };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function interpolateSize(
|
|
|
|
|
|
keyframes: { times: number[]; sizes: [number, number, number][] },
|
|
|
|
|
|
t: number,
|
|
|
|
|
|
): [number, number, number] {
|
|
|
|
|
|
const { times, sizes } = keyframes;
|
|
|
|
|
|
if (times.length === 0) return [1, 1, 1];
|
|
|
|
|
|
if (t <= times[0]) return sizes[0];
|
|
|
|
|
|
if (t >= times[times.length - 1]) return sizes[sizes.length - 1];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < times.length - 1; i++) {
|
|
|
|
|
|
if (t >= times[i] && t <= times[i + 1]) {
|
|
|
|
|
|
const frac = (t - times[i]) / (times[i + 1] - times[i]);
|
|
|
|
|
|
return [
|
|
|
|
|
|
sizes[i][0] + (sizes[i + 1][0] - sizes[i][0]) * frac,
|
|
|
|
|
|
sizes[i][1] + (sizes[i + 1][1] - sizes[i][1]) * frac,
|
|
|
|
|
|
sizes[i][2] + (sizes[i + 1][2] - sizes[i][2]) * frac,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return sizes[sizes.length - 1];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Renders an explosion DTS shape using useStaticShape (shared GLTF cache)
|
|
|
|
|
|
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
|
|
|
|
|
|
*/
|
2026-03-12 16:25:04 -07:00
|
|
|
|
export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
|
|
|
|
|
|
const playback = streamPlaybackStore.getState().playback;
|
|
|
|
|
|
const gltf = useStaticShape(entity.shapeName!);
|
2026-03-05 15:00:05 -08:00
|
|
|
|
const groupRef = useRef<Group>(null);
|
2026-03-09 12:38:40 -07:00
|
|
|
|
const startTimeRef = useRef(effectNow());
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/purity
|
2026-03-05 15:00:05 -08:00
|
|
|
|
const randAngleRef = useRef(Math.random() * Math.PI * 2);
|
|
|
|
|
|
const iflAtlasesRef = useRef<Array<{ atlas: IflAtlas; info: IflInfo }>>([]);
|
|
|
|
|
|
|
|
|
|
|
|
const expBlock = useMemo(() => {
|
|
|
|
|
|
if (!entity.explosionDataBlockId) return undefined;
|
|
|
|
|
|
return playback.getDataBlockData(entity.explosionDataBlockId);
|
|
|
|
|
|
}, [entity.explosionDataBlockId, playback]);
|
|
|
|
|
|
|
|
|
|
|
|
const sizeKeyframes = useMemo(
|
|
|
|
|
|
() => (expBlock ? extractSizeKeyframes(expBlock) : undefined),
|
|
|
|
|
|
[expBlock],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const baseScale = useMemo<[number, number, number]>(() => {
|
|
|
|
|
|
const explosionScale = expBlock?.explosionScale as
|
|
|
|
|
|
| { x: number; y: number; z: number }
|
|
|
|
|
|
| undefined;
|
|
|
|
|
|
return explosionScale
|
|
|
|
|
|
? [explosionScale.x / 100, explosionScale.y / 100, explosionScale.z / 100]
|
|
|
|
|
|
: [1, 1, 1];
|
|
|
|
|
|
}, [expBlock]);
|
|
|
|
|
|
|
|
|
|
|
|
// lifetimeMS is packed as value >> 5 (ticks); recover with << 5 (× 32).
|
|
|
|
|
|
const lifetimeTicks = (expBlock?.lifetimeMS as number) ?? 31;
|
|
|
|
|
|
const lifetimeMS = lifetimeTicks * 32;
|
|
|
|
|
|
const faceViewer = entity.faceViewer !== false;
|
|
|
|
|
|
|
|
|
|
|
|
// Clone scene, process materials, collect vis nodes and IFL info.
|
|
|
|
|
|
const { scene, mixer, visNodes, iflInfos, materials } = useMemo(() => {
|
|
|
|
|
|
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
|
|
|
|
|
|
|
|
|
|
|
// Collect IFL info BEFORE processShapeScene replaces materials.
|
|
|
|
|
|
const iflInfos: IflInfo[] = [];
|
|
|
|
|
|
scene.traverse((node: any) => {
|
|
|
|
|
|
if (!node.isMesh || !node.material) return;
|
2026-03-12 16:25:04 -07:00
|
|
|
|
const mat = Array.isArray(node.material)
|
|
|
|
|
|
? node.material[0]
|
|
|
|
|
|
: node.material;
|
2026-03-05 15:00:05 -08:00
|
|
|
|
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;
|
|
|
|
|
|
iflInfos.push({
|
|
|
|
|
|
mesh: node,
|
|
|
|
|
|
iflPath: `textures/${rp}.ifl`,
|
|
|
|
|
|
sequenceName: ud?.ifl_sequence
|
|
|
|
|
|
? String(ud.ifl_sequence).toLowerCase()
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
duration: ud?.ifl_duration ? Number(ud.ifl_duration) : undefined,
|
|
|
|
|
|
cyclic: ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined,
|
2026-03-12 16:25:04 -07:00
|
|
|
|
toolBegin:
|
|
|
|
|
|
ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined,
|
2026-03-05 15:00:05 -08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-12 16:25:04 -07:00
|
|
|
|
processShapeScene(scene, entity.shapeName);
|
2026-03-05 15:00:05 -08:00
|
|
|
|
|
|
|
|
|
|
// Collect vis-animated nodes keyed by sequence name.
|
|
|
|
|
|
const visNodes: 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;
|
|
|
|
|
|
// Only include vis nodes tied to the "ambient" sequence.
|
|
|
|
|
|
if (seqName === "ambient") {
|
2026-03-12 16:25:04 -07:00
|
|
|
|
visNodes.push({
|
|
|
|
|
|
mesh: node,
|
|
|
|
|
|
keyframes: kf,
|
|
|
|
|
|
duration: dur,
|
|
|
|
|
|
cyclic: !!ud.vis_cyclic,
|
|
|
|
|
|
});
|
2026-03-05 15:00:05 -08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Activate vis nodes: make visible, ensure transparent material.
|
|
|
|
|
|
for (const v of visNodes) {
|
|
|
|
|
|
v.mesh.visible = true;
|
|
|
|
|
|
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
|
|
|
|
|
|
v.mesh.material.transparent = true;
|
|
|
|
|
|
v.mesh.material.depthWrite = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Also un-hide IFL meshes that don't have vis_sequence (always visible).
|
|
|
|
|
|
for (const info of iflInfos) {
|
|
|
|
|
|
if (!info.mesh.userData?.vis_sequence) {
|
|
|
|
|
|
info.mesh.visible = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set up animation mixer with the ambient clip (LoopOnce).
|
|
|
|
|
|
const clips = new Map<string, any>();
|
|
|
|
|
|
for (const clip of gltf.animations) {
|
|
|
|
|
|
clips.set(clip.name.toLowerCase(), clip);
|
|
|
|
|
|
}
|
|
|
|
|
|
const ambientClip = clips.get("ambient");
|
|
|
|
|
|
let mixer: AnimationMixer | null = null;
|
|
|
|
|
|
if (ambientClip) {
|
|
|
|
|
|
mixer = new AnimationMixer(scene);
|
|
|
|
|
|
const action = mixer.clipAction(ambientClip);
|
|
|
|
|
|
action.setLoop(LoopOnce, 1);
|
|
|
|
|
|
action.clampWhenFinished = true;
|
|
|
|
|
|
// playSpeed is packed as value*20 on the wire; divide by 20.
|
|
|
|
|
|
const playSpeed = ((expBlock?.playSpeed as number) ?? 20) / 20;
|
|
|
|
|
|
action.timeScale = playSpeed;
|
|
|
|
|
|
action.play();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Collect all materials for fade-out.
|
|
|
|
|
|
const materials: Material[] = [];
|
|
|
|
|
|
scene.traverse((child: any) => {
|
|
|
|
|
|
if (!child.isMesh) return;
|
|
|
|
|
|
if (Array.isArray(child.material)) {
|
|
|
|
|
|
materials.push(...child.material);
|
|
|
|
|
|
} else if (child.material) {
|
|
|
|
|
|
materials.push(child.material);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Disable frustum culling (explosion may scale beyond bounds).
|
2026-03-12 16:25:04 -07:00
|
|
|
|
scene.traverse((child) => {
|
|
|
|
|
|
child.frustumCulled = false;
|
|
|
|
|
|
});
|
2026-03-05 15:00:05 -08:00
|
|
|
|
|
|
|
|
|
|
return { scene, mixer, visNodes, iflInfos, materials };
|
|
|
|
|
|
}, [gltf, expBlock]);
|
|
|
|
|
|
|
|
|
|
|
|
// Load IFL texture atlases.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
iflAtlasesRef.current = [];
|
|
|
|
|
|
for (const info of iflInfos) {
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
iflAtlasesRef.current.push({ atlas, info });
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [iflInfos]);
|
|
|
|
|
|
|
|
|
|
|
|
useFrame((state, delta) => {
|
|
|
|
|
|
const group = groupRef.current;
|
|
|
|
|
|
if (!group) return;
|
|
|
|
|
|
|
|
|
|
|
|
const playbackState = engineStore.getState().playback;
|
2026-03-12 16:25:04 -07:00
|
|
|
|
const effectDelta =
|
|
|
|
|
|
playbackState.status === "playing" ? delta * playbackState.rate : 0;
|
2026-03-05 15:00:05 -08:00
|
|
|
|
|
2026-03-09 12:38:40 -07:00
|
|
|
|
const elapsed = effectNow() - startTimeRef.current;
|
2026-03-05 15:00:05 -08:00
|
|
|
|
const t = Math.min(elapsed / lifetimeMS, 1);
|
|
|
|
|
|
const elapsedSec = elapsed / 1000;
|
|
|
|
|
|
|
|
|
|
|
|
// Advance skeleton animation.
|
|
|
|
|
|
if (mixer) {
|
|
|
|
|
|
mixer.update(effectDelta);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fade multiplier for the last 20% of lifetime.
|
|
|
|
|
|
const fadeAlpha = t > 0.8 ? 1 - (t - 0.8) / 0.2 : 1;
|
|
|
|
|
|
|
|
|
|
|
|
// Drive vis opacity animation.
|
|
|
|
|
|
for (const { mesh, keyframes, duration, cyclic } of visNodes) {
|
|
|
|
|
|
const mat = mesh.material;
|
|
|
|
|
|
if (!mat || Array.isArray(mat)) continue;
|
|
|
|
|
|
const rawT = elapsedSec / duration;
|
|
|
|
|
|
const vt = cyclic ? rawT % 1 : Math.min(rawT, 1);
|
|
|
|
|
|
const n = keyframes.length;
|
|
|
|
|
|
const pos = vt * n;
|
|
|
|
|
|
const lo = Math.floor(pos) % n;
|
|
|
|
|
|
const hi = (lo + 1) % n;
|
|
|
|
|
|
const frac = pos - Math.floor(pos);
|
|
|
|
|
|
const visOpacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
|
|
|
|
|
|
mat.opacity = visOpacity * fadeAlpha;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Also fade non-vis materials.
|
|
|
|
|
|
if (fadeAlpha < 1) {
|
|
|
|
|
|
for (const mat of materials) {
|
|
|
|
|
|
if ("opacity" in mat) {
|
|
|
|
|
|
mat.transparent = true;
|
|
|
|
|
|
(mat as any).opacity *= fadeAlpha;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Advance IFL texture atlases.
|
|
|
|
|
|
for (const { atlas, info } of iflAtlasesRef.current) {
|
|
|
|
|
|
let iflTime: number;
|
|
|
|
|
|
if (info.sequenceName && info.duration) {
|
|
|
|
|
|
const pos = info.cyclic
|
|
|
|
|
|
? (elapsedSec / info.duration) % 1
|
|
|
|
|
|
: Math.min(elapsedSec / info.duration, 1);
|
|
|
|
|
|
iflTime = pos * info.duration + (info.toolBegin ?? 0);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
iflTime = elapsedSec;
|
|
|
|
|
|
}
|
|
|
|
|
|
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, iflTime));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Size keyframe interpolation.
|
|
|
|
|
|
if (sizeKeyframes) {
|
|
|
|
|
|
const size = interpolateSize(sizeKeyframes, t);
|
|
|
|
|
|
group.scale.set(
|
|
|
|
|
|
size[0] * baseScale[0],
|
|
|
|
|
|
size[1] * baseScale[1],
|
|
|
|
|
|
size[2] * baseScale[2],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// faceViewer: billboard toward camera with random Z rotation.
|
|
|
|
|
|
if (faceViewer) {
|
|
|
|
|
|
group.lookAt(state.camera.position);
|
|
|
|
|
|
group.rotateZ(randAngleRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<group ref={groupRef}>
|
|
|
|
|
|
{/* Flip 180° around Y so the face (GLB +Z normal) points toward the
|
|
|
|
|
|
camera after the parent group's lookAt (which aims -Z at camera). */}
|
|
|
|
|
|
<group rotation={[0, Math.PI, 0]}>
|
|
|
|
|
|
<primitive object={scene} />
|
|
|
|
|
|
</group>
|
|
|
|
|
|
</group>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|