fix additive animations

This commit is contained in:
Brian Beck 2026-03-25 22:55:16 -06:00
parent bb654b3667
commit 814b0af9c6
205 changed files with 279 additions and 73 deletions

View file

@ -34,6 +34,10 @@
transition: opacity 0.2s;
}
.Button:not(:has(.ButtonLabel)) svg {
opacity: 1;
}
.Button:disabled {
opacity: 0.6;
box-shadow: inset 0 0 12px 1px rgba(54, 54, 54, 0.5);
@ -48,11 +52,13 @@
border-right: 1px solid rgba(200, 200, 200, 0.3);
border-bottom: 1px solid rgba(200, 200, 200, 0.3);
background: rgba(3, 82, 147, 0.6);
color: rgba(255, 255, 255, 0.9);
}
.Button:not(:disabled):hover {
background: rgba(0, 98, 179, 0.8);
border-color: rgba(255, 255, 255, 0.4);
color: #fff;
}
.Button svg {

View file

@ -40,7 +40,10 @@ export function CopyCoordinatesButton({
if (!camera) return;
const hash = encodeViewHash(camera);
const params = new URLSearchParams();
params.set("mission", `${missionName}~${missionType}`);
const missionString = missionType
? `${missionName}~${missionType}`
: missionName;
params.set("mission", missionString);
params.set("fog", fogEnabled.toString());
const fullPath = `${window.location.pathname}?${params}${hash}`;
const fullUrl = `${window.location.origin}${fullPath}`;

View file

@ -23,11 +23,6 @@
justify-content: center;
padding: 0;
font-size: 12px;
/* border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
cursor: pointer; */
}
@media (pointer: coarse) {

View file

@ -9,6 +9,7 @@ import {
MeshBasicMaterial,
MeshLambertMaterial,
AdditiveBlending,
AdditiveAnimationBlendMode,
AnimationMixer,
AnimationClip,
LoopOnce,
@ -46,8 +47,25 @@ import {
} from "../stream/playbackUtils";
import type { ThreadState as StreamThreadState } from "../stream/types";
/** Shape entity data readable in useFrame for streaming mode. */
interface StreamShapeEntity {
threads?: StreamThreadState[];
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
steeringYaw?: number;
frozen?: boolean;
maxSteeringAngle?: number;
}
const log = createLogger("GenericShape");
/** WheeledVehicle per-wheel animation state (position-controlled, not threaded). */
interface WheelAnimState {
wheelAction?: AnimationAction;
springAction?: AnimationAction;
turnAction?: AnimationAction;
rotation: number;
}
/** Returns pausable time in seconds for demo mode, real time otherwise. */
function shapeNowSec(): number {
const { recording } = engineStore.getState().playback;
@ -461,8 +479,8 @@ export const ShapeRenderer = memo(function ShapeRenderer({
children,
}: {
loadingColor?: string;
/** Stable entity reference whose `.threads` field is mutated in-place. */
streamEntity?: { threads?: StreamThreadState[] };
/** Stable entity reference whose fields are mutated in-place. */
streamEntity?: StreamShapeEntity;
children?: React.ReactNode;
}) {
const { object, shapeName } = useShapeInfo();
@ -514,8 +532,8 @@ export const ShapeModel = memo(function ShapeModel({
streamEntity,
}: {
gltf: ReturnType<typeof useStaticShape>;
/** Stable entity reference whose `.threads` field is mutated in-place. */
streamEntity?: { threads?: StreamThreadState[] };
/** Stable entity reference whose fields are mutated in-place. */
streamEntity?: StreamShapeEntity;
}) {
const { object, shapeName } = useShapeInfo();
const { debugMode } = useDebug();
@ -656,6 +674,8 @@ export const ShapeModel = memo(function ShapeModel({
const animationEnabledRef = useRef(animationEnabled);
animationEnabledRef.current = animationEnabled;
const wheelAnimsRef = useRef<WheelAnimState[] | null>(null);
// 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.
@ -698,24 +718,28 @@ export const ShapeModel = memo(function ShapeModel({
}
}, [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>();
// DTS sequence flags by name, parsed from glTF extras.
const { seqCyclicByName, seqBlendByName } = useMemo(() => {
const cycMap = new Map<string, boolean>();
const blendMap = 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") {
const rawBlend = gltf.scene.userData?.dts_sequence_blend;
if (typeof rawNames === "string") {
try {
const names: string[] = JSON.parse(rawNames);
const cyclic: boolean[] = JSON.parse(rawCyclic);
const cyclic: boolean[] = typeof rawCyclic === "string" ? JSON.parse(rawCyclic) : [];
const blend: boolean[] = typeof rawBlend === "string" ? JSON.parse(rawBlend) : [];
for (let i = 0; i < names.length; i++) {
map.set(names[i].toLowerCase(), cyclic[i] ?? true);
const lower = names[i].toLowerCase();
cycMap.set(lower, cyclic[i] ?? true);
if (blend[i]) blendMap.set(lower, true);
}
} catch {
/* expected */
}
}
return map;
return { seqCyclicByName: cycMap, seqBlendByName: blendMap };
}, [gltf]);
// Animation setup.
@ -768,6 +792,12 @@ export const ShapeModel = memo(function ShapeModel({
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
}
// Blend sequences (DTS flag 0x8) are delta transforms multiplied
// onto the existing pose. Use Three.js additive blending so they
// composite on top of non-blend threads (e.g. Deploy on Ambient).
if (seqBlendByName.has(seqLower)) {
action.blendMode = AdditiveAnimationBlendMode;
}
action.reset().play();
thread.action = action;
}
@ -857,11 +887,57 @@ export const ShapeModel = memo(function ShapeModel({
}
}
// Set up WheeledVehicle wheel/spring/turn animations.
// These are position-controlled (setPos) not thread-controlled.
// Detect by checking if wheel0/spring0 clips exist.
if (mixer && clipsByName.has("wheel0")) {
const wheelAnims: WheelAnimState[] = [];
for (let i = 0; i < 6; i++) {
const state: WheelAnimState = { rotation: 0 };
const wheelClip = clipsByName.get(`wheel${i}`);
if (wheelClip) {
const action = mixer.clipAction(wheelClip);
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
action.paused = true;
action.play();
state.wheelAction = action;
}
const springClip = clipsByName.get(`spring${i}`);
if (springClip) {
const action = mixer.clipAction(springClip);
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
action.paused = true;
action.play();
// Rest position: springs at full extension (pos=0 in Torque).
action.time = 0;
state.springAction = action;
}
const turnClip = clipsByName.get(`turn${i}`);
if (turnClip) {
const action = mixer.clipAction(turnClip);
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
action.paused = true;
action.play();
// Center (straight ahead).
action.time = turnClip.duration * 0.5;
state.turnAction = action;
}
wheelAnims.push(state);
}
wheelAnimsRef.current = wheelAnims;
} else {
wheelAnimsRef.current = null;
}
return () => {
unsubs.forEach((fn) => fn());
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
prevDemoThreadsRef.current = undefined;
wheelAnimsRef.current = null;
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}, [
@ -869,6 +945,7 @@ export const ShapeModel = memo(function ShapeModel({
clipsByName,
visNodesBySequence,
seqCyclicByName,
seqBlendByName,
object,
runtime,
]);
@ -963,6 +1040,19 @@ export const ShapeModel = memo(function ShapeModel({
if (!seqName) continue;
if (t.state === 0) {
playThread(slot, seqName);
// If the animation is already finished (atEnd=true on first
// appearance — e.g. a deployed MPB entering scope), snap to
// the end pose immediately instead of replaying it.
if (t.atEnd) {
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;
}
}
} else {
stopThread(slot);
}
@ -1005,6 +1095,46 @@ export const ShapeModel = memo(function ShapeModel({
}
}
// Drive WheeledVehicle wheel/spring/turn animations from ghost state.
const wheelAnims = wheelAnimsRef.current;
if (wheelAnims && animationEnabled) {
const entity = streamEntityRef.current;
const wheels = entity?.wheels;
const steeringYaw = entity?.steeringYaw ?? 0;
// From VehicleData datablock (e.g. MPB = 0.3 rad).
const maxSteeringAngle = entity?.maxSteeringAngle ?? 0.3;
for (let i = 0; i < wheelAnims.length; i++) {
const wa = wheelAnims[i];
const wheel = wheels?.[i];
// Wheel rotation: accumulate from speed, matching Torque's
// advanceTime: rotation += wheelSpeed * dt * TWO_PI, then
// wrap to [0,1) and flip negative to 1-rotation.
if (wa.wheelAction && wheel) {
wa.rotation += wheel.speed * effectDelta * Math.PI * 2;
wa.rotation -= Math.floor(wa.rotation); // wrap to [0,1)
wa.wheelAction.time =
wa.rotation * wa.wheelAction.getClip().duration;
}
// Spring: ghost vehicles stay at rest (fully extended = pos 0).
// The server already accounts for spring height in the ghost position.
// (Spring animation would only change with client-side raycasts.)
// Turn: steering angle → animation position.
// Torque: pos = 0.5 - t * 0.5 where t = steerAngle² / maxSteeringAngle
if (wa.turnAction) {
const t =
(steeringYaw * Math.abs(steeringYaw)) / maxSteeringAngle;
const pos = 0.5 - t * 0.5;
wa.turnAction.time =
Math.max(0, Math.min(1, pos)) *
wa.turnAction.getClip().duration;
}
}
}
// Advance IFL texture atlases.
// Matches Torque's animateIfls():
// time = th->pos * th->sequence->duration + th->sequence->toolBegin

View file

@ -213,13 +213,15 @@
}
.CloseButton {
flex: 0 0 auto;
align-self: stretch;
position: relative;
z-index: 101;
display: grid;
place-content: center;
background: transparent;
margin: 0 0 0 -8px;
padding: 6px;
padding: 6px 12px 6px 8px;
border: 0;
font-size: 24px;
color: rgba(255, 255, 255, 0.5);

View file

@ -1,13 +1,14 @@
.Button {
flex: 0 0 auto;
align-self: stretch;
display: grid;
place-content: center;
min-width: 30px;
min-height: 30px;
padding: 2px;
margin: 0 0 0 8px;
padding: 2px 4px 2px 8px;
margin: 0 0 0 2px;
font-size: 24px;
border-radius: 4px;
border-radius: 0;
background: transparent;
color: #fff;
border: 0;

View file

@ -132,6 +132,14 @@ export interface ShapeEntity extends PositionedBase {
targetRenderFlags?: number;
iffColor?: { r: number; g: number; b: number };
weaponShape?: string;
/** WheeledVehicle per-wheel state (speed, slip). */
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
/** Vehicle steering angle (radians). */
steeringYaw?: number;
/** Vehicle frozen state (deployed). */
frozen?: boolean;
/** Vehicle max steering angle (radians), from datablock. */
maxSteeringAngle?: number;
}
export interface PlayerEntity extends PositionedBase {

View file

@ -126,6 +126,14 @@ export interface MutableEntity {
audioMinLoopGap?: number;
audioMaxLoopGap?: number;
sceneData?: SceneObject;
/** WheeledVehicle per-wheel state from ghost data. */
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
/** Vehicle steering angle (radians), from ghost data. */
steeringYaw?: number;
/** Vehicle frozen state (deployed MPB, etc.). */
frozen?: boolean;
/** Vehicle max steering angle (radians), from VehicleData datablock. */
maxSteeringAngle?: number;
/** Force field visual data extracted from ForceFieldBareData datablock. */
forceFieldData?: {
textures: string[];
@ -961,6 +969,14 @@ export abstract class StreamEngine implements StreamingPlayback {
}
}
// Vehicle maxSteeringAngle from VehicleData datablock.
if (
entity.className === "WheeledVehicle" &&
typeof blockData?.maxSteeringAngle === "number"
) {
entity.maxSteeringAngle = blockData.maxSteeringAngle;
}
// Force field visual data from ForceFieldBareData datablock.
if (entity.className === "ForceFieldBare" && blockData) {
const color1 = blockData.color1 as
@ -993,6 +1009,35 @@ export abstract class StreamEngine implements StreamingPlayback {
}
}
// WheeledVehicle per-wheel state. Mutate in-place to avoid allocation
// on every ghost update (~32Hz).
if (Array.isArray(data.wheels)) {
const incoming = data.wheels as Array<{
avel: number;
Dy: number;
Dx: number;
}>;
if (!entity.wheels || entity.wheels.length !== incoming.length) {
entity.wheels = incoming.map((w) => ({
speed: w.avel,
lateralSlip: w.Dx,
longitudinalSlip: w.Dy,
}));
} else {
for (let i = 0; i < incoming.length; i++) {
entity.wheels[i].speed = incoming[i].avel;
entity.wheels[i].lateralSlip = incoming[i].Dx;
entity.wheels[i].longitudinalSlip = incoming[i].Dy;
}
}
}
if (typeof data.steeringYaw === "number") {
entity.steeringYaw = data.steeringYaw;
}
if (typeof data.frozen === "boolean") {
entity.frozen = data.frozen;
}
// Weapon images (Player)
if (entity.type === "Player") {
const images = data.images as
@ -2253,6 +2298,10 @@ export abstract class StreamEngine implements StreamingPlayback {
audioMaxDistance: entity.audioMaxDistance,
audioMinLoopGap: entity.audioMinLoopGap,
audioMaxLoopGap: entity.audioMaxLoopGap,
wheels: entity.wheels,
steeringYaw: entity.steeringYaw,
frozen: entity.frozen,
maxSteeringAngle: entity.maxSteeringAngle,
sceneData: entity.sceneData,
forceFieldData: entity.forceFieldData,
});

View file

@ -223,5 +223,9 @@ export function streamEntityToGameEntity(
threads: entity.threads,
targetRenderFlags: entity.targetRenderFlags,
iffColor: entity.iffColor,
wheels: entity.wheels,
steeringYaw: entity.steeringYaw,
frozen: entity.frozen,
maxSteeringAngle: entity.maxSteeringAngle,
} satisfies ShapeEntity;
}

View file

@ -153,6 +153,14 @@ export interface StreamEntity {
audioMaxDistance?: number;
audioMinLoopGap?: number;
audioMaxLoopGap?: number;
/** WheeledVehicle per-wheel state. */
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
/** Vehicle steering angle (radians). */
steeringYaw?: number;
/** Vehicle frozen state (deployed). */
frozen?: boolean;
/** Vehicle max steering angle (radians). */
maxSteeringAngle?: number;
/** Scene infrastructure data (terrain, interior, sky, etc.). */
sceneData?: SceneObject;
/** Force field visual data from ForceFieldBareData datablock. */