mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-02 03:53:52 +00:00
add IFL texture animation
This commit is contained in:
parent
25449af198
commit
af17b43584
2506 changed files with 393603 additions and 6536 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
80
src/components/TickProvider.tsx
Normal file
80
src/components/TickProvider.tsx
Normal 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;
|
||||
}
|
||||
144
src/components/useIflTexture.ts
Normal file
144
src/components/useIflTexture.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue