add env maps

This commit is contained in:
Brian Beck 2026-03-30 20:01:31 -07:00
parent 9a95c04c68
commit 7c0e39d57c
491 changed files with 14447 additions and 1552 deletions

View file

@ -1,38 +1,61 @@
/**
* Color parsing utilities for Tribes 2 mission files.
* Color parsing utilities for Tribes 2 mission/scene files.
*
* Torque (2001) worked in gamma/sRGB space - colors in mission files are
* Torque (2001) worked in gamma/sRGB space colors in mission files are
* specified as they should appear on screen. Three.js expects linear colors
* for lighting calculations, so convert with .convertSRGBToLinear() when
* passing to lit materials.
* for lighting calculations, so convert with SRGBColorSpace or
* .convertSRGBToLinear() when passing to lit materials.
*/
import { Color, SRGBColorSpace } from "three";
import type { Color3, Color4 } from "./scene/types";
/**
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
* The values are interpreted as sRGB and stored as linear internally by Three.js.
*
* @param colorString - Space-separated "R G B" or "R G B A" string (0-1 range)
* @returns Color (linear internally), or undefined if no string
*/
/** Parse a Torque color string ("R G B" or "R G B A", values 01) as sRGB. */
export function parseColor(colorString: string | undefined): Color | undefined {
if (!colorString) return undefined;
const parts = colorString.split(" ").map((s) => parseFloat(s));
const [r = 0, g = 0, b = 0] = parts;
// Interpret as sRGB, Three.js converts to linear internally
return new Color().setRGB(r, g, b, SRGBColorSpace);
}
/**
* Parse a Tribes 2 color string and convert to linear color space.
* Use this when passing colors to Three.js lit materials.
*
* @param colorString - Space-separated "R G B" or "R G B A" string (0-1 range)
* @returns Color in linear space, or undefined if no string
*/
/** Parse a Torque color string and convert to linear color space. */
export function parseColorLinear(
colorString: string | undefined,
): Color | undefined {
const color = parseColor(colorString);
return color?.convertSRGBToLinear();
}
/** Parse a Torque color string to a plain {r, g, b} object (raw sRGB). */
export function parseColor3(
s: string | undefined,
fallback: Color3 = { r: 0, g: 0, b: 0 },
): Color3 {
if (!s) return fallback;
const parts = s.split(" ").map(Number);
return {
r: parts[0] ?? fallback.r,
g: parts[1] ?? fallback.g,
b: parts[2] ?? fallback.b,
};
}
/** Parse a Torque color string to a plain {r, g, b, a} object (raw sRGB). */
export function parseColor4(
s: string | undefined,
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
): Color4 {
if (!s) return fallback;
const parts = s.split(" ").map(Number);
return {
r: parts[0] ?? fallback.r,
g: parts[1] ?? fallback.g,
b: parts[2] ?? fallback.b,
a: parts[3] ?? fallback.a,
};
}
/** Parse a Torque color string to a [r, g, b] tuple (raw sRGB). */
export function parseColorTuple(s: string): [number, number, number] {
const parts = s.split(" ").map((v) => parseFloat(v));
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}

View file

@ -12,7 +12,7 @@
padding: 6px 8px;
color: #fff;
font-family: inherit;
font-size: 12px;
font-size: 11px;
font-weight: normal;
text-align: left;
border: 0;

View file

@ -1,4 +1,4 @@
import { useRecording } from "./RecordingProvider";
import { useRecording } from "./usePlayback";
import { useInputMode } from "./InputContext";
import { useCameraTour } from "../state/cameraTourStore";
import { InputBindings } from "./InputBindings";

View file

@ -26,6 +26,22 @@ export const audioBufferCache = new Map<string, AudioBuffer>();
// intrinsic pitch (1.0 for normal, or the voice pitch multiplier for chat).
const _activeSounds = new Map<Audio<GainNode | PannerNode>, number>();
/** Whether to adjust audio pitch to match playback speed. When false, sounds
* play at their original pitch regardless of fast-forward/slow-motion. */
let _adjustAudioSpeed = true;
export function setAdjustAudioSpeedFlag(value: boolean): void {
_adjustAudioSpeed = value;
// Re-apply rate to all active sounds immediately.
const rate = engineStore.getState().playback.rate;
for (const [sound, basePitch] of _activeSounds) {
try {
sound.setPlaybackRate(basePitch * (_adjustAudioSpeed ? rate : 1));
} catch {
/* disposed */
}
}
}
/** Register a sound for automatic playback rate tracking during streaming. */
export function trackSound(
sound: Audio<GainNode | PannerNode>,
@ -50,6 +66,12 @@ export function getSoundGeneration(): number {
return _soundGeneration;
}
/** Get the effective playback rate for a sound, respecting the adjustAudioSpeed setting. */
export function getEffectiveSoundRate(basePitch = 1): number {
const rate = engineStore.getState().playback.rate;
return basePitch * (_adjustAudioSpeed ? rate : 1);
}
/** Stop and unregister all tracked sounds. Called on recording change. */
export function stopAllTrackedSounds(): void {
_soundGeneration++;
@ -73,7 +95,7 @@ engineStore.subscribe(
(rate) => {
for (const [sound, basePitch] of _activeSounds) {
try {
sound.setPlaybackRate(basePitch * rate);
sound.setPlaybackRate(basePitch * (_adjustAudioSpeed ? rate : 1));
} catch {
// Sound may have been disposed.
}
@ -151,7 +173,7 @@ export function playOneShotSound(
sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(resolved.volume);
sound.setPlaybackRate(rate);
sound.setPlaybackRate(_adjustAudioSpeed ? rate : 1);
if (position) {
sound.position.copy(position);
}
@ -171,7 +193,7 @@ export function playOneShotSound(
const sound = new Audio(audioListener);
sound.setBuffer(buffer);
sound.setVolume(resolved.volume);
sound.setPlaybackRate(rate);
sound.setPlaybackRate(_adjustAudioSpeed ? rate : 1);
_activeSounds.set(sound, 1);
sound.play();
sound.source!.onended = () => {

View file

@ -342,8 +342,7 @@ export function CameraTourConsumer() {
useInputAction("nextStop", () => {
const animation = cameraTourStore.getState().animation;
if (!animation) return;
const isLastTarget =
animation.currentIndex >= animation.targets.length - 1;
const isLastTarget = animation.currentIndex >= animation.targets.length - 1;
if (isLastTarget) {
cameraTourStore.getState().cancel();
} else {

View file

@ -4,6 +4,7 @@ import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
import {
getCachedAudioBuffer,
getEffectiveSoundRate,
getSoundGeneration,
trackSound,
untrackSound,
@ -76,7 +77,7 @@ export function ChatSoundPlayer() {
}
const sound = new Audio(audioListener);
sound.setBuffer(buffer);
sound.setPlaybackRate(pitch * rate);
sound.setPlaybackRate(getEffectiveSoundRate(pitch));
trackSound(sound, pitch);
if (sender) {
activeBySender.set(sender, sound);

View file

@ -1,8 +1,53 @@
import { Stats, Html } from "@react-three/drei";
import { useEffect, useRef } from "react";
import { AxesHelper } from "three";
import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import {
AxesHelper,
Color,
MeshLambertMaterial,
SphereGeometry,
Vector3,
} from "three";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting, injectShapeEnvMap } from "../shapeMaterial";
import styles from "./DebugElements.module.css";
const debugSphereGeo = new SphereGeometry(10, 64, 64);
/**
* A chrome sphere that visualizes the env map. Placed 30 units in front of
* the camera so you can always see it.
*/
function DebugEnvMapSphere() {
const meshRef = useRef<THREE.Mesh>(null);
const material = useMemo(() => {
const mat = new MeshLambertMaterial({
color: new Color(0.5, 0.5, 0.5),
});
mat.customProgramCacheKey = () => "debug-envmap-sphere";
mat.onBeforeCompile = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
injectShapeEnvMap(shader, 1.0);
};
return mat;
}, []);
const _dir = useMemo(() => new Vector3(), []);
// Follow the camera: position 80 units in front.
useFrame(({ camera }) => {
const mesh = meshRef.current;
if (!mesh) return;
camera.getWorldDirection(_dir);
mesh.position.copy(camera.position).addScaledVector(_dir, 80);
});
return <mesh ref={meshRef} geometry={debugSphereGeo} material={material} />;
}
export function DebugElements() {
const axesRef = useRef<AxesHelper>(null);
@ -40,6 +85,7 @@ export function DebugElements() {
X
</span>
</Html>
<DebugEnvMapSphere />
</>
);
}

View file

@ -9,7 +9,7 @@ import {
useRecording,
useSpeed,
SPEED_OPTIONS,
} from "./RecordingProvider";
} from "./usePlayback";
// import {
// streamPlaybackStore,
// type DemoCameraMode,
@ -143,7 +143,7 @@ export function DemoPlaybackControls() {
>
{SPEED_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}x
{s}&times;
</option>
))}
</select>

View file

@ -6,7 +6,7 @@ import type {
TimelineEvent,
TimelineEventType,
} from "../state/demoTimelineStore";
import { usePlaybackActions } from "./RecordingProvider";
import { usePlaybackActions } from "./usePlayback";
import { BsPlayFill } from "react-icons/bs";
import { AiFillStop } from "react-icons/ai";
import { LuCrosshair } from "react-icons/lu";

View file

@ -163,6 +163,7 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
<ShapeRenderer
loadingColor={loadingColor}
streamEntity={torqueObject ? undefined : entity}
emap={entity.emap}
>
{flagLabel ? (
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>

View file

@ -1,21 +1,15 @@
import { memo, useCallback, useRef, useState, useMemo } from "react";
import { memo, useCallback, useRef, useState, useMemo, Suspense } from "react";
import { Quaternion } from "three";
import type { Group } from "three";
import { useFrame } from "@react-three/fiber";
import { useAllGameEntities } from "../state/gameEntityStore";
import type {
GameEntity,
PositionedEntity,
PlayerEntity,
} from "../state/gameEntityTypes";
import type { GameEntity, PositionedEntity } from "../state/gameEntityTypes";
import { isSceneEntity } from "../state/gameEntityTypes";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { EntityRenderer } from "./EntityRenderer";
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { PlayerNameplate } from "./PlayerNameplate";
import { FlagMarker } from "./FlagMarker";
import { entityTypeColor } from "../stream/playbackUtils";
import { useEngineSelector } from "../state/engineStore";
/**
* The ONE rendering component tree for all game entities.
@ -94,18 +88,6 @@ const EntityWrapper = memo(function EntityWrapper({
return <PositionedEntityWrapper entity={entity} />;
});
/** Renders the player nameplate, subscribing to controlPlayerGhostId
* internally so that PositionedEntityWrapper doesn't need to. Keeps
* engine store mutations from triggering synchronous selector evaluations
* on every positioned entity. */
function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
const controlPlayerGhostId = useEngineSelector(
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
);
if (entity.id === controlPlayerGhostId) return null;
return <PlayerNameplate entity={entity} />;
}
/** Imperatively tracks targetRenderFlags bit 0x2 on a game entity and
* mounts/unmounts FlagMarker when the flag state changes. Entity field
* mutations don't trigger React re-renders (ID-only equality), so this
@ -145,8 +127,6 @@ function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
return new Quaternion(...entity.rotation);
}, [entity.rotation]);
const isPlayer = entity.renderType === "Player";
// Entities without a resolved shape get a wireframe placeholder.
if (entity.renderType === "Shape" && !entity.shapeName) {
return (
@ -190,9 +170,6 @@ function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
<ShapeErrorBoundary fallback={fallback}>
<EntityRenderer entity={entity} />
</ShapeErrorBoundary>
{isPlayer && (
<PlayerNameplateIfVisible entity={entity as PlayerEntity} />
)}
<FlagMarkerSlot entity={entity} />
</group>
</group>

View file

@ -17,6 +17,7 @@ import {
} from "react";
import { useFrame } from "@react-three/fiber";
import { Color } from "three";
import { parseColorLinear } from "../colorUtils";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getProperty } from "../mission";
import type { SceneSky } from "../scene/types";
@ -84,15 +85,7 @@ const FogUniformsContext =
*
* Torque (2001) worked in gamma space - colors were specified as they should
* appear on screen. Three.js expects linear colors (it converts to sRGB on output).
* We convert sRGB->linear so the final output matches the intended appearance.
*/
function parseColor(colorString: string | undefined): Color {
if (!colorString) return new Color(0.5, 0.5, 0.5);
const parts = colorString.split(" ").map((s) => parseFloat(s));
const [r, g, b] = parts;
// Convert from sRGB (how Torque specified colors) to linear (what Three.js expects)
return new Color().setRGB(r, g, b).convertSRGBToLinear();
}
/**
* Parse a fog volume property string.
@ -148,7 +141,9 @@ export function parseFogState(
? highVisibleDistance
: visibleDistanceBase;
const fogColor = parseColor(getProperty(object, "fogColor"));
const fogColor =
parseColorLinear(getProperty(object, "fogColor")) ??
new Color(0.5, 0.5, 0.5);
// Parse fog volumes (up to 3)
// Note: fogVolumeColor is intentionally not parsed - see parseFogVolume comment

View file

@ -1,7 +1,7 @@
import { lazy, memo, Suspense } from "react";
import { type RootState } from "@react-three/fiber";
import { useDataSource } from "../state/gameEntityStore";
import { useRecording } from "./RecordingProvider";
import { useRecording } from "./usePlayback";
import { AudioProvider } from "./AudioContext";
import { CamerasProvider } from "./CamerasProvider";
import { InputProducer } from "./InputProducer";

View file

@ -1,5 +1,6 @@
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { ErrorBoundary } from "react-error-boundary";
import type { AnimationAction, Material } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { createLogger } from "../logger";
@ -12,13 +13,13 @@ import {
AdditiveAnimationBlendMode,
AnimationMixer,
AnimationClip,
AnimationUtils,
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";
@ -39,18 +40,25 @@ import {
import type { IflAtlas } from "./useIflTexture";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting } from "../shapeMaterial";
import { injectShapeLighting, injectShapeEnvMap } from "../shapeMaterial";
import {
processShapeScene,
replaceWithShapeMaterial,
disposeClonedScene,
buildRestPoseClip,
} 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 }>;
damageState?: number;
emap?: boolean;
wheels?: Array<{
speed: number;
lateralSlip: number;
longitudinalSlip: number;
}>;
steeringYaw?: number;
frozen?: boolean;
maxSteeringAngle?: number;
@ -115,14 +123,27 @@ const basicBeforeCompile: Material["onBeforeCompile"] = (shader) => {
/**
* Helper to apply volumetric fog and lighting multipliers to a material.
* When envMapOptions is provided, also injects Tribes 2 env map reflectivity.
*/
export function applyShapeShaderModifications(
mat: MeshBasicMaterial | MeshLambertMaterial,
envMapOptions?: { reflectionAmount: number },
): void {
mat.onBeforeCompile =
mat instanceof MeshLambertMaterial
? lambertBeforeCompile
: basicBeforeCompile;
if (!envMapOptions) {
mat.onBeforeCompile =
mat instanceof MeshLambertMaterial
? lambertBeforeCompile
: basicBeforeCompile;
return;
}
const matType = mat instanceof MeshLambertMaterial ? "lambert" : "basic";
mat.customProgramCacheKey = () => `shape-envmap-${matType}`;
const { reflectionAmount } = envMapOptions;
mat.onBeforeCompile = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
injectShapeEnvMap(shader, reflectionAmount);
};
}
export function createMaterialFromFlags(
@ -132,6 +153,7 @@ export function createMaterialFromFlags(
isOrganic: boolean,
vis: number = 1,
animated: boolean = false,
reflectionAmount: number = 0,
): MaterialResult {
const isTranslucent = flagNames.has("Translucent");
const isAdditive = flagNames.has("Additive");
@ -141,6 +163,13 @@ export function createMaterialFromFlags(
// Animated vis also needs transparent materials so opacity can be updated per frame.
const isFaded = vis < 1 || animated;
// Env map reflectivity: gated only by NeverEnvMap flag and reflectionAmount
// (which is 0 when the datablock doesn't have emap=true). Independent of
// SelfIlluminating/Additive — those affect lighting, not env mapping.
const enableEnvMap =
!flagNames.has("NeverEnvMap") && reflectionAmount > 0 && !isFaded;
const envMapOptions = enableEnvMap ? { reflectionAmount } : undefined;
// 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.
@ -156,7 +185,7 @@ export function createMaterialFromFlags(
...(isFaded && { opacity: vis }),
...(isAdditive && { blending: AdditiveBlending }),
});
applyShapeShaderModifications(mat);
applyShapeShaderModifications(mat, envMapOptions);
return mat;
}
@ -186,13 +215,12 @@ export function createMaterialFromFlags(
...baseProps,
side: 0, // FrontSide
});
applyShapeShaderModifications(backMat);
applyShapeShaderModifications(frontMat);
applyShapeShaderModifications(backMat, envMapOptions);
applyShapeShaderModifications(frontMat, envMapOptions);
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
@ -203,7 +231,7 @@ export function createMaterialFromFlags(
depthWrite: false,
}),
});
applyShapeShaderModifications(mat);
applyShapeShaderModifications(mat, envMapOptions);
return mat;
}
@ -476,11 +504,14 @@ export function DebugPlaceholder({
export const ShapeRenderer = memo(function ShapeRenderer({
loadingColor = "yellow",
streamEntity,
emap,
children,
}: {
loadingColor?: string;
/** Stable entity reference whose fields are mutated in-place. */
streamEntity?: StreamShapeEntity;
/** Datablock enables environment map reflections. */
emap?: boolean;
children?: React.ReactNode;
}) {
const { object, shapeName } = useShapeInfo();
@ -496,9 +527,12 @@ export const ShapeRenderer = memo(function ShapeRenderer({
fallback={
<DebugPlaceholder color="red" label={`${object?._id}: ${shapeName}`} />
}
onError={(error) => {
log.error("Shape error: %s: %o", shapeName, error);
}}
>
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
<ShapeModelLoader streamEntity={streamEntity} />
<ShapeModelLoader streamEntity={streamEntity} emap={emap} />
{children}
</Suspense>
</ErrorBoundary>
@ -530,10 +564,13 @@ interface ThreadState {
export const ShapeModel = memo(function ShapeModel({
gltf,
streamEntity,
emap,
}: {
gltf: ReturnType<typeof useStaticShape>;
/** Stable entity reference whose fields are mutated in-place. */
streamEntity?: StreamShapeEntity;
/** Datablock enables environment map reflections. */
emap?: boolean;
}) {
const { object, shapeName } = useShapeInfo();
const { debugMode } = useDebug();
@ -588,7 +625,10 @@ export const ShapeModel = memo(function ShapeModel({
}
});
processShapeScene(scene, shapeName ?? undefined, { anisotropy });
processShapeScene(scene, shapeName ?? undefined, {
anisotropy,
emap: emap ?? streamEntity?.emap,
});
// 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
@ -646,7 +686,7 @@ export const ShapeModel = memo(function ShapeModel({
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
};
}, [gltf, anisotropy]);
}, [gltf, anisotropy, emap]);
// Dispose cloned geometries and materials when the scene is replaced or
// the component unmounts, to prevent GPU memory from accumulating.
@ -658,6 +698,7 @@ export const ShapeModel = memo(function ShapeModel({
}, [clonedScene, mixer]);
const threadsRef = useRef(new Map<number, ThreadState>());
const additiveClipsRef = useRef(new Set<string>());
const iflMeshAtlasRef = useRef(new Map<any, IflAtlas>());
interface IflAnimInfo {
@ -728,8 +769,10 @@ export const ShapeModel = memo(function ShapeModel({
if (typeof rawNames === "string") {
try {
const names: string[] = JSON.parse(rawNames);
const cyclic: boolean[] = typeof rawCyclic === "string" ? JSON.parse(rawCyclic) : [];
const blend: boolean[] = typeof rawBlend === "string" ? JSON.parse(rawBlend) : [];
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++) {
const lower = names[i].toLowerCase();
cycMap.set(lower, cyclic[i] ?? true);
@ -752,6 +795,7 @@ export const ShapeModel = memo(function ShapeModel({
// handler reads ghost ThreadMask data and drives everything.
useEffect(() => {
const threads = threadsRef.current;
const additiveClips = additiveClipsRef.current;
const isMissionMode = streamEntityRef.current == null;
function prepareVisNode(v: VisNode) {
@ -792,10 +836,16 @@ 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).
// Blend sequences (DTS flag 0x8) are delta transforms post-multiplied
// onto the existing pose. The GLB stores rest*delta (Blender's glTF
// exporter bakes in the rest pose). Subtracting the rest pose via
// buildRestPoseClip recovers pure deltas for Three.js additive mode.
if (seqBlendByName.has(seqLower)) {
if (!additiveClips.has(seqLower)) {
const restClip = buildRestPoseClip(gltf.scene, clip);
AnimationUtils.makeClipAdditive(clip, 0, restClip, 30);
additiveClips.add(seqLower);
}
action.blendMode = AdditiveAnimationBlendMode;
}
action.reset().play();
@ -834,6 +884,7 @@ export const ShapeModel = memo(function ShapeModel({
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
prevDemoThreadsRef.current = undefined;
additiveClips.clear();
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}
@ -1095,6 +1146,26 @@ export const ShapeModel = memo(function ShapeModel({
}
}
// Drive damage state Visibility — swap normal/HULK meshes.
// In Torque, mHulkThread plays the "Visibility" sequence at pos 0.0
// (Enabled) or 1.0 (Destroyed). The vis_keyframes on each mesh encode:
// normal meshes [1,0] (visible→hidden), HULK meshes [0,1] (hidden→visible).
const entity = streamEntityRef.current;
const damageState = entity?.damageState ?? 0;
const visibilityNodes = visNodesBySequence.get("visibility");
if (visibilityNodes) {
// Position 0.0 = Enabled (normal visible), 1.0 = Destroyed (HULK visible)
const pos = damageState >= 2 ? 1.0 : 0.0;
for (const { mesh, keyframes } of visibilityNodes) {
const mat = mesh.material;
if (!mat || Array.isArray(mat)) continue;
const n = keyframes.length;
const idx = Math.min(Math.floor(pos * n), n - 1);
mat.opacity = keyframes[idx];
mesh.visible = mat.opacity > 0.01;
}
}
// Drive WheeledVehicle wheel/spring/turn animations from ghost state.
const wheelAnims = wheelAnimsRef.current;
if (wheelAnims && animationEnabled) {
@ -1114,8 +1185,7 @@ export const ShapeModel = memo(function ShapeModel({
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;
wa.wheelAction.time = wa.rotation * wa.wheelAction.getClip().duration;
}
// Spring: ghost vehicles stay at rest (fully extended = pos 0).
@ -1125,12 +1195,10 @@ export const ShapeModel = memo(function ShapeModel({
// 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 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;
Math.max(0, Math.min(1, pos)) * wa.turnAction.getClip().duration;
}
}
}
@ -1198,10 +1266,12 @@ export const ShapeModel = memo(function ShapeModel({
function ShapeModelLoader({
streamEntity,
emap,
}: {
streamEntity?: { threads?: StreamThreadState[] };
streamEntity?: StreamShapeEntity;
emap?: boolean;
}) {
const { shapeName } = useShapeInfo();
const gltf = useStaticShape(shapeName);
return <ShapeModel gltf={gltf} streamEntity={streamEntity} />;
return <ShapeModel gltf={gltf} streamEntity={streamEntity} emap={emap} />;
}

View file

@ -17,7 +17,7 @@ import type { ClientMove } from "../../relay/types";
const log = createLogger("InputConsumer");
const MAX_SPEED = 270;
const MAX_SPEED = 200;
const LOCAL_MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
/**

View file

@ -120,9 +120,7 @@ export function eventModifiersMatch(
const wantShift = required?.includes("Shift") ?? false;
const wantAlt = required?.includes("Alt") ?? false;
return (
e.ctrlKey === wantCtrl &&
e.shiftKey === wantShift &&
e.altKey === wantAlt
e.ctrlKey === wantCtrl && e.shiftKey === wantShift && e.altKey === wantAlt
);
}

View file

@ -14,7 +14,7 @@ import { Accordion, AccordionGroup } from "./Accordion";
import { useTouchDevice } from "./useTouchDevice";
import { DemoTimeline } from "./DemoTimeline";
import { MapTourPanel } from "./MapTourPanel";
import { useRecording } from "./RecordingProvider";
import { useRecording } from "./usePlayback";
import { useDataSource, useMissionName } from "../state/gameEntityStore";
import { useLiveSelector } from "../state/liveConnectionStore";
import { hasMission } from "../manifest";
@ -74,6 +74,8 @@ export const InspectorControls = memo(function InspectorControls({
setAudioEnabled,
audioVolume,
setAudioVolume,
adjustAudioSpeed,
setAdjustAudioSpeed,
animationEnabled,
setAnimationEnabled,
fpsLimit,
@ -362,6 +364,22 @@ export const InspectorControls = memo(function InspectorControls({
/>
</div>
</div>
<div className={styles.CheckboxField}>
<input
id="adjustAudioSpeedInput"
type="checkbox"
checked={adjustAudioSpeed}
onChange={(event) => {
setAdjustAudioSpeed(event.target.checked);
}}
/>
<label
className={styles.Label}
htmlFor="adjustAudioSpeedInput"
>
Adjust audio speed to match demo playback
</label>
</div>
</Accordion>
<Accordion value="graphics" label="Graphics">
<div className={styles.CheckboxField}>

View file

@ -262,7 +262,11 @@ export const InteriorInstance = memo(function InteriorInstance({
/>
}
onError={(error) => {
log.error("Failed to load %s: %s", scene.interiorFile, (error as Error).message);
log.error(
"Failed to load %s: %s",
scene.interiorFile,
(error as Error).message,
);
}}
>
<DebugSuspense

View file

@ -10,7 +10,7 @@ import {
useIsPlaying,
useRecording,
useSpeed,
} from "./RecordingProvider";
} from "./usePlayback";
import { useInputMode } from "./InputContext";
import { useCameraTour } from "../state/cameraTourStore";
import {

View file

@ -4,7 +4,7 @@ import { createLogger } from "../logger";
import { cameraTourStore } from "../state/cameraTourStore";
import { demoTimelineStore } from "../state/demoTimelineStore";
import { liveConnectionStore } from "../state/liveConnectionStore";
import { usePlaybackActions, useRecording } from "./RecordingProvider";
import { usePlaybackActions, useRecording } from "./usePlayback";
import styles from "./Button.module.css";
const log = createLogger("LoadDemoButton");

View file

@ -17,10 +17,7 @@ import { InspectorControls } from "@/src/components/InspectorControls";
import { MissionSelect } from "@/src/components/MissionSelect";
import { StreamingMissionInfo } from "@/src/components/StreamingMissionInfo";
import { useSettings } from "@/src/components/SettingsProvider";
import {
RecordingProvider,
useRecording,
} from "@/src/components/RecordingProvider";
import { useRecording } from "@/src/components/usePlayback";
import { useFeatures } from "@/src/components/FeaturesProvider";
import {
liveConnectionStore,
@ -227,7 +224,7 @@ export function MapInspector() {
return (
<main className={styles.Frame}>
<RecordingProvider>
<>
<header className={styles.Toolbar}>
<ToggleSidebarButton
orientation="top"
@ -358,7 +355,7 @@ export function MapInspector() {
</Suspense>
</ViewTransition>
) : null}
</RecordingProvider>
</>
</main>
);
}

View file

@ -51,6 +51,7 @@ import {
untrackSound,
} from "./AudioEmitter";
import { effectNow, engineStore } from "../state/engineStore";
import { getEffectiveSoundRate } from "./AudioEmitter";
// ── Constants ──
@ -1278,7 +1279,7 @@ export function ParticleEffects({
sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(resolved.volume);
sound.setPlaybackRate(playbackState.rate);
sound.setPlaybackRate(getEffectiveSoundRate());
sound.setLoop(true);
sound.position.set(
entity.position![1],

View file

@ -225,7 +225,6 @@ function WeaponHUD() {
);
}
function TeamScores() {
const teamScores = useEngineSelector(
(state) => state.playback.streamSnapshot?.teamScores,
@ -416,8 +415,7 @@ export function PlayerHUD() {
// In free-fly mode the camera is disconnected from the player, so
// player-specific HUD elements (health, energy, weapons, etc.) are hidden.
const showPlayerElements =
hasControlPlayer && cameraMode !== "freeFly";
const showPlayerElements = hasControlPlayer && cameraMode !== "freeFly";
return (
<div className={styles.PlayerHUD}>

View file

@ -13,11 +13,13 @@ import {
PositionalAudio,
Vector3,
} from "three";
import type { AnimationAction, AnimationClip } from "three";
import type { AnimationAction } from "three";
import { AnimationClip } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
ANIM_TRANSITION_TIME,
DEFAULT_EYE_HEIGHT,
buildRestPoseClip,
disposeClonedScene,
getKeyframeAtTime,
getPosedNodeTransform,
@ -40,26 +42,15 @@ import {
trackSound,
untrackSound,
} from "./AudioEmitter";
import { getEffectiveSoundRate } from "./AudioEmitter";
import type { ResolvedAudioProfile } from "./AudioEmitter";
import { audioToUrl } from "../loaders";
import { useSettings } from "./SettingsProvider";
import { useEngineStoreApi, useEngineSelector } from "../state/engineStore";
import { PlayerNameplate } from "./PlayerNameplate";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import type { PlayerEntity } from "../state/gameEntityTypes";
/**
* 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";
}
/** Number of table actions in the engine's ActionAnimationList (Tribes2.exe build 25034). */
const NUM_TABLE_ACTION_ANIMS = 8;
@ -223,19 +214,25 @@ function stopLoopingSound(
*/
export function PlayerModel({ entity }: { entity: PlayerEntity }) {
const engineStore = useEngineStoreApi();
const shapeName = entity.shapeName ?? entity.dataBlock;
const gltf = useStaticShape(shapeName!);
const shapeName = entity.shapeName!;
const gltf = useStaticShape(shapeName);
const shapeAliases = useEngineSelector((state) => {
const sn = shapeName?.toLowerCase();
return sn ? state.runtime.sequenceAliases.get(sn) : undefined;
});
const anisotropy = useAnisotropy();
const controlPlayerGhostId = useEngineSelector(
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
);
// Clone scene preserving skeleton bindings, create mixer, find mount bones.
const { clonedScene, mixer, mount0, mount1, mount2, iflInitializers } =
useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
const iflInits = processShapeScene(scene, undefined, { anisotropy });
const iflInits = processShapeScene(scene, undefined, {
anisotropy,
emap: entity.emap,
});
// Use front-face-only rendering so the camera can see out from inside the
// model in first-person (backface culling hides interior faces).
@ -265,7 +262,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
mount2: m2,
iflInitializers: iflInits,
};
}, [gltf, anisotropy]);
}, [gltf.scene, anisotropy, entity.emap]);
useEffect(() => {
return () => {
@ -381,34 +378,79 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
}
blendActionsRef.current = blendRefs;
// Arm pose blend actions: create one per available arm animation so we
// can switch between them when the equipped weapon changes.
// All arm clips use the lookde midpoint as the additive reference, so
// switching from lookde to lookms captures the shoulder repositioning.
const armActions = new Map<string, AnimationAction>();
const lookdeClip = gltf.animations.find(
(c) => c.name.toLowerCase() === "lookde",
// In Torque, the "root" animation provides arm bone values (R Clavicle,
// R UpperArm, etc.) that persist even when movement anims play — because
// movement anims don't animate arm bones. In Three.js, when root fades
// out, arm bones fall to the rest pose. Fix: extract root's arm-only
// tracks into a permanent action that always plays at weight=1.
const rootClip = gltf.animations.find(
(c) => c.name.toLowerCase() === "root",
);
const fps = 30;
const lookdeRefFrame = lookdeClip
? Math.round((lookdeClip.duration * fps) / 2)
: 0;
for (const armName of ["lookde", "lookms", "looksn"]) {
const clip = gltf.animations.find(
(c) => c.name.toLowerCase() === armName,
);
if (!clip) continue;
const cloned = clip.clone();
// Use lookde's midpoint as reference for all arm clips so that
// lookms/looksn capture the absolute shoulder offset.
const refClip = lookdeClip ?? clip;
AnimationUtils.makeClipAdditive(cloned, lookdeRefFrame, refClip, fps);
const action = mixer.clipAction(cloned);
action.blendMode = AdditiveAnimationBlendMode;
action.timeScale = 0;
action.weight = 0;
action.play();
armActions.set(armName, action);
if (rootClip) {
// Find bones that movement anims DON'T animate — these need root's values.
const movementBones = new Set<string>();
for (const clip of gltf.animations) {
const lower = clip.name.toLowerCase();
if (["forward", "back", "side", "fall"].includes(lower)) {
for (const t of clip.tracks) {
movementBones.add(t.name.slice(0, t.name.lastIndexOf(".")));
}
}
}
const rootArmTracks = rootClip.tracks.filter((t) => {
const bone = t.name.slice(0, t.name.lastIndexOf("."));
return !movementBones.has(bone);
});
if (rootArmTracks.length > 0) {
const rootArmsClip = new AnimationClip(
"root_arms",
rootClip.duration,
rootArmTracks,
);
const rootArmsAction = mixer.clipAction(rootArmsClip);
rootArmsAction.play(); // weight=1, always on
}
}
// Arm pose blend actions (DTS blend sequences). These are applied
// additively on top of root's arm base. Subtracting the rest pose via
// buildRestPoseClip recovers pure deltas from the GLB's rest*delta
// keyframes. Applied onto root's arm values, this matches Torque's
// post-multiply behavior.
//
// Instead of hardcoding arm pose names, iterate ALL blend sequences
// from the GLB's dts_sequence_blend metadata (skipping head/headside
// which are handled separately with their own pitch/yaw scrubbing).
const armActions = new Map<string, AnimationAction>();
const rawSeqNames = gltf.scene.userData?.dts_sequence_names;
const rawSeqBlend = gltf.scene.userData?.dts_sequence_blend;
if (typeof rawSeqNames === "string" && typeof rawSeqBlend === "string") {
try {
const seqNames: string[] = JSON.parse(rawSeqNames);
const seqBlend: boolean[] = JSON.parse(rawSeqBlend);
for (let i = 0; i < seqNames.length; i++) {
if (!seqBlend[i]) continue;
const name = seqNames[i].toLowerCase();
// head/headside are blend sequences but driven by headPitch/headYaw,
// not the arm action index — handled separately above.
if (name === "head" || name === "headside") continue;
const clip = gltf.animations.find(
(c) => c.name.toLowerCase() === name,
);
if (!clip) continue;
const cloned = clip.clone();
const restClip = buildRestPoseClip(gltf.scene, cloned);
AnimationUtils.makeClipAdditive(cloned, 0, restClip, 30);
const action = mixer.clipAction(cloned);
action.blendMode = AdditiveAnimationBlendMode;
action.timeScale = 0;
action.weight = 0;
action.play();
armActions.set(name, action);
}
} catch {
/* malformed metadata */
}
}
armActionsRef.current = armActions;
@ -685,8 +727,14 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
}
}
// Switch arm blend animation based on equipped weapon.
const desiredArm = getArmThread(entity.weaponShape);
// Switch arm blend animation based on the networked arm action index.
// The server resolves the weapon datablock's armThread field to an action
// index and sends it via Player::packUpdate (ActionMask).
const armEntry =
entity.armAction != null
? actionAnimMap.get(entity.armAction)
: undefined;
const desiredArm = armEntry?.clipName ?? "lookde";
if (desiredArm !== activeArmRef.current) {
const armActions = armActionsRef.current;
const prev = activeArmRef.current
@ -751,7 +799,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
try {
sound.setBuffer(jetBufferRef.current);
sound.setLoop(true);
sound.setPlaybackRate(playback.rate);
sound.setPlaybackRate(getEffectiveSoundRate());
sound.play();
trackSound(sound, 1);
} catch {
@ -779,6 +827,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
return (
<>
{entity.id !== controlPlayerGhostId && (
<PlayerNameplate entity={entity} />
)}
<group rotation={[0, Math.PI / 2, 0]}>
<primitive object={clonedScene} />
</group>
@ -812,7 +863,11 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
<ShapePlaceholder color="cyan" label={currentPackShape} />
}
>
<PackModel packShape={currentPackShape} mountBone={mount1} />
<PackModel
packShape={currentPackShape}
mountBone={mount1}
emap={entity.emap}
/>
</DebugSuspense>
</ShapeErrorBoundary>
)}
@ -827,7 +882,11 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
<ShapePlaceholder color="cyan" label={currentFlagShape} />
}
>
<PackModel packShape={currentFlagShape} mountBone={mount2} />
<PackModel
packShape={currentFlagShape}
mountBone={mount2}
emap={entity.emap}
/>
</DebugSuspense>
</ShapeErrorBoundary>
)}
@ -888,7 +947,10 @@ function WeaponModel({
weaponIflInitializers,
} = useMemo(() => {
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
const iflInits = processShapeScene(clone, undefined, { anisotropy });
const iflInits = processShapeScene(clone, undefined, {
anisotropy,
emap: entity.emap,
});
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
const mp = getPosedNodeTransform(
@ -1097,7 +1159,7 @@ function WeaponModel({
sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(resolved.volume);
sound.setPlaybackRate(playback.rate);
sound.setPlaybackRate(getEffectiveSoundRate());
sound.setLoop(true);
weaponClone.add(sound);
trackSound(sound);
@ -1234,16 +1296,21 @@ function applyWeaponAnim(
function PackModel({
packShape,
mountBone,
emap,
}: {
packShape: string;
mountBone: Object3D;
emap?: boolean;
}) {
const packGltf = useStaticShape(packShape);
const anisotropy = useAnisotropy();
const { packClone, packIflInitializers } = useMemo(() => {
const clone = SkeletonUtils.clone(packGltf.scene) as Group;
const iflInits = processShapeScene(clone, undefined, { anisotropy });
const iflInits = processShapeScene(clone, undefined, {
anisotropy,
emap,
});
// Compute Mountpoint inverse offset so the pack aligns to Mount1.
const mp = getPosedNodeTransform(

View file

@ -29,7 +29,7 @@ const EMPTY_KEYFRAMES: never[] = [];
* bar. Fades out with distance.
*/
export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!);
const gltf = useStaticShape(entity.shapeName!);
const { groupRef, isVisible, opacityRef } = useFloatingLabelFade({
fadeDistance: NAMEPLATE_FADE_DISTANCE,
});

View file

@ -12,6 +12,7 @@ import {
} from "react";
import { useFogQueryState } from "./useQueryParams";
import { useTouchDevice } from "./useTouchDevice";
import { setAdjustAudioSpeedFlag } from "./AudioEmitter";
export const MIN_SPEED_MULTIPLIER = 0.01;
export const MAX_SPEED_MULTIPLIER = 1;
@ -40,6 +41,8 @@ type SettingsContextType = {
setWarriorName: StateSetter<string>;
audioVolume: number;
setAudioVolume: StateSetter<number>;
adjustAudioSpeed: boolean;
setAdjustAudioSpeed: StateSetter<boolean>;
sidebarOpen: boolean;
setSidebarOpen: StateSetter<boolean>;
fpsLimit: number | null;
@ -81,6 +84,7 @@ type PersistedSettings = {
mouseSensitivity?: number;
fov?: number;
audioEnabled?: boolean;
adjustAudioSpeed?: boolean;
animationEnabled?: boolean;
debugMode?: boolean;
touchMode?: TouchMode;
@ -134,6 +138,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [fov, setFov] = useState(90);
const [audioEnabled, setAudioEnabled] = useState(false);
const [audioVolume, setAudioVolume] = useState(0.75);
const [adjustAudioSpeed, setAdjustAudioSpeed] = useState(true);
const [animationEnabled, setAnimationEnabled] = useState(true);
const [debugMode, setDebugMode] = useState(false);
const [touchMode, setTouchMode] = useState<TouchMode>("moveLookStick");
@ -176,6 +181,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setWarriorName,
audioVolume,
setAudioVolume,
adjustAudioSpeed,
setAdjustAudioSpeed,
sidebarOpen,
setSidebarOpen,
fpsLimit,
@ -194,6 +201,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
animationEnabled,
warriorName,
audioVolume,
adjustAudioSpeed,
sidebarOpen,
fpsLimit,
showInputOverlay,
@ -294,6 +302,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
if (savedSettings.audioVolume != null) {
setAudioVolume(savedSettings.audioVolume);
}
if (savedSettings.adjustAudioSpeed != null) {
setAdjustAudioSpeed(savedSettings.adjustAudioSpeed);
}
if (savedSettings.invertScroll != null) {
setInvertScroll(savedSettings.invertScroll);
}
@ -320,6 +331,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
}
}, [isTouch]);
// Sync adjustAudioSpeed to the AudioEmitter module-level flag.
useEffect(() => {
setAdjustAudioSpeedFlag(adjustAudioSpeed);
}, [adjustAudioSpeed]);
// Persist settings to localStorage with debouncing to avoid excessive writes
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -343,6 +359,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
touchMode,
warriorName,
audioVolume,
adjustAudioSpeed,
invertScroll,
invertDrag,
invertJoystick,
@ -374,6 +391,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
touchMode,
warriorName,
audioVolume,
adjustAudioSpeed,
invertScroll,
invertDrag,
invertJoystick,

View file

@ -24,19 +24,6 @@ import type { TorqueObject } from "../torqueScript";
import type { ExplosionEntity, ShapeEntity } from "../state/gameEntityTypes";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
/**
* 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";
}
/**
* Renders a mounted weapon using the Torque engine's mount system.
*
@ -54,7 +41,9 @@ export function WeaponModel({ entity }: { entity: ShapeEntity }) {
const mountTransform = useMemo(() => {
// Get Mount0 from the player's posed skeleton with arm animation applied.
const armThread = getArmThread(shapeName);
// TODO: resolve arm animation from armAction index once actionAnimMap
// is available here. For now use default arm pose.
const armThread = "lookde";
const m0 = getPosedNodeTransform(
playerGltf.scene,
playerGltf.animations,
@ -275,7 +264,10 @@ export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
}
});
processShapeScene(scene, entity.shapeName, { anisotropy });
processShapeScene(scene, entity.shapeName, {
anisotropy,
emap: "emap" in entity ? (entity as any).emap : undefined,
});
// Collect vis-animated nodes keyed by sequence name.
const visNodes: VisNode[] = [];

View file

@ -0,0 +1,163 @@
.Root {
display: flex;
width: 100vw;
height: 100vh;
background: #1a1a1a;
color: #ddd;
font-family: system-ui, sans-serif;
font-size: 13px;
}
.Sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid #333;
background: #222;
}
.Title {
margin: 0;
padding: 12px 14px;
font-size: 15px;
font-weight: 600;
border-bottom: 1px solid #333;
}
.Search {
margin: 8px;
padding: 6px 10px;
border: 1px solid #444;
border-radius: 4px;
background: #1a1a1a;
color: #ddd;
font-size: 13px;
outline: none;
}
.Search:focus {
border-color: #5599cc;
}
.ShapeList {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.ShapeItem {
display: block;
width: 100%;
padding: 4px 14px;
border: 0;
background: transparent;
color: #ccc;
font-family: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ShapeItem:hover {
background: rgba(255, 255, 255, 0.05);
}
.ShapeItem[data-active="true"] {
background: rgba(0, 100, 200, 0.4);
color: #fff;
}
.Main {
flex: 1;
display: flex;
flex-direction: column;
}
.CanvasWrap {
flex: 1;
position: relative;
}
.AnimPanel {
height: 200px;
flex-shrink: 0;
border-top: 1px solid #333;
background: #222;
display: flex;
flex-direction: column;
overflow: hidden;
}
.AnimTitle {
margin: 0;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.AnimNote {
margin: 0;
padding: 0 14px 6px;
font-size: 11px;
opacity: 0.5;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.SpeedControl {
padding: 0 14px 8px;
flex-shrink: 0;
font-size: 12px;
}
.SpeedControl input[type="range"] {
width: 120px;
margin-left: 8px;
vertical-align: middle;
}
.AnimList {
flex: 1;
overflow-y: auto;
padding: 0 0 8px;
}
.AnimItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 14px;
border: 0;
background: transparent;
color: #ccc;
font-family: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.AnimItem:hover {
background: rgba(255, 255, 255, 0.05);
}
.AnimItem[data-active="true"] {
background: rgba(0, 100, 200, 0.4);
color: #fff;
}
.AnimName {
flex: 1;
}
.AnimIndex {
font-size: 11px;
opacity: 0.5;
}

View file

@ -0,0 +1,257 @@
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, useGLTF } from "@react-three/drei";
import {
AnimationMixer,
AnimationAction,
LoopRepeat,
NoToneMapping,
SRGBColorSpace,
} from "three";
import { useFrame } from "@react-three/fiber";
import { shapeToUrl } from "../loaders";
import { getResourceList, getSourceAndPath } from "../manifest";
import styles from "./ShapeViewer.module.css";
// ── Shape list ──
interface ShapeItem {
resourceKey: string;
displayName: string;
shapeName: string;
source: string;
}
const allShapes: ShapeItem[] = getResourceList()
.filter((key) => key.startsWith("shapes/") && key.endsWith(".dts"))
.map((resourceKey) => {
const [sourcePath, actualPath] = getSourceAndPath(resourceKey);
const fileName = actualPath.split("/").pop() ?? actualPath;
return {
resourceKey,
displayName: fileName,
shapeName: fileName,
source: sourcePath,
};
})
.sort((a, b) => a.displayName.localeCompare(b.displayName));
// ── Shape model with animation controls ──
function ShapeScene({
shapeName,
activeAnimation,
animationSpeed,
onAnimationsLoaded,
}: {
shapeName: string;
activeAnimation: string | null;
animationSpeed: number;
onAnimationsLoaded: (names: string[], seqNames: string[] | null) => void;
}) {
const url = shapeToUrl(shapeName);
const gltf = useGLTF(url);
const mixerRef = useRef<AnimationMixer | null>(null);
const actionRef = useRef<AnimationAction | null>(null);
// Report animations to parent
useEffect(() => {
const glbNames = gltf.animations.map((a) => a.name);
const rawSeqNames = gltf.scene.userData?.dts_sequence_names;
let seqNames: string[] | null = null;
if (typeof rawSeqNames === "string") {
try {
seqNames = JSON.parse(rawSeqNames);
} catch {
/* ignore */
}
}
onAnimationsLoaded(glbNames, seqNames);
}, [gltf, onAnimationsLoaded]);
// Create mixer
useEffect(() => {
const mixer = new AnimationMixer(gltf.scene);
mixerRef.current = mixer;
return () => {
mixer.stopAllAction();
mixerRef.current = null;
};
}, [gltf.scene]);
// Play selected animation
useEffect(() => {
const mixer = mixerRef.current;
if (!mixer) return;
// Stop previous
if (actionRef.current) {
actionRef.current.stop();
actionRef.current = null;
}
if (!activeAnimation) return;
const clip = gltf.animations.find(
(a) => a.name.toLowerCase() === activeAnimation.toLowerCase(),
);
if (!clip) return;
const action = mixer.clipAction(clip);
action.setLoop(LoopRepeat, Infinity);
action.reset().play();
actionRef.current = action;
return () => {
action.stop();
actionRef.current = null;
};
}, [gltf.animations, activeAnimation]);
useFrame((_, delta) => {
mixerRef.current?.update(delta * animationSpeed);
});
return (
<>
<primitive object={gltf.scene} rotation={[0, Math.PI / 2, 0]} />
</>
);
}
// ── Main viewer ──
export function ShapeViewer() {
const [selectedShape, setSelectedShape] = useState(
allShapes[0]?.shapeName ?? "",
);
const [filter, setFilter] = useState("");
const [animations, setAnimations] = useState<string[]>([]);
const [seqNames, setSeqNames] = useState<string[] | null>(null);
const [activeAnimation, setActiveAnimation] = useState<string | null>(null);
const [animationSpeed, setAnimationSpeed] = useState(1);
const filtered = useMemo(() => {
if (!filter) return allShapes;
const lower = filter.toLowerCase();
return allShapes.filter((s) => s.displayName.toLowerCase().includes(lower));
}, [filter]);
const handleAnimationsLoaded = useMemo(
() => (names: string[], seq: string[] | null) => {
setAnimations(names);
setSeqNames(seq);
setActiveAnimation(null);
},
[],
);
return (
<div className={styles.Root}>
<div className={styles.Sidebar}>
<h2 className={styles.Title}>Shape Viewer</h2>
<input
className={styles.Search}
type="text"
placeholder="Filter shapes…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<div className={styles.ShapeList}>
{filtered.map((shape) => (
<button
key={shape.resourceKey}
type="button"
className={styles.ShapeItem}
data-active={shape.shapeName === selectedShape}
onClick={() => setSelectedShape(shape.shapeName)}
>
{shape.displayName}
</button>
))}
</div>
</div>
<div className={styles.Main}>
<div className={styles.CanvasWrap}>
<Canvas
gl={{
toneMapping: NoToneMapping,
outputColorSpace: SRGBColorSpace,
}}
camera={{ position: [8, 6, 8], fov: 50 }}
>
<ambientLight intensity={0.6} />
<directionalLight position={[5, 10, 5]} intensity={0.8} />
<gridHelper args={[50, 50, "#444", "#333"]} />
<OrbitControls />
<Suspense fallback={null}>
{selectedShape && (
<ShapeScene
key={selectedShape}
shapeName={selectedShape}
activeAnimation={activeAnimation}
animationSpeed={animationSpeed}
onAnimationsLoaded={handleAnimationsLoaded}
/>
)}
</Suspense>
</Canvas>
</div>
<div className={styles.AnimPanel}>
<h3 className={styles.AnimTitle}>Animations ({animations.length})</h3>
{seqNames && (
<p className={styles.AnimNote}>
DTS sequence order: {seqNames.join(", ")}
</p>
)}
<div className={styles.SpeedControl}>
<label>
Speed: {animationSpeed.toFixed(1)}x
<input
type="range"
min={0}
max={4}
step={0.1}
value={animationSpeed}
onChange={(e) => setAnimationSpeed(parseFloat(e.target.value))}
/>
</label>
</div>
<div className={styles.AnimList}>
<button
type="button"
className={styles.AnimItem}
data-active={activeAnimation === null}
onClick={() => setActiveAnimation(null)}
>
(none)
</button>
{animations.map((name, i) => {
const dtsIndex = seqNames
? seqNames.findIndex(
(s) => s.toLowerCase() === name.toLowerCase(),
)
: i;
return (
<button
key={name}
type="button"
className={styles.AnimItem}
data-active={
activeAnimation?.toLowerCase() === name.toLowerCase()
}
onClick={() => setActiveAnimation(name)}
>
<span className={styles.AnimName}>{name}</span>
{dtsIndex >= 0 && (
<span className={styles.AnimIndex}>seq {dtsIndex}</span>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
);
}

View file

@ -4,8 +4,18 @@ import { useThree, useFrame } from "@react-three/fiber";
import { useCubeTexture } from "@react-three/drei";
import { Color, Fog } from "three";
import { createLogger } from "../logger";
import { useSettings } from "./SettingsProvider";
import { loadDetailMapList, textureToUrl } from "../loaders";
import { useDebug, useSettings } from "./SettingsProvider";
import {
FALLBACK_TEXTURE_URL,
loadDetailMapList,
textureToUrl,
} from "../loaders";
import {
setShapeEnvMap,
resetShapeEnvMap,
shapeEnvMapUniforms,
} from "../shapeMaterial";
import { loadTexture, setupTexture } from "../textureUtils";
import { CloudLayers } from "./CloudLayers";
import { fogStateFromScene, type FogState } from "./FogProvider";
import { installCustomFogShader } from "../fogShader";
@ -310,6 +320,38 @@ export function SkyBox({
[detailMapList],
);
// Load the sphere-map environment texture (index 6 in the .dml) for shape
// reflections. This is a dedicated 2D texture, NOT the skybox cubemap.
// Some maps have broken emap paths (e.g. Katabatic references desert/skies/
// but file is at ice/skies/) — skip if the texture doesn't resolve.
useEffect(() => {
const emapName = detailMapList?.[6];
if (!emapName) return;
const url = textureToUrl(emapName);
// textureToUrl returns the fallback URL for missing textures (e.g.
// Katabatic's DML has a broken emap path). Don't set a fallback as envmap.
if (url === FALLBACK_TEXTURE_URL) return;
// Load WITHOUT sRGB conversion (noColorSpace). The env map values stay as
// raw sRGB bytes, which is what Torque's fixed-function pipeline operates
// on. The 2x modulate: 2 * lit_base_linear * env_sRGB produces display
// values that closely match Torque's 2 * lit_base_sRGB * env_sRGB.
const tex = loadTexture(url, (loaded) => {
setupTexture(loaded, { noColorSpace: true });
setShapeEnvMap(loaded);
});
if (tex.image) {
setupTexture(tex, { noColorSpace: true });
setShapeEnvMap(tex);
}
return () => resetShapeEnvMap();
}, [detailMapList]);
// In debug mode, show sphere map UVs as colors instead of the texture.
const { debugMode } = useDebug();
useEffect(() => {
shapeEnvMapUniforms.shapeEnvMapDebugUV.value = debugMode;
}, [debugMode]);
// Don't render until we have real texture URLs
if (!skyBoxFiles) {
return null;

View file

@ -47,6 +47,7 @@ function mutateRenderFields(
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.weaponShape = stream.weaponShape;
e.armAction = stream.armAction;
e.packShape = stream.packShape;
e.flagShape = stream.flagShape;
e.falling = stream.falling;
@ -63,6 +64,7 @@ function mutateRenderFields(
case "Shape": {
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.damageState = stream.damageState;
e.targetRenderFlags = stream.targetRenderFlags;
e.iffColor = stream.iffColor;
break;
@ -109,7 +111,9 @@ export function StreamingController({
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const currentTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const eyeOffsetRef = useRef(new Vector3(0, DEFAULT_EYE_HEIGHT, 0));
const streamRef = useRef<StreamingPlayback | null>(recording.streamingPlayback ?? null);
const streamRef = useRef<StreamingPlayback | null>(
recording.streamingPlayback ?? null,
);
const publishedSnapshotRef = useRef<StreamSnapshot | null>(null);
const entityMapRef = useRef<Map<string, GameEntity>>(new Map());
const lastSyncedSnapshotRef = useRef<StreamSnapshot | null>(null);

View file

@ -14,7 +14,7 @@ import {
selectPing,
useLiveSelector,
} from "../state/liveConnectionStore";
import { useRecording } from "./RecordingProvider";
import { useRecording } from "./usePlayback";
import { LuCircleArrowOutUpLeft } from "react-icons/lu";
import { BiSolidEject } from "react-icons/bi";
import { formatPing } from "../stringUtils";

View file

@ -1,89 +0,0 @@
import { createContext, type Dispatch, ReactNode, type SetStateAction, useContext, useMemo, useState } from "react";
/**
* Handle for querying terrain data.
*
* TerrainBlock registers this via useEffect, allowing other components
* to query terrain data without prop drilling.
*/
export interface TerrainHandle {
/**
* Query terrain height at world coordinates.
* Coordinates wrap via `& 255` to support infinite terrain tiling,
* matching Torque's FluidSupport.cc:
* i = (((m_SquareY0+Y) & 255) << 8) + ((m_SquareX0+X) & 255);
*/
getHeightAt: (worldX: number, worldZ: number) => number;
/**
* Check if a point is above terrain at given world coordinates.
* Used for water masking (matching Torque's fluid reject mask).
* Coordinates wrap to support infinite terrain tiling.
*/
isAboveTerrain: (worldX: number, worldZ: number, height: number) => boolean;
/**
* Get primary terrain tile bounds in world coordinates.
* Note: Terrain actually tiles infinitely via coordinate wrapping.
*/
getBounds: () => {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
};
}
type StateSetter<T> = Dispatch<SetStateAction<T>>;
interface TerrainContextValue {
terrain: TerrainHandle | null;
setTerrain: StateSetter<TerrainHandle | null>;
}
const TerrainContext = createContext<TerrainContextValue | null>(null);
interface TerrainProviderProps {
children: ReactNode;
}
/**
* Provider for terrain query handle.
*
* TerrainBlock registers its handle via useEffect on mount, and other
* components (like WaterBlock) can access it to query terrain heights.
*/
export function TerrainProvider({ children }: TerrainProviderProps) {
const [terrain, setTerrain] = useState<TerrainHandle | null>(null);
const context = useMemo(() => ({ terrain, setTerrain }), [terrain]);
return (
<TerrainContext.Provider value={context}>
{children}
</TerrainContext.Provider>
);
}
/**
* Get the terrain handle from context.
* Returns null if no TerrainBlock has registered yet.
*/
export function useTerrainHandle() {
const context = useContext(TerrainContext);
if (!context) {
throw new Error("useTerrainHandle must be used within a TerrainProvider");
}
return context.terrain;
}
/**
* Get the terrain setter for registration.
* Used by TerrainBlock to register its handle.
*/
export function useRegisterTerrain() {
const context = useContext(TerrainContext);
if (!context) {
throw new Error("useRegisterTerrain must be used within a TerrainProvider");
}
return context.setTerrain;
}

View file

@ -1,49 +0,0 @@
import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { createLogger } from "../logger";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import { useDatablock } from "./useDatablock";
const log = createLogger("Turret");
export function Turret({ object }: { object: TorqueObject }) {
const datablockName = getProperty(object, "dataBlock") ?? "";
const barrelDatablockName = getProperty(object, "initialBarrel");
const datablock = useDatablock(datablockName);
const barrelDatablock = useDatablock(barrelDatablockName);
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const shapeName = getProperty(datablock, "shapeFile");
const barrelShapeName = getProperty(barrelDatablock, "shapeFile");
if (!shapeName) {
log.error("Turret missing shape for datablock: %s", datablockName);
}
// `initialBarrel` is optional - turrets can exist without a barrel mounted.
// But if we do have one, it needs a shape name.
if (barrelDatablockName && !barrelShapeName) {
log.error(
"Turret missing shape for barrel datablock: %s",
barrelDatablockName,
);
}
return (
<ShapeInfoProvider type="Turret" object={object} shapeName={shapeName}>
<group position={position} quaternion={q} scale={scale}>
<ShapeRenderer />
{barrelShapeName ? (
<ShapeInfoProvider
type="Turret"
object={object}
shapeName={barrelShapeName}
>
<group position={[0, 1.5, 0]}>
<ShapeRenderer />
</group>
</ShapeInfoProvider>
) : null}
</group>
</ShapeInfoProvider>
);
}

View file

@ -200,7 +200,9 @@ export const WaterBlock = memo(function WaterBlock({
setReps((prevReps) => {
if (
prevReps.length === newReps.length &&
prevReps.every((r, i) => r[0] === newReps[i][0] && r[1] === newReps[i][1])
prevReps.every(
(r, i) => r[0] === newReps[i][0] && r[1] === newReps[i][1],
)
) {
return prevReps;
}

View file

@ -3,8 +3,6 @@ import { FloatingLabel } from "./FloatingLabel";
export function WayPoint({ entity }: { entity: WayPointEntity }) {
return entity.label ? (
<FloatingLabel opacity={0.6}>
{entity.label}
</FloatingLabel>
<FloatingLabel opacity={0.6}>{entity.label}</FloatingLabel>
) : null;
}

View file

@ -1,22 +0,0 @@
import { useFrame, useThree } from "@react-three/fiber";
import { RefObject, useRef } from "react";
import { Object3D } from "three";
import { useWorldPosition } from "./useWorldPosition";
export function useDistanceFromCamera<T extends Object3D>(
ref: RefObject<T>,
): RefObject<number | null> {
const camera = useThree((state) => state.camera);
const distanceRef = useRef<number>(null);
const worldPosRef = useWorldPosition(ref);
useFrame(() => {
if (!worldPosRef.current) {
distanceRef.current = null;
} else {
distanceRef.current = camera.position.distanceTo(worldPosRef.current);
}
});
return distanceRef;
}

View file

@ -1,12 +1,8 @@
import { useCallback, type ReactNode } from "react";
import { useCallback } from "react";
import type { StreamRecording } from "../stream/types";
import { useEngineSelector } from "../state/engineStore";
export const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
export function RecordingProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}
export const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2, 3, 4];
export function useRecording(): StreamRecording | null {
return useEngineSelector((state) => state.playback.recording);

View file

@ -1,6 +1,6 @@
import { useEffect, useEffectEvent } from "react";
import { getMissionInfo, getMissionList } from "../manifest";
import { usePlaybackActions } from "./RecordingProvider";
import { usePlaybackActions } from "./usePlayback";
import type { CurrentMission } from "./useQueryParams";
declare global {

View file

@ -1,9 +0,0 @@
import type { TorqueObject } from "../torqueScript";
import { useRuntimeObjectByName } from "../state/engineStore";
/**
* Look up a scene object by name from the runtime.
*/
export function useSceneObject(name: string): TorqueObject | undefined {
return useRuntimeObjectByName(name);
}

View file

@ -16,7 +16,7 @@ export type { TerrainFile };
export const BASE_URL = "/t2-mapper";
export const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;
export const FALLBACK_TEXTURE_URL = `${BASE_URL}/magenta.png`;
export const FALLBACK_TEXTURE_URL = `${BASE_URL}/white.png`;
export function getUrlForPath(resourcePath: string, fallbackUrl?: string) {
let resourceKey;

View file

@ -3,7 +3,7 @@ import pino from "pino";
/**
* Module-scoped browser logging via pino.
*
* Control via VITE_LOG (comma-separated):
* Control via LOG_LEVEL (comma-separated):
* "debug" all modules at debug
* "liveStreaming:debug,DebugSuspense:trace" those modules only, rest silent
* "debug,liveStreaming:trace" all at debug, liveStreaming at trace
@ -27,12 +27,12 @@ const PINO_LEVELS = new Set([
"silent",
]);
/** Parse VITE_LOG into a global default and per-module overrides. */
/** Parse LOG_LEVEL into a global default and per-module overrides. */
function parseLogConfig(): {
globalLevel: string;
modules: Map<string, string>;
} {
const raw = import.meta.env.VITE_LOG?.trim();
const raw = (process.env.LOG_LEVEL ?? "").trim();
if (!raw) return { globalLevel: "info", modules: new Map() };
let globalLevel: string | null = null;

File diff suppressed because it is too large Load diff

View file

@ -98,6 +98,7 @@ export function getResourceList(): string[] {
* 5. .bmp
*/
const standardTextureExt = ["", ".jpg", ".png", ".gif", ".bmp"];
export function getStandardTextureResourceKey(resourcePath: string) {
const baseResourceKey = getResourceKey(resourcePath);
for (const ext of standardTextureExt) {

View file

@ -1,83 +0,0 @@
import { Skeleton, Bone, BufferGeometry } from "three";
/**
* Extract hull bone indices from a skeleton
* @param skeleton - The Three.js skeleton to scan
* @returns Set of bone indices for bones matching the hull pattern (starts with "Hulk")
*/
export function getHullBoneIndices(skeleton: Skeleton): Set<number> {
const hullBoneIndices = new Set<number>();
skeleton.bones.forEach((bone: Bone, index: number) => {
if (bone.name.match(/^Hulk/i)) {
hullBoneIndices.add(index);
}
});
return hullBoneIndices;
}
/**
* Filter geometry by removing faces influenced by hull bones
* @param geometry - The Three.js geometry to filter
* @param hullBoneIndices - Set of bone indices that represent hull (collision) geometry
* @returns Filtered geometry with hull-influenced faces removed
*/
export function filterGeometryByVertexGroups(
geometry: BufferGeometry,
hullBoneIndices: Set<number>,
): BufferGeometry {
// If no hull bones or no skinning data, return original geometry
if (hullBoneIndices.size === 0 || !geometry.attributes.skinIndex) {
return geometry;
}
const skinIndex = geometry.attributes.skinIndex;
const skinWeight = geometry.attributes.skinWeight;
const index = geometry.index;
// Track which vertices are influenced by hull bones
const vertexHasHullInfluence = new Array(skinIndex.count).fill(false);
// Check each vertex's bone influences
for (let i = 0; i < skinIndex.count; i++) {
for (let j = 0; j < 4; j++) {
const boneIndex = skinIndex.array[i * 4 + j];
const weight = skinWeight.array[i * 4 + j];
// If this vertex has significant weight to a hull bone, mark it
if (weight > 0.01 && hullBoneIndices.has(boneIndex)) {
vertexHasHullInfluence[i] = true;
break;
}
}
}
// Build new index array excluding faces that use hull-influenced vertices
if (index) {
const newIndices: number[] = [];
const indexArray = index.array;
for (let i = 0; i < indexArray.length; i += 3) {
const i0 = indexArray[i];
const i1 = indexArray[i + 1];
const i2 = indexArray[i + 2];
// Only keep face if all vertices don't have hull influence
if (
!vertexHasHullInfluence[i0] &&
!vertexHasHullInfluence[i1] &&
!vertexHasHullInfluence[i2]
) {
newIndices.push(i0, i1, i2);
}
}
// Create new geometry with filtered indices
const filteredGeometry = geometry.clone();
filteredGeometry.setIndex(newIndices);
return filteredGeometry;
}
return geometry;
}

View file

@ -147,8 +147,12 @@ export function parseMissionScript(script: string): ParsedMission {
pragma.missiontypes
?.split(/\s+/)
.filter(Boolean)
.map((name) => (normalizedMissionTypes as Record<string, string>)[name.toLowerCase()] ?? name) ??
[],
.map(
(name) =>
(normalizedMissionTypes as Record<string, string>)[
name.toLowerCase()
] ?? name,
) ?? [],
missionBriefing: getSection("MISSION BRIEFING"),
briefingWav: pragma.briefingwav ?? null,
bitmap: pragma.bitmap ?? null,

View file

@ -4,6 +4,7 @@
*/
import type { TorqueObject } from "../torqueScript";
import { parseColor3, parseColor4 } from "../colorUtils";
import type {
SceneTerrainBlock,
SceneInteriorInstance,
@ -15,8 +16,6 @@ import type {
SceneObject,
MatrixF,
Vec3,
Color3,
Color4,
SceneSkyFogVolume,
SceneSkyCloudLayer,
} from "./types";
@ -54,33 +53,6 @@ function parseVec3(
};
}
function parseColor3(
s: string | undefined,
fallback: Color3 = { r: 0, g: 0, b: 0 },
): Color3 {
if (!s) return fallback;
const parts = s.split(" ").map(Number);
return {
r: parts[0] ?? fallback.r,
g: parts[1] ?? fallback.g,
b: parts[2] ?? fallback.b,
};
}
function parseColor4(
s: string | undefined,
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
): Color4 {
if (!s) return fallback;
const parts = s.split(" ").map(Number);
return {
r: parts[0] ?? fallback.r,
g: parts[1] ?? fallback.g,
b: parts[2] ?? fallback.b,
a: parts[3] ?? fallback.a,
};
}
/**
* Build a MatrixF from .mis position ("x y z") and rotation ("ax ay az angleDeg").
* Torque stores rotation as axis-angle in degrees.

View file

@ -2,15 +2,7 @@
* Shape material utilities and shader modifications.
*/
import {
DoubleSide,
MeshStandardMaterial,
Texture,
RepeatWrapping,
LinearFilter,
LinearMipmapLinearFilter,
SRGBColorSpace,
} from "three";
import { Texture } from "three";
import { SHAPE_LIGHTING } from "./lightingConfig";
/**
@ -44,54 +36,131 @@ uniform float shapeAmbientFactor;
);
}
// Shared shader modification function to avoid duplication
const alphaAsRoughnessShaderModifier = (shader: any) => {
// Modify fragment shader to extract alpha channel as roughness after map is sampled
// We need to intercept after diffuseColor is set from the map
shader.fragmentShader = shader.fragmentShader.replace(
"#include <roughness_fragment>",
`
#include <roughness_fragment>
// Override roughness with map alpha channel if map exists
#ifdef USE_MAP
roughnessFactor = texture2D(map, vMapUv).a;
#endif
`,
);
// ── Environment map reflectivity ──
//
// Tribes 2 uses a 2D sphere map texture (from the sky's .dml file, index 6)
// for shape reflections. The MULTI_1 path (binary-verified via Ghidra) uses
// GL_COMBINE with GL_INTERPOLATE (0x8575):
// result = envmap * factor + base * (1 - factor)
// where factor = base_texture_alpha * vertexAlpha
// vertexAlpha = environmentMapAlpha * reflectionAmount (per material)
// The base texture's alpha channel acts as a reflectance mask — bright alpha
// areas show the env map, dark alpha areas keep the base appearance.
// environmentMapAlpha = 1.0 for all game objects.
/**
* Shared sphere map uniform. Sky component sets the real texture; materials
* reference this object so updates propagate without recompilation.
*/
export const shapeEnvMapUniforms = {
shapeEnvMap: { value: null as Texture | null },
shapeEnvMapActive: { value: false },
shapeEnvMapDebugUV: { value: false },
};
/**
* Configures a texture for use with alpha-as-roughness materials
* @param texture - The texture to configure
*/
export function setupAlphaAsRoughnessTexture(
texture: Texture,
options: { anisotropy?: number } = {},
) {
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.colorSpace = SRGBColorSpace;
texture.flipY = false;
texture.anisotropy = options.anisotropy ?? 1;
texture.generateMipmaps = true;
texture.minFilter = LinearMipmapLinearFilter;
texture.magFilter = LinearFilter;
texture.needsUpdate = true;
/** Set the shared shape environment sphere map (called by Sky component). */
export function setShapeEnvMap(texture: Texture): void {
shapeEnvMapUniforms.shapeEnvMap.value = texture;
shapeEnvMapUniforms.shapeEnvMapActive.value = true;
}
/** Reset env map (called when Sky unmounts). */
export function resetShapeEnvMap(): void {
shapeEnvMapUniforms.shapeEnvMap.value = null;
shapeEnvMapUniforms.shapeEnvMapActive.value = false;
}
/**
* Creates a reusable shader-enhanced material that treats alpha as roughness
* The same material instance can be used with different textures by setting the `map` property
* @returns A pre-configured MeshStandardMaterial with the shader modifier attached
* Inject Tribes 2 environment map reflectivity into a Lambert shader.
* Uses a 2D sphere map texture with the classic GL_SPHERE_MAP UV formula.
* Blends envmap color with diffuse based on the base texture's alpha channel.
*/
export function createAlphaAsRoughnessMaterial() {
const material = new MeshStandardMaterial({
side: DoubleSide,
metalness: 0.0,
roughness: 1.0,
});
export function injectShapeEnvMap(
shader: {
uniforms: Record<string, { value: unknown }>;
fragmentShader: string;
vertexShader: string;
},
reflectionAmount: number,
): void {
// Shared uniforms — values update when sky loads, no recompile needed.
shader.uniforms.shapeEnvMap = shapeEnvMapUniforms.shapeEnvMap;
shader.uniforms.shapeEnvMapActive = shapeEnvMapUniforms.shapeEnvMapActive;
shader.uniforms.shapeEnvMapDebugUV = shapeEnvMapUniforms.shapeEnvMapDebugUV;
shader.uniforms.shapeReflectionAmount = { value: reflectionAmount };
// Attach shader modifier (will be applied when shader is compiled)
material.onBeforeCompile = alphaAsRoughnessShaderModifier;
// Per-vertex sphere map UV computation, matching GL_SPHERE_MAP texgen.
// GL_SPHERE_MAP computes UVs at vertices and interpolates linearly.
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`#include <common>
uniform sampler2D shapeEnvMap;
uniform bool shapeEnvMapActive;
uniform float shapeReflectionAmount;
uniform bool shapeEnvMapDebugUV;
varying vec2 vShapeSphereUV;
`,
);
return material;
shader.vertexShader = shader.vertexShader.replace(
"#include <common>",
`#include <common>
varying vec2 vShapeSphereUV;
`,
);
// GL_SPHERE_MAP formula adapted for Torque's coordinate system.
// Torque puts darkToOGLCoord (Z-up to Y-up) in the PROJECTION matrix, so
// the MODELVIEW (and thus eye space) stays Z-up: X-right, Y-forward, Z-up.
// Three.js eye space is Y-up: X-right, Y-up, Z-backward.
// Mapping: torque.x = three.x, torque.y = -three.z, torque.z = three.y
// The GL_SPHERE_MAP formula in Torque's eye space is:
// m = 2*sqrt(rt.x² + rt.y² + (rt.z+1)²), s = rt.x/m+0.5, t = rt.y/m+0.5
// Substituting Three.js coords:
// m = 2*sqrt(r.x² + r.z² + (r.y+1)²), s = r.x/m+0.5, t = -r.z/m+0.5
shader.vertexShader = shader.vertexShader.replace(
"#include <fog_vertex>",
`#include <fog_vertex>
{
vec3 _eyePos = (modelViewMatrix * vec4(transformed, 1.0)).xyz;
#ifdef FLAT_SHADED
vec3 _eyeN = vec3(0.0, 0.0, 1.0);
#else
vec3 _eyeN = normalize(normalMatrix * normal);
#endif
vec3 _eyeU = normalize(_eyePos);
vec3 _r = reflect(_eyeU, _eyeN);
float _m = 2.0 * sqrt(_r.x * _r.x + _r.z * _r.z + (_r.y + 1.0) * (_r.y + 1.0));
vShapeSphereUV = vec2(_r.x, -_r.z) / _m + 0.5;
}
`,
);
// Binary-verified GL_INTERPOLATE (0x8575) with base texture alpha as
// reflectance mask. The formula is:
// result = envmap * factor + base * (1 - factor)
// where factor = base_alpha * vertexAlpha
// vertexAlpha = environmentMapAlpha(1.0) * reflectionAmount(per-material)
shader.fragmentShader = shader.fragmentShader.replace(
"#include <opaque_fragment>",
`// Tribes 2 sphere-map environment reflections (GL_INTERPOLATE)
if (shapeEnvMapActive && shapeReflectionAmount > 0.0) {
if (shapeEnvMapDebugUV) {
outgoingLight = vec3(vShapeSphereUV, 0.0);
} else {
vec3 _envColor = texture2D(shapeEnvMap, vShapeSphereUV).rgb;
#ifdef USE_MAP
float _baseAlpha = texture2D(map, vMapUv).a;
#else
float _baseAlpha = 1.0;
#endif
float _factor = _baseAlpha * shapeReflectionAmount;
// Torque blends in sRGB space (fixed-function pipeline, no gamma).
// Convert outgoingLight to sRGB, mix with sRGB env map, convert back.
vec3 _baseSRGB = pow(max(outgoingLight, 0.0), vec3(1.0 / 2.2));
outgoingLight = pow(mix(_baseSRGB, _envColor, _factor), vec3(2.2));
}
}
#include <opaque_fragment>`,
);
}

View file

@ -171,7 +171,9 @@ export const gameEntityStore = createStore<GameEntityState>()((set) => ({
if (info.gameClassName) {
const raw = info.gameClassName.replace(/Game$/i, "");
updates.missionType =
(normalizedMissionTypes as Record<string, string>)[raw.toLowerCase()] ?? raw;
(normalizedMissionTypes as Record<string, string>)[
raw.toLowerCase()
] ?? raw;
} else {
updates.missionType = null;
}

View file

@ -125,7 +125,11 @@ export interface ShapeEntity extends PositionedBase {
shapeName?: string;
shapeType?: string;
dataBlock?: string;
/** Datablock enables environment map reflections. */
emap?: boolean;
threads?: ThreadState[];
/** Torque DamageState: 0=Enabled, 1=Disabled, 2=Destroyed. */
damageState?: number;
rotate?: boolean;
teamId?: number;
barrelShapeName?: string;
@ -133,7 +137,11 @@ export interface ShapeEntity extends PositionedBase {
iffColor?: { r: number; g: number; b: number };
weaponShape?: string;
/** WheeledVehicle per-wheel state (speed, slip). */
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
wheels?: Array<{
speed: number;
lateralSlip: number;
longitudinalSlip: number;
}>;
/** Vehicle steering angle (radians). */
steeringYaw?: number;
/** Vehicle frozen state (deployed). */
@ -146,7 +154,11 @@ export interface PlayerEntity extends PositionedBase {
renderType: "Player";
shapeName?: string;
dataBlock?: string;
/** Datablock enables environment map reflections. */
emap?: boolean;
weaponShape?: string;
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
packShape?: string;
/** DTS shape name for the carried flag (slot 3, Mount2 bone). */
flagShape?: string;

View file

@ -49,8 +49,7 @@ export interface LiveConnectionStore extends LiveConnectionState {
sendCommand(command: string, ...args: string[]): void;
}
const DEFAULT_RELAY_URL =
import.meta.env.VITE_RELAY_URL || "ws://localhost:8765";
const DEFAULT_RELAY_URL = process.env.RELAY_URL || "ws://localhost:8765";
export const liveConnectionStore = createStore<LiveConnectionStore>(
(set, get) => ({

View file

@ -70,6 +70,8 @@ export interface MutableEntity {
dataBlockId?: number;
shapeHint?: string;
dataBlock?: string;
/** Datablock enables environment map reflections. */
emap?: boolean;
visual?: StreamVisual;
direction?: [number, number, number];
weaponShape?: string;
@ -82,6 +84,7 @@ export interface MutableEntity {
maxEnergy?: number;
actionAnim?: number;
actionAtEnd?: boolean;
armAction?: number;
damageState?: number;
targetId?: number;
projectilePhysics?: "linear" | "ballistic" | "seeker";
@ -127,7 +130,11 @@ export interface MutableEntity {
audioMaxLoopGap?: number;
sceneData?: SceneObject;
/** WheeledVehicle per-wheel state from ghost data. */
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
wheels?: Array<{
speed: number;
lateralSlip: number;
longitudinalSlip: number;
}>;
/** Vehicle steering angle (radians), from ghost data. */
steeringYaw?: number;
/** Vehicle frozen state (deployed MPB, etc.). */
@ -906,6 +913,7 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.damageState = undefined;
entity.actionAnim = undefined;
entity.actionAtEnd = undefined;
entity.armAction = undefined;
entity.explosionDataBlockId = undefined;
entity.maintainEmitterId = undefined;
}
@ -931,6 +939,9 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.shapeHint = shapeName;
entity.dataBlock = shapeName;
}
if (blockData && "emap" in blockData) {
entity.emap = !!blockData.emap;
}
if (
entity.type === "Player" &&
typeof blockData?.maxEnergy === "number"
@ -993,14 +1004,9 @@ export abstract class StreamEngine implements StreamingPlayback {
| undefined;
entity.forceFieldData = {
textures,
color: color1
? [color1.r, color1.g, color1.b]
: [1, 1, 1],
baseTranslucency:
(blockData.baseTranslucency as number) ?? 1,
dimensions: scale
? [scale.y, scale.z, scale.x]
: [1, 1, 1],
color: color1 ? [color1.r, color1.g, color1.b] : [1, 1, 1],
baseTranslucency: (blockData.baseTranslucency as number) ?? 1,
dimensions: scale ? [scale.y, scale.z, scale.x] : [1, 1, 1],
framesPerSec: (blockData.framesPerSec as number) ?? 1,
scrollSpeed: (blockData.scrollSpeed as number) ?? 0,
umapping: (blockData.umapping as number) ?? 1,
@ -1237,7 +1243,9 @@ export abstract class StreamEngine implements StreamingPlayback {
"Item %s (%s): atRest=false pos=%s vel=%s",
entity.id,
entity.shapeHint ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
data.position ? `${(data.position as Vec3).x.toFixed(1)},${(data.position as Vec3).y.toFixed(1)},${(data.position as Vec3).z.toFixed(1)}` : "none",
data.position
? `${(data.position as Vec3).x.toFixed(1)},${(data.position as Vec3).y.toFixed(1)},${(data.position as Vec3).z.toFixed(1)}`
: "none",
`${vel.x.toFixed(1)},${vel.y.toFixed(1)},${vel.z.toFixed(1)}`,
);
} else if (atRest === true) {
@ -1245,7 +1253,9 @@ export abstract class StreamEngine implements StreamingPlayback {
"Item %s (%s): atRest=true pos=%s",
entity.id,
entity.shapeHint ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
entity.position ? `${entity.position[0].toFixed(1)},${entity.position[1].toFixed(1)},${entity.position[2].toFixed(1)}` : "none",
entity.position
? `${entity.position[0].toFixed(1)},${entity.position[1].toFixed(1)},${entity.position[2].toFixed(1)}`
: "none",
);
entity.itemPhysics = undefined;
}
@ -1335,6 +1345,9 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.actionAnim = data.action;
entity.actionAtEnd = !!data.actionAtEnd;
}
if (typeof data.armAction === "number") {
entity.armAction = data.armAction;
}
// Threads
if (Array.isArray(data.threads)) {
@ -2280,6 +2293,7 @@ export abstract class StreamEngine implements StreamingPlayback {
energy: entity.energy,
actionAnim: entity.actionAnim,
actionAtEnd: entity.actionAtEnd,
armAction: entity.armAction,
damageState: entity.damageState,
faceViewer: entity.faceViewer,
threads: entity.threads,

View file

@ -204,7 +204,11 @@ export async function scanDemoTimeline(
} catch (err) {
// Parser cursor state is unknown after a throw — stop scanning and
// return whatever events we've found rather than risking an infinite loop.
log.warn("Stopping scan at block %d due to read error: %o", blockCount, err);
log.warn(
"Stopping scan at block %d due to read error: %o",
blockCount,
err,
);
break;
}
if (!block) break;
@ -268,15 +272,9 @@ export async function scanDemoTimeline(
recorderClientId != null &&
args.length >= 6
) {
const clientId = parseInt(
resolveNetString(args[4], netStrings),
10,
);
const clientId = parseInt(resolveNetString(args[4], netStrings), 10);
if (clientId === recorderClientId) {
const teamId = parseInt(
resolveNetString(args[5], netStrings),
10,
);
const teamId = parseInt(resolveNetString(args[5], netStrings), 10);
if (!isNaN(teamId)) recorderTeamId = teamId;
}
}

View file

@ -118,7 +118,9 @@ export function streamEntityToGameEntity(
renderType: "Player",
shapeName: entity.dataBlock,
dataBlock: entity.dataBlock,
emap: entity.emap,
weaponShape: entity.weaponShape,
armAction: entity.armAction,
packShape: entity.packShape,
flagShape: entity.flagShape,
falling: entity.falling,
@ -219,7 +221,10 @@ export function streamEntityToGameEntity(
? "Item"
: "StaticShape",
dataBlock: entity.dataBlock,
emap: entity.emap,
damageState: entity.damageState,
weaponShape: entity.weaponShape,
armAction: entity.armAction,
threads: entity.threads,
targetRenderFlags: entity.targetRenderFlags,
iffColor: entity.iffColor,

View file

@ -8,6 +8,7 @@ import type {
WayPointEntity,
} from "../state/gameEntityTypes";
import { getPosition, getProperty, getScale } from "../mission";
import { parseColorTuple } from "../colorUtils";
import {
terrainFromMis,
interiorFromMis,
@ -35,11 +36,6 @@ function isTruthy(value: unknown): boolean {
return !!value;
}
function parseColor3(colorStr: string): [number, number, number] {
const parts = colorStr.split(" ").map((s) => parseFloat(s));
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
function parseRotationToQuat(
rotationStr: string,
): [number, number, number, number] {
@ -215,6 +211,10 @@ function buildShapeEntity(
shapeName,
shapeType,
dataBlock: datablockName || undefined,
emap:
getProperty(datablock, "emap") != null
? isTruthy(getProperty(datablock, "emap"))
: undefined,
teamId,
};
@ -251,7 +251,7 @@ function buildForceFieldEntity(
): ForceFieldBareEntity {
const colorStr = getProperty(datablock, "color");
const color = colorStr
? parseColor3(colorStr)
? parseColorTuple(colorStr)
: ([1, 1, 1] as [number, number, number]);
const baseTranslucency =
parseFloat(getProperty(datablock, "baseTranslucency")) || 1;

View file

@ -9,7 +9,9 @@ import {
NoColorSpace,
Object3D,
Quaternion,
QuaternionKeyframeTrack,
Vector3,
VectorKeyframeTrack,
} from "three";
import type {
BufferGeometry,
@ -27,7 +29,6 @@ import {
getFrameIndexForTime,
updateAtlasFrame,
} from "../components/useIflTexture";
import { getHullBoneIndices } from "../meshUtils";
import { loadTexture, setupTexture } from "../textureUtils";
import { textureToUrl } from "../loaders";
import type { Keyframe } from "./types";
@ -141,6 +142,51 @@ export function getKeyframeAtTime(
return keyframes[lo];
}
/**
* Build a 1-frame AnimationClip that captures the skeleton's rest/bind pose
* for every bone track in `targetClip`. Used as a reference for
* `makeClipAdditive` on DTS blend sequences the Blender glTF exporter
* bakes `rest * delta` into blend keyframes, so subtracting the rest pose
* recovers the pure deltas needed for Three.js additive blending.
*/
export function buildRestPoseClip(
scene: Object3D,
targetClip: AnimationClip,
): AnimationClip {
// Build a name → node lookup from the scene graph.
const nodesByName = new Map<string, Object3D>();
scene.traverse((n) => {
if (n.name) nodesByName.set(n.name, n);
});
const tracks: (QuaternionKeyframeTrack | VectorKeyframeTrack)[] = [];
for (const track of targetClip.tracks) {
// Track names are like "BoneName.quaternion" or "BoneName.position".
const dotIdx = track.name.lastIndexOf(".");
if (dotIdx === -1) continue;
const nodeName = track.name.slice(0, dotIdx);
const prop = track.name.slice(dotIdx + 1);
const node = nodesByName.get(nodeName);
if (!node) continue;
if (prop === "quaternion") {
const q = node.quaternion;
tracks.push(
new QuaternionKeyframeTrack(track.name, [0], [q.x, q.y, q.z, q.w]),
);
} else if (prop === "position") {
const p = node.position;
tracks.push(new VectorKeyframeTrack(track.name, [0], [p.x, p.y, p.z]));
} else if (prop === "scale") {
const s = node.scale;
tracks.push(new VectorKeyframeTrack(track.name, [0], [s.x, s.y, s.z]));
}
}
return new AnimationClip("_restPose", 0, tracks);
}
/**
* Clone a shape scene, apply the "Root" idle animation at t=0, and return the
* world-space transform of the named node. This evaluates the skeleton at its
@ -193,51 +239,6 @@ export function getPosedNodeTransform(
return { position, quaternion };
}
/**
* Remove faces influenced by hull bones, mutating the geometry in place.
* Unlike filterGeometryByVertexGroups (which clones), this is safe when the
* geometry is already our own copy (e.g. from SkeletonUtils.clone).
*/
function filterHullFaces(
geometry: BufferGeometry,
hullBoneIndices: Set<number>,
): void {
if (hullBoneIndices.size === 0 || !geometry.attributes.skinIndex) return;
const skinIndex = geometry.attributes.skinIndex;
const skinWeight = geometry.attributes.skinWeight;
const index = geometry.index;
if (!index) return;
const vertexHasHullInfluence = new Array(skinIndex.count).fill(false);
for (let i = 0; i < skinIndex.count; i++) {
for (let j = 0; j < 4; j++) {
const boneIndex = skinIndex.array[i * 4 + j];
const weight = skinWeight.array[i * 4 + j];
if (weight > 0.01 && hullBoneIndices.has(boneIndex)) {
vertexHasHullInfluence[i] = true;
break;
}
}
}
const newIndices: number[] = [];
const indexArray = index.array;
for (let i = 0; i < indexArray.length; i += 3) {
const i0 = indexArray[i];
const i1 = indexArray[i + 1];
const i2 = indexArray[i + 2];
if (
!vertexHasHullInfluence[i0] &&
!vertexHasHullInfluence[i1] &&
!vertexHasHullInfluence[i2]
) {
newIndices.push(i0, i1, i2);
}
}
geometry.setIndex(newIndices);
}
/**
* Smooth vertex normals across co-located split vertices (same position, different
* UVs). Matches the technique used by ShapeModel for consistent lighting.
@ -307,10 +308,11 @@ export function replaceWithShapeMaterial(
mat: MeshStandardMaterial,
vis: number,
isOrganic = false,
options: { anisotropy?: number } = {},
options: { anisotropy?: number; emap?: boolean } = {},
): ShapeMaterialResult {
const resourcePath: string | undefined = mat.userData?.resource_path;
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
const reflectionAmount: number = mat.userData?.reflection_amount ?? 1.0;
if (!resourcePath) {
// No texture path — plain Lambert fallback with fog/lighting shaders.
@ -326,6 +328,9 @@ export function replaceWithShapeMaterial(
// IFL materials need async atlas loading — create with null map to avoid
// "Resource not found" warnings from textureToUrl, and return an initializer
// that loads the atlas and sets up per-frame animation.
const emapEnabled = !!options.emap;
const effectiveReflectionAmount = emapEnabled ? reflectionAmount : 0;
if (flagNames.has("IflMaterial")) {
const result = createMaterialFromFlags(
mat,
@ -333,6 +338,8 @@ export function replaceWithShapeMaterial(
flagNames,
isOrganic,
vis,
false,
effectiveReflectionAmount,
);
if (Array.isArray(result)) {
const material = result[1];
@ -371,6 +378,8 @@ export function replaceWithShapeMaterial(
flagNames,
isOrganic,
vis,
false,
effectiveReflectionAmount,
);
if (Array.isArray(result)) {
return { material: result[1], backMaterial: result[0] };
@ -397,7 +406,10 @@ async function initializeIflMaterial(
let disposed = false;
const prevOnBeforeRender = mesh.onBeforeRender;
mesh.onBeforeRender = function (this: any, ...args: Parameters<typeof mesh.onBeforeRender>) {
mesh.onBeforeRender = function (
this: any,
...args: Parameters<typeof mesh.onBeforeRender>
) {
prevOnBeforeRender?.apply(this, args);
if (disposed) return;
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, getTime()));
@ -417,28 +429,27 @@ async function initializeIflMaterial(
export function processShapeScene(
scene: Object3D,
shapeName?: string,
options: { anisotropy?: number } = {},
options: { anisotropy?: number; emap?: boolean } = {},
): IflInitializer[] {
const iflInitializers: IflInitializer[] = [];
const isOrganic = shapeName ? isOrganicShape(shapeName) : false;
// Find skeleton for hull bone filtering.
let skeleton: any = null;
scene.traverse((n: any) => {
if (!skeleton && n.skeleton) skeleton = n.skeleton;
});
const hullBoneIndices = skeleton
? getHullBoneIndices(skeleton)
: new Set<number>();
// Collect back-face meshes to add after traversal (can't modify during traverse).
const backFaceMeshes: Array<{ parent: Object3D; mesh: any }> = [];
scene.traverse((node: any) => {
if (!node.isMesh) return;
// Hide unwanted nodes: hull geometry, unassigned materials.
if (node.name.match(/^Hulk/i) || node.material?.name === "Unassigned") {
// Hide collision-only meshes. In Torque, these live in detail levels with
// size < 0 (utility details). The Blender addon exports dts_detail_size.
if (node.material?.name === "Unassigned") {
node.visible = false;
return;
}
if (
typeof node.userData?.dts_detail_size === "number" &&
node.userData.dts_detail_size < 0
) {
node.visible = false;
return;
}
@ -446,23 +457,20 @@ export function processShapeScene(
// Hide vis-animated meshes (default vis < 0.01) but DON'T skip material
// replacement — they need correct textures for when they become visible
// (e.g. disc launcher's Disc mesh toggles visibility via state machine).
const hasVisSequence = !!node.userData?.vis_sequence;
if ((node.userData?.vis ?? 1) < 0.01) {
node.visible = false;
}
// Filter hull-influenced triangles and smooth normals.
// SkeletonUtils.clone already deep-clones geometry, so no extra clone
// is needed — we can mutate in place.
if (node.geometry) {
filterHullFaces(node.geometry, hullBoneIndices);
smoothVertexNormals(node.geometry);
}
// Replace PBR materials with diffuse-only Lambert materials.
// For vis-animated meshes, use vis=1 so the material is fully opaque —
// their visibility is toggled via node.visible, not material opacity.
const vis: number = hasVisSequence ? 1 : (node.userData?.vis ?? 1);
const vis: number = node.userData?.vis_sequence
? 1
: (node.userData?.vis ?? 1);
if (Array.isArray(node.material)) {
node.material = node.material.map((m: MeshStandardMaterial) => {
const result = replaceWithShapeMaterial(m, vis, isOrganic, options);

View file

@ -77,8 +77,7 @@ export function pickMoveAnimation(
// be skiing/airborne since the engine limits grounded speed.
const horizontalSpeedSq = vx * vx + vy * vy;
const airborne =
Math.abs(vz) > 2 ||
horizontalSpeedSq > MAX_GROUND_SPEED * MAX_GROUND_SPEED;
Math.abs(vz) > 2 || horizontalSpeedSq > MAX_GROUND_SPEED * MAX_GROUND_SPEED;
if (airborne) {
// Tribes2.exe checks mJetting here: jetting → JetAnim, else → RootAnim.

View file

@ -108,6 +108,8 @@ export interface StreamEntity {
className?: string;
dataBlockId?: number;
shapeHint?: string;
/** Whether the datablock enables environment map reflections. */
emap?: boolean;
/** Position in Torque space [x, y, z]. */
position?: [number, number, number];
/** Quaternion in Three.js space [x, y, z, w]. */
@ -154,7 +156,11 @@ export interface StreamEntity {
audioMinLoopGap?: number;
audioMaxLoopGap?: number;
/** WheeledVehicle per-wheel state. */
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
wheels?: Array<{
speed: number;
lateralSlip: number;
longitudinalSlip: number;
}>;
/** Vehicle steering angle (radians). */
steeringYaw?: number;
/** Vehicle frozen state (deployed). */

View file

@ -15,6 +15,10 @@ import {
} from "three";
const _bitmapLoader = new ImageBitmapLoader();
// Prevent the browser from premultiplying alpha, which destroys RGB data in
// transparent pixels. Tribes 2 uses the alpha channel for purposes other than
// transparency (e.g. environment map masking) on many non-Translucent textures.
_bitmapLoader.setOptions({ premultiplyAlpha: "none" });
const _textureCache = new Map<string, Texture>();
/**
@ -86,6 +90,8 @@ export interface TextureSetupOptions {
disableMipmaps?: boolean;
/** Override anisotropy level. Default: max supported by the GPU. */
anisotropy?: number;
/** Skip sRGB colorspace assignment (for textures sampled in sRGB space). */
noColorSpace?: boolean;
}
/**
@ -99,10 +105,17 @@ export function setupTexture<T extends Texture>(
tex: T,
options: TextureSetupOptions = {},
): T {
const { repeat = [1, 1], disableMipmaps = false, anisotropy } = options;
const {
repeat = [1, 1],
disableMipmaps = false,
anisotropy,
noColorSpace = false,
} = options;
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.colorSpace = SRGBColorSpace;
if (!noColorSpace) {
tex.colorSpace = SRGBColorSpace;
}
tex.repeat.set(...repeat);
tex.flipY = false; // DDS/DIF textures are already flipped
tex.anisotropy = anisotropy ?? 1;