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;
}