mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-23 22:29:31 +00:00
173 lines
5.2 KiB
TypeScript
173 lines
5.2 KiB
TypeScript
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 { iflTextureToUrl, loadImageFrameList } from "../loaders";
|
|
import { loadTextureAsync } from "../textureUtils";
|
|
import { useTick, TICK_RATE } from "./TickProvider";
|
|
import { useSettings } from "./SettingsProvider";
|
|
|
|
/** One IFL tick in seconds (Torque converts at 1/30s per tick). */
|
|
export const IFL_TICK_SECONDS = 1 / 30;
|
|
|
|
export interface IflAtlas {
|
|
texture: CanvasTexture;
|
|
columns: number;
|
|
rows: number;
|
|
frameCount: number;
|
|
/** Cumulative end time (seconds) for each frame. */
|
|
frameOffsetSeconds: number[];
|
|
/** Total IFL cycle duration in seconds. */
|
|
totalDurationSeconds: 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 firstImage = textures[0].image as HTMLImageElement | ImageBitmap;
|
|
const frameWidth = firstImage.width;
|
|
const frameHeight = firstImage.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 as CanvasImageSource,
|
|
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,
|
|
frameOffsetSeconds: [],
|
|
totalDurationSeconds: 0,
|
|
lastFrame: -1,
|
|
};
|
|
}
|
|
|
|
function computeTiming(
|
|
atlas: IflAtlas,
|
|
frames: { name: string; frameCount: number }[],
|
|
) {
|
|
let cumulativeSeconds = 0;
|
|
atlas.frameOffsetSeconds = frames.map((frame) => {
|
|
cumulativeSeconds += frame.frameCount * IFL_TICK_SECONDS;
|
|
return cumulativeSeconds;
|
|
});
|
|
atlas.totalDurationSeconds = cumulativeSeconds;
|
|
}
|
|
|
|
export 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);
|
|
}
|
|
|
|
/**
|
|
* Find the frame index for a given time in seconds. Matches Torque's
|
|
* `animateIfls()` lookup using cumulative `iflFrameOffTimes`.
|
|
*/
|
|
export function getFrameIndexForTime(atlas: IflAtlas, seconds: number): number {
|
|
const dur = atlas.totalDurationSeconds;
|
|
if (dur <= 0) return 0;
|
|
let t = seconds;
|
|
if (t > dur) t -= dur * Math.floor(t / dur);
|
|
for (let i = 0; i < atlas.frameOffsetSeconds.length; i++) {
|
|
if (t <= atlas.frameOffsetSeconds[i]) return i;
|
|
}
|
|
return atlas.frameOffsetSeconds.length - 1;
|
|
}
|
|
|
|
/**
|
|
* Imperatively load an IFL atlas (all frames). Returns a cached atlas if the
|
|
* same IFL has been loaded before. The returned atlas can be animated
|
|
* per-frame with `updateAtlasFrame` + `getFrameIndexForTime`.
|
|
*/
|
|
export async function loadIflAtlas(iflPath: string): Promise<IflAtlas> {
|
|
const cached = atlasCache.get(iflPath);
|
|
if (cached) return cached;
|
|
|
|
const frames = await loadImageFrameList(iflPath);
|
|
const urls = frames.map((f) => iflTextureToUrl(f.name, iflPath));
|
|
const textures = await Promise.all(urls.map(loadTextureAsync));
|
|
|
|
const atlas = createAtlas(textures);
|
|
computeTiming(atlas, frames);
|
|
atlasCache.set(iflPath, atlas);
|
|
|
|
return atlas;
|
|
}
|
|
|
|
/**
|
|
* 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) => iflTextureToUrl(frame.name, iflPath)),
|
|
[frames, iflPath],
|
|
);
|
|
|
|
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 time = tick / TICK_RATE;
|
|
const frameIndex = animationEnabled ? getFrameIndexForTime(atlas, time) : 0;
|
|
updateAtlasFrame(atlas, frameIndex);
|
|
});
|
|
|
|
return atlas.texture;
|
|
}
|