add IFL texture animation

This commit is contained in:
Brian Beck 2025-12-01 22:33:12 -08:00
parent 25449af198
commit af17b43584
2506 changed files with 393603 additions and 6536 deletions

View file

@ -1,4 +1,4 @@
import { memo, Suspense, useMemo } from "react";
import { memo, Suspense, useMemo, useRef, useEffect } from "react";
import { useGLTF, useTexture } from "@react-three/drei";
import {
FALLBACK_TEXTURE_URL,
@ -15,6 +15,7 @@ import { setupColor } from "../textureUtils";
import { useDebug } from "./SettingsProvider";
import { useShapeInfo } from "./ShapeInfoProvider";
import { FloatingLabel } from "./FloatingLabel";
import { useIflTexture } from "./useIflTexture";
/**
* Load a .glb file that was converted from a .dts, used for static shapes.
@ -24,28 +25,57 @@ export function useStaticShape(shapeName: string) {
return useGLTF(url);
}
export function ShapeTexture({
/**
* Animated IFL (Image File List) material component. Creates a sprite sheet
* from all frames and animates via texture offset.
*/
export function IflTexture({
material,
shapeName,
}: {
material?: MeshStandardMaterial;
material: MeshStandardMaterial;
shapeName?: string;
}) {
const resourcePath = material.userData.resource_path;
// Convert resource_path (e.g., "skins/blue00") to IFL path
const iflPath = `textures/${resourcePath}.ifl`;
const texture = useIflTexture(iflPath);
const isOrganic = shapeName && /borg|xorg|porg|dorg/i.test(shapeName);
const customMaterial = useMemo(() => {
if (!isOrganic) {
const shaderMaterial = createAlphaAsRoughnessMaterial();
shaderMaterial.map = texture;
return shaderMaterial;
}
const clonedMaterial = material.clone();
clonedMaterial.map = texture;
clonedMaterial.transparent = true;
clonedMaterial.alphaTest = 0.9;
clonedMaterial.side = 2; // DoubleSide
return clonedMaterial;
}, [material, texture, isOrganic]);
return <primitive object={customMaterial} attach="material" />;
}
function StaticTexture({ material, shapeName }) {
const resourcePath = material.userData.resource_path;
const url = useMemo(() => {
const flagNames = new Set(material.userData.flag_names ?? []);
const isIflMaterial = flagNames.has("IflMaterial");
const resourcePath = material.userData.resource_path;
if (!resourcePath) {
console.warn(
`Material index out of range on shape "${shapeName}" - rendering fallback.`,
);
}
return resourcePath && !isIflMaterial
return resourcePath
? // Use custom `resource_path` added by forked io_dts3d Blender add-on
shapeTextureToUrl(resourcePath)
: // Not supported yet
FALLBACK_TEXTURE_URL;
}, [material]);
: FALLBACK_TEXTURE_URL;
}, [material, resourcePath, shapeName]);
const isOrganic = shapeName && /borg|xorg|porg|dorg/i.test(shapeName);
@ -76,6 +106,25 @@ export function ShapeTexture({
return <primitive object={customMaterial} attach="material" />;
}
export function ShapeTexture({
material,
shapeName,
}: {
material?: MeshStandardMaterial;
shapeName?: string;
}) {
const flagNames = new Set(material.userData.flag_names ?? []);
const isIflMaterial = flagNames.has("IflMaterial");
const resourcePath = material.userData.resource_path;
// Use IflTexture for animated materials
if (isIflMaterial && resourcePath) {
return <IflTexture material={material} shapeName={shapeName} />;
} else {
return <StaticTexture material={material} shapeName={shapeName} />;
}
}
export function ShapePlaceholder({
color,
label,

View file

@ -10,31 +10,48 @@ const excludeMissions = new Set([
]);
const sourceGroupNames = {
"Classic_maps_v1.vl2": "Classic",
"DynamixFinalPack.vl2": "Official",
"missions.vl2": "Official",
"S5maps.vl2": "S5",
"S8maps.vl2": "S8",
"SkiFreeGameType.vl2": "SkiFree",
"TR2final105-client.vl2": "Team Rabbit 2",
"TWL-MapPack.vl2": "TWL",
"TWL2-MapPack.vl2": "TWL2",
"z_DMP2-V0.6.vl2": "DMP2 (Discord Map Pack)",
"zAddOnsVL2s/TWL_T2arenaOfficialMaps.vl2": "Arena",
"zAddOnsVL2s/zDiscord-Map-Pack-4.7.1.vl2": "DMP (Discord Map Pack)",
"z_mappacks/CTF/Classic_maps_v1.vl2": "Classic",
"z_mappacks/CTF/DynamixFinalPack.vl2": "Official",
"z_mappacks/CTF/KryMapPack_b3EDIT.vl2": "KryMapPack",
"z_mappacks/CTF/S5maps.vl2": "S5",
"z_mappacks/CTF/S8maps.vl2": "S8",
"z_mappacks/CTF/TWL-MapPack.vl2": "TWL",
"z_mappacks/CTF/TWL-MapPackEDIT.vl2": "TWL",
"z_mappacks/CTF/TWL2-MapPack.vl2": "TWL2",
"z_mappacks/CTF/TWL2-MapPackEDIT.vl2": "TWL2",
"z_mappacks/TWL_T2arenaOfficialMaps.vl2": "Arena",
"z_mappacks/z_DMP2-V0.6.vl2": "DMP2 (Discord Map Pack)",
"z_mappacks/zDMP-4.7.3DX.vl2": "DMP (Discord Map Pack)",
// "SkiFreeGameType.vl2": "SkiFree",
};
const dirGroupNames = {
"z_mappacks/DM": "DM",
"z_mappacks/LCTF": "LCTF",
"z_mappacks/Lak": "LakRabbit",
};
const getDirName = (sourcePath: string) => {
const match = sourcePath.match(/^(.*)(\/[^/]+)$/);
return match ? match[1] : "";
};
const groupedMissions = getMissionList().reduce(
(groupMap, missionName) => {
const missionInfo = getMissionInfo(missionName);
const [sourcePath] = getSourceAndPath(missionInfo.resourcePath);
const groupName = sourceGroupNames[sourcePath] ?? null;
const sourceDir = getDirName(sourcePath);
const groupName =
sourceGroupNames[sourcePath] ?? dirGroupNames[sourceDir] ?? null;
const groupMissions = groupMap.get(groupName) ?? [];
if (!excludeMissions.has(missionName)) {
groupMissions.push({
resourcePath: missionInfo.resourcePath,
missionName,
displayName: missionInfo.displayName,
sourcePath,
});
groupMap.set(groupName, groupMissions);
}
@ -46,6 +63,7 @@ const groupedMissions = getMissionList().reduce(
resourcePath: string;
missionName: string;
displayName: string;
sourcePath: string;
}>
>(),
);
@ -78,6 +96,8 @@ export function InspectorControls({
setFov,
audioEnabled,
setAudioEnabled,
animationEnabled,
setAnimationEnabled,
} = useSettings();
const { speedMultiplier, setSpeedMultiplier } = useControls();
const { debugMode, setDebugMode } = useDebug();
@ -150,6 +170,17 @@ export function InspectorControls({
/>
<label htmlFor="audioInput">Audio?</label>
</div>
<div className="CheckboxField">
<input
id="animationInput"
type="checkbox"
checked={animationEnabled}
onChange={(event) => {
setAnimationEnabled(event.target.checked);
}}
/>
<label htmlFor="animationInput">Animation?</label>
</div>
<div className="CheckboxField">
<input
id="debugInput"
@ -164,7 +195,7 @@ export function InspectorControls({
<div className="Field">
<label htmlFor="fovInput">FOV</label>
<input
id="speedInput"
id="fovInput"
type="range"
min={75}
max={120}

View file

@ -8,6 +8,7 @@ import {
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
import { renderObject } from "./renderObject";
import { memo, useEffect, useState } from "react";
import { TickProvider } from "./TickProvider";
const loadScript = createScriptLoader();
@ -70,5 +71,9 @@ export const Mission = memo(function Mission({ name }: { name: string }) {
return null;
}
return executedMission.objects.map((object, i) => renderObject(object, i));
return (
<TickProvider>
{executedMission.objects.map((object, i) => renderObject(object, i))}
</TickProvider>
);
});

View file

@ -17,6 +17,7 @@ type PersistedSettings = {
speedMultiplier?: number;
fov?: number;
audioEnabled?: boolean;
animationEnabled?: boolean;
debugMode?: boolean;
};
@ -37,6 +38,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [speedMultiplier, setSpeedMultiplier] = useState(1);
const [fov, setFov] = useState(90);
const [audioEnabled, setAudioEnabled] = useState(false);
const [animationEnabled, setAnimationEnabled] = useState(true);
const [debugMode, setDebugMode] = useState(false);
const settingsContext = useMemo(
@ -47,8 +49,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setFov,
audioEnabled,
setAudioEnabled,
animationEnabled,
setAnimationEnabled,
}),
[fogEnabled, speedMultiplier, fov, audioEnabled],
[fogEnabled, speedMultiplier, fov, audioEnabled, animationEnabled],
);
const debugContext = useMemo(
@ -75,6 +79,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
if (savedSettings.audioEnabled != null) {
setAudioEnabled(savedSettings.audioEnabled);
}
if (savedSettings.animationEnabled != null) {
setAnimationEnabled(savedSettings.animationEnabled);
}
if (savedSettings.fogEnabled != null) {
setFogEnabled(savedSettings.fogEnabled);
}
@ -102,6 +109,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
speedMultiplier,
fov,
audioEnabled,
animationEnabled,
debugMode,
};
try {
@ -116,7 +124,14 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
clearTimeout(saveTimerRef.current);
}
};
}, [fogEnabled, speedMultiplier, fov, audioEnabled, debugMode]);
}, [
fogEnabled,
speedMultiplier,
fov,
audioEnabled,
animationEnabled,
debugMode,
]);
return (
<SettingsContext.Provider value={settingsContext}>

View file

@ -0,0 +1,80 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
import { useFrame } from "@react-three/fiber";
export const TICK_RATE = 32;
const TICK_INTERVAL = 1 / TICK_RATE;
type TickCallback = (tick: number) => void;
type TickContextValue = {
subscribe: (callback: TickCallback) => () => void;
getTick: () => number;
};
const TickContext = createContext<TickContextValue | null>(null);
export function TickProvider({ children }: { children: ReactNode }) {
const callbacksRef = useRef<Set<TickCallback> | undefined>(undefined);
const accumulatorRef = useRef(0);
const tickRef = useRef(0);
useFrame((_, delta) => {
accumulatorRef.current += delta;
while (accumulatorRef.current >= TICK_INTERVAL) {
accumulatorRef.current -= TICK_INTERVAL;
tickRef.current++;
if (callbacksRef.current) {
for (const callback of callbacksRef.current) {
callback(tickRef.current);
}
}
}
});
const subscribe = useCallback((callback: TickCallback) => {
callbacksRef.current ??= new Set();
callbacksRef.current.add(callback);
return () => {
callbacksRef.current!.delete(callback);
};
}, []);
const getTick = useCallback(() => tickRef.current, []);
const context = useMemo(() => ({ subscribe, getTick }), [subscribe, getTick]);
return (
<TickContext.Provider value={context}>{children}</TickContext.Provider>
);
}
export function useTick(callback: TickCallback) {
const context = useContext(TickContext);
if (!context) {
throw new Error("useTick must be used within a TickProvider");
}
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
return context.subscribe((tick) => callbackRef.current(tick));
}, [context]);
}
export function useGetTick() {
const context = useContext(TickContext);
if (!context) {
throw new Error("useGetTick must be used within a TickProvider");
}
return context.getTick;
}

View file

@ -0,0 +1,144 @@
import { useMemo } from "react";
import { useTexture } from "@react-three/drei";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
CanvasTexture,
ClampToEdgeWrapping,
NearestFilter,
SRGBColorSpace,
Texture,
} from "three";
import { loadImageFrameList, textureFrameToUrl } from "../loaders";
import { useTick } from "./TickProvider";
import { useSettings } from "./SettingsProvider";
interface IflAtlas {
texture: CanvasTexture;
columns: number;
rows: number;
frameCount: number;
/** Tick at which each frame starts (cumulative). */
frameStartTicks: number[];
/** Total ticks for one complete animation cycle. */
totalTicks: number;
/** Last rendered frame index, to avoid redundant offset updates. */
lastFrame: number;
}
// Module-level cache for atlas textures, shared across all components.
const atlasCache = new Map<string, IflAtlas>();
function createAtlas(textures: Texture[]): IflAtlas {
const frameWidth = textures[0].image.width;
const frameHeight = textures[0].image.height;
const frameCount = textures.length;
// Arrange frames in a roughly square grid.
const columns = Math.ceil(Math.sqrt(frameCount));
const rows = Math.ceil(frameCount / columns);
const canvas = document.createElement("canvas");
canvas.width = frameWidth * columns;
canvas.height = frameHeight * rows;
const ctx = canvas.getContext("2d")!;
textures.forEach((tex, i) => {
const col = i % columns;
const row = Math.floor(i / columns);
ctx.drawImage(tex.image, col * frameWidth, row * frameHeight);
});
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = NearestFilter;
texture.magFilter = NearestFilter;
texture.wrapS = ClampToEdgeWrapping;
texture.wrapT = ClampToEdgeWrapping;
texture.repeat.set(1 / columns, 1 / rows);
return {
texture,
columns,
rows,
frameCount,
frameStartTicks: [],
totalTicks: 0,
lastFrame: -1,
};
}
function computeTiming(
atlas: IflAtlas,
frames: { name: string; frameCount: number }[],
) {
let totalTicks = 0;
atlas.frameStartTicks = frames.map((frame) => {
const start = totalTicks;
totalTicks += frame.frameCount;
return start;
});
atlas.totalTicks = totalTicks;
}
function updateAtlasFrame(atlas: IflAtlas, frameIndex: number) {
if (frameIndex === atlas.lastFrame) return;
atlas.lastFrame = frameIndex;
const col = frameIndex % atlas.columns;
// Flip row: canvas Y=0 is top, but texture V=0 is bottom.
const row = atlas.rows - 1 - Math.floor(frameIndex / atlas.columns);
atlas.texture.offset.set(col / atlas.columns, row / atlas.rows);
}
function getFrameIndexForTick(atlas: IflAtlas, tick: number): number {
if (atlas.totalTicks === 0) return 0;
const cycleTick = tick % atlas.totalTicks;
const { frameStartTicks } = atlas;
// Binary search would be faster for many frames, but linear is fine for typical IFLs.
for (let i = frameStartTicks.length - 1; i >= 0; i--) {
if (cycleTick >= frameStartTicks[i]) {
return i;
}
}
return 0;
}
/**
* Loads an IFL (Image File List) and returns an animated texture.
* The texture atlas is shared across all components using the same IFL path.
*/
export function useIflTexture(iflPath: string): Texture {
const { animationEnabled } = useSettings();
const { data: frames } = useSuspenseQuery({
queryKey: ["ifl", iflPath],
queryFn: () => loadImageFrameList(iflPath),
});
const textureUrls = useMemo(
() => frames.map((frame) => textureFrameToUrl(frame.name)),
[frames],
);
const textures = useTexture(textureUrls);
const atlas = useMemo(() => {
let cached = atlasCache.get(iflPath);
if (!cached) {
cached = createAtlas(textures);
atlasCache.set(iflPath, cached);
}
computeTiming(cached, frames);
return cached;
}, [iflPath, textures, frames]);
useTick((tick) => {
const frameIndex = animationEnabled ? getFrameIndexForTick(atlas, tick) : 0;
updateAtlasFrame(atlas, frameIndex);
});
return atlas.texture;
}

View file

@ -1,3 +1,11 @@
/**
* Parse an IFL file, for frame based image animation.
*
* See:
* - https://help.autodesk.com/view/3DSMAX/2025/ENU/?guid=GUID-CA63616D-9E87-42FC-8E84-D67E1990EE71
* - https://docs.torque3d.org/for-artists/materials/material-animation
* - http://wiki.torque3d.org/artist:mapping-materials-to-textures#toc4
*/
export function parseImageFileList(source: string) {
const lines = source
.split(/(?:\r\n|\r|\n)/g)

View file

@ -73,19 +73,11 @@ export function createBuiltins(
isobject(obj: any): boolean {
return runtime().$.isObject(obj);
},
typeof(obj: any): string {
if (obj == null) return "";
if (typeof obj === "object" && obj._class) return obj._className;
return typeof obj;
},
// Object lookup
nametoid(name: string): number {
return runtime().$.nameToId(name);
},
isfunction(name: string): boolean {
return runtime().$.isFunction(name);
},
// String functions
strlen(str: any): number {
@ -310,12 +302,6 @@ export function createBuiltins(
const max = Number(b) || 0;
return Math.floor(Math.random() * (max - min + 1)) + min;
},
getrandomseed(): number {
throw new Error("getRandomSeed() not implemented");
},
setrandomseed(_seed: any): void {
throw new Error("setRandomSeed() not implemented");
},
mdegtorad(deg: any): number {
return (Number(deg) || 0) * (Math.PI / 180);
},

View file

@ -1,5 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { createRuntime } from "./runtime";
import type { TorqueRuntimeOptions } from "./types";
import { parse, transpile } from "./index";
@ -7,9 +6,11 @@ import { parse, transpile } from "./index";
function run(script: string, options?: TorqueRuntimeOptions) {
const { $, $f, $g, state } = createRuntime(options);
const { code } = transpile(script);
const fn = new Function("$", "$f", "$g", code);
fn($, $f, $g);
return { $, $f, $g, state };
// Provide $l (locals) at module scope for top-level local variable access
const $l = $.locals();
const fn = new Function("$", "$f", "$g", "$l", code);
fn($, $f, $g, $l);
return { $, $f, $g, $l, state };
}
describe("TorqueScript Runtime", () => {
@ -78,6 +79,33 @@ describe("TorqueScript Runtime", () => {
`);
expect($g.get("result")).toBe(120);
});
it("handles top-level local variables", () => {
const { $g, $l } = run(`
%x = 5;
%y = 10;
$result = %x * %y;
%name = "test";
`);
expect($g.get("result")).toBe(50);
expect($l.get("x")).toBe(5);
expect($l.get("y")).toBe(10);
expect($l.get("name")).toBe("test");
});
it("top-level locals are separate from function locals", () => {
const { $g } = run(`
%x = 100;
function getX() {
%x = 42;
return %x;
}
$funcResult = getX();
$topResult = %x;
`);
expect($g.get("funcResult")).toBe(42);
expect($g.get("topResult")).toBe(100);
});
});
describe("string operations", () => {
@ -725,86 +753,95 @@ describe("TorqueScript Runtime", () => {
});
});
describe("real TorqueScript files", () => {
const emptyAST = parse("");
describe("complex scripts", () => {
it("sets globals and registers methods", () => {
const { $g, state } = run(`
$Game::numRoles = 3;
$Game::role0 = "Goalie";
$Game::role1 = "Defense";
$Game::role2 = "Offense";
async function runFile(filepath: string) {
const source = readFileSync(filepath, "utf8");
// Provide a loader that returns empty scripts for known dependencies
const runtime = createRuntime({
loadScript: async (path) => {
// Return empty script for known dependencies
if (path.toLowerCase().includes("aitdm")) return "";
return null;
},
});
const script = await runtime.loadFromSource(source);
script.execute();
return {
$: runtime.$,
$f: runtime.$f,
$g: runtime.$g,
state: runtime.state,
};
}
function MyGame::onStart(%game) {
return "started";
}
`);
it("transpiles and executes TR2Roles.cs", async () => {
const { $g, state } = await runFile(
"docs/base/@vl2/TR2final105-server.vl2/scripts/TR2Roles.cs",
);
// Verify globals were set
expect($g.get("TR2::numRoles")).toBe(3);
expect($g.get("TR2::role0")).toBe("Goalie");
expect($g.get("TR2::role1")).toBe("Defense");
expect($g.get("TR2::role2")).toBe("Offense");
// Verify methods were registered
expect(state.methods.has("TR2Game")).toBe(true);
expect($g.get("Game::numRoles")).toBe(3);
expect($g.get("Game::role0")).toBe("Goalie");
expect($g.get("Game::role1")).toBe("Defense");
expect($g.get("Game::role2")).toBe("Offense");
expect(state.methods.has("MyGame")).toBe(true);
});
it("transpiles and executes a .mis mission file", async () => {
const { $, state } = await runFile(
"docs/base/@vl2/4thGradeDropout.vl2/missions/4thGradeDropout.mis",
);
it("creates nested object hierarchies like mission files", () => {
const { $, state } = run(`
new SimGroup(MissionGroup) {
new TerrainBlock(Terrain) {
size = 1024;
};
new Sky(Sky) {
cloudSpeed = 1.5;
};
new SimGroup(PlayerDropPoints) {
new SpawnSphere(Spawn1) { position = "0 0 100"; };
new SpawnSphere(Spawn2) { position = "100 0 100"; };
};
};
`);
// Verify MissionGroup was created
const missionGroup = $.deref("MissionGroup");
expect(missionGroup).toBeDefined();
expect(missionGroup._className).toBe("SimGroup");
// Verify child objects were created
const terrain = $.deref("Terrain");
expect(terrain).toBeDefined();
const sky = $.deref("Sky");
expect(sky).toBeDefined();
// Verify object count
expect(state.objectsByName.size).toBeGreaterThan(10);
expect($.deref("Terrain")).toBeDefined();
expect($.deref("Sky")).toBeDefined();
expect($.deref("Spawn1")).toBeDefined();
expect($.deref("Spawn2")).toBeDefined();
expect(state.objectsByName.size).toBe(6);
});
it("transpiles TDMGame.cs with methods and parent calls", async () => {
const source = readFileSync(
"docs/base/@vl2/z_DMP2-V0.6.vl2/scripts/TDMGame.cs",
"utf-8",
);
const runtime = createRuntime({
loadScript: async (path) => {
if (path.toLowerCase().includes("aitdm")) return "";
return null;
},
});
const script = await runtime.loadFromSource(source);
script.execute();
it("supports namespace method calls", () => {
const { $g } = run(`
function BaseGame::getValue(%game) {
return 10;
}
// Verify methods were registered on TDMGame
expect(runtime.state.methods.has("TDMGame")).toBe(true);
const tdmMethods = runtime.state.methods.get("TDMGame");
expect(tdmMethods).toBeDefined();
function BaseGame::getDoubled(%game) {
return BaseGame::getValue(%game) * 2;
}
// Verify transpiled code contains parent calls
const { code } = transpile(source);
$base = BaseGame::getValue(0);
$doubled = BaseGame::getDoubled(0);
`);
expect($g.get("base")).toBe(10);
expect($g.get("doubled")).toBe(20);
});
it("supports parent:: in package overrides", () => {
const { $g } = run(`
function doSomething() {
return 10;
}
package MyOverride {
function doSomething() {
return Parent::doSomething() + 5;
}
};
$result = doSomething();
`);
expect($g.get("result")).toBe(15);
});
it("generates parent calls in transpiled code", () => {
const { code } = transpile(`
function MyGame::onEnd(%game) {
Parent::onEnd(%game);
}
`);
expect(code).toContain("$.parent(");
});
});

View file

@ -758,8 +758,10 @@ export function createRuntime(
function executeAST(ast: Program): void {
const code = getOrGenerateCode(ast);
const execFn = new Function("$", "$f", "$g", code);
execFn($, $f, $g);
// Provide $l (locals) at module scope for top-level local variable access
const $l = createLocals();
const execFn = new Function("$", "$f", "$g", "$l", code);
execFn($, $f, $g, $l);
}
function createLoadedScript(ast: Program, path?: string): LoadedScript {