fix duplicate images in IFL atlases

This commit is contained in:
Brian Beck 2026-03-18 20:26:05 -07:00
parent 34095f161d
commit fc51095776
31 changed files with 113 additions and 81 deletions

View file

@ -66,7 +66,6 @@ const AudioEmitter = createLazy("AudioEmitter", () => import("./AudioEmitter"));
const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock"));
const WeaponModel = createLazy("WeaponModel", () => import("./ShapeModel"));
/**
* Renders a GameEntity by dispatching to the appropriate renderer based
* on renderType. Does NOT handle positioning the caller is responsible
@ -142,7 +141,9 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
// Flag label for flag Items
const isFlag = entity.dataBlock?.toLowerCase() === "flag";
const teamName =
entity.teamId && entity.teamId > 0 ? DEFAULT_TEAM_NAMES[entity.teamId] : null;
entity.teamId && entity.teamId > 0
? DEFAULT_TEAM_NAMES[entity.teamId]
: null;
const flagLabel = isFlag && teamName ? `${teamName} Flag` : null;
const loadingColor =

View file

@ -451,22 +451,6 @@ export function DebugPlaceholder({
return debugMode ? <ShapePlaceholder color={color} label={label} /> : null;
}
/** Shapes that don't have a .glb conversion and are rendered with built-in
* Three.js geometry instead. These are editor-only markers in Tribes 2. */
const HARDCODED_SHAPES = new Set(["octahedron.dts"]);
function HardcodedShape({ label }: { label?: string }) {
const { debugMode } = useDebug();
if (!debugMode) return null;
return (
<mesh>
<icosahedronGeometry args={[1, 1]} />
<meshBasicMaterial color="cyan" wireframe />
{label ? <FloatingLabel color="cyan">{label}</FloatingLabel> : null}
</mesh>
);
}
/**
* Wrapper component that handles the common ErrorBoundary + Suspense + ShapeModel
* pattern used across shape-rendering components.
@ -489,10 +473,6 @@ export const ShapeRenderer = memo(function ShapeRenderer({
);
}
if (HARDCODED_SHAPES.has(shapeName.toLowerCase())) {
return <HardcodedShape label={`${object?._id}: ${shapeName}`} />;
}
return (
<ErrorBoundary
fallback={

View file

@ -20,34 +20,73 @@ export interface IflAtlas {
texture: CanvasTexture;
columns: number;
rows: number;
frameCount: number;
/** Cumulative end time (seconds) for each frame. */
/** Number of unique image slots in the atlas grid. */
slotCount: number;
/**
* Maps each IFL entry index to its atlas slot. Many entries may point to
* the same slot since IFL files repeat images with different durations.
*/
frameToSlot: number[];
/** Cumulative end time (seconds) for each IFL entry. */
frameOffsetSeconds: number[];
/** Total IFL cycle duration in seconds. */
totalDurationSeconds: number;
/** Last rendered frame index, to avoid redundant offset updates. */
lastFrame: number;
/** Last rendered atlas slot, to avoid redundant offset updates. */
lastSlot: 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;
/**
* Deduplicate IFL frame entries by image name. Returns the list of unique
* names and a mapping from each IFL entry index to its unique slot.
*/
function deduplicateFrames(frames: { name: string }[]): {
uniqueNames: string[];
frameToSlot: number[];
} {
const nameToSlot = new Map<string, number>();
const uniqueNames: string[] = [];
const frameToSlot: number[] = [];
for (const f of frames) {
let slot = nameToSlot.get(f.name);
if (slot === undefined) {
slot = uniqueNames.length;
nameToSlot.set(f.name, slot);
uniqueNames.push(f.name);
}
frameToSlot.push(slot);
}
return { uniqueNames, frameToSlot };
}
/**
* Build an atlas texture containing only the unique images. Each IFL entry
* index maps to an atlas slot via `frameToSlot`.
*/
function createAtlas(
uniqueTextures: Texture[],
frameToSlot: number[],
): IflAtlas {
if (uniqueTextures.length === 0) {
throw new Error("Cannot create IFL atlas with no textures");
}
const firstImage = uniqueTextures[0].image as HTMLImageElement | ImageBitmap;
const frameWidth = firstImage.width;
const frameHeight = firstImage.height;
const frameCount = textures.length;
const slotCount = uniqueTextures.length;
// Arrange frames in a roughly square grid.
const columns = Math.ceil(Math.sqrt(frameCount));
const rows = Math.ceil(frameCount / columns);
// Arrange unique frames in a roughly square grid.
const columns = Math.ceil(Math.sqrt(slotCount));
const rows = Math.ceil(slotCount / columns);
const canvas = document.createElement("canvas");
canvas.width = frameWidth * columns;
canvas.height = frameHeight * rows;
const ctx = canvas.getContext("2d")!;
textures.forEach((tex, i) => {
uniqueTextures.forEach((tex, i) => {
const col = i % columns;
const row = Math.floor(i / columns);
ctx.drawImage(
@ -70,10 +109,11 @@ function createAtlas(textures: Texture[]): IflAtlas {
texture,
columns,
rows,
frameCount,
slotCount,
frameToSlot,
frameOffsetSeconds: [],
totalDurationSeconds: 0,
lastFrame: -1,
lastSlot: -1,
};
}
@ -89,18 +129,23 @@ function computeTiming(
atlas.totalDurationSeconds = cumulativeSeconds;
}
/**
* Set the atlas texture offset to show the image for the given IFL entry.
* Uses `frameToSlot` to map the entry index to the atlas grid position.
*/
export function updateAtlasFrame(atlas: IflAtlas, frameIndex: number) {
if (frameIndex === atlas.lastFrame) return;
atlas.lastFrame = frameIndex;
const slot = atlas.frameToSlot[frameIndex] ?? 0;
if (slot === atlas.lastSlot) return;
atlas.lastSlot = slot;
const col = frameIndex % atlas.columns;
const col = slot % 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);
const row = atlas.rows - 1 - Math.floor(slot / 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
* Find the IFL entry index for a given time in seconds. Matches Torque's
* `animateIfls()` lookup using cumulative `iflFrameOffTimes`.
*/
export function getFrameIndexForTime(atlas: IflAtlas, seconds: number): number {
@ -115,8 +160,8 @@ export function getFrameIndexForTime(atlas: IflAtlas, seconds: number): number {
}
/**
* 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
* Imperatively load an IFL atlas (all unique 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> {
@ -124,10 +169,11 @@ export async function loadIflAtlas(iflPath: string): Promise<IflAtlas> {
if (cached) return cached;
const frames = await loadImageFrameList(iflPath);
const urls = frames.map((f) => iflTextureToUrl(f.name, iflPath));
const { uniqueNames, frameToSlot } = deduplicateFrames(frames);
const urls = uniqueNames.map((name) => iflTextureToUrl(name, iflPath));
const textures = await Promise.all(urls.map(loadTextureAsync));
const atlas = createAtlas(textures);
const atlas = createAtlas(textures, frameToSlot);
computeTiming(atlas, frames);
atlasCache.set(iflPath, atlas);
@ -146,9 +192,14 @@ export function useIflTexture(iflPath: string): Texture {
queryFn: () => loadImageFrameList(iflPath),
});
const { uniqueNames, frameToSlot } = useMemo(
() => deduplicateFrames(frames),
[frames],
);
const textureUrls = useMemo(
() => frames.map((frame) => iflTextureToUrl(frame.name, iflPath)),
[frames, iflPath],
() => uniqueNames.map((name) => iflTextureToUrl(name, iflPath)),
[uniqueNames, iflPath],
);
const textures = useTexture(textureUrls);
@ -156,12 +207,12 @@ export function useIflTexture(iflPath: string): Texture {
const atlas = useMemo(() => {
let cached = atlasCache.get(iflPath);
if (!cached) {
cached = createAtlas(textures);
cached = createAtlas(textures, frameToSlot);
atlasCache.set(iflPath, cached);
}
computeTiming(cached, frames);
return cached;
}, [iflPath, textures, frames]);
}, [iflPath, textures, frames, frameToSlot]);
useTick((tick) => {
const time = tick / TICK_RATE;