mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 07:24:52 +09:00
Incremental rendering of timeline scrubbing
This commit is contained in:
1 parent
9dc5f8ff37
commit
0785798b7d
2 files changed
+387
-40
No files matched your search
@@ -309,4 +309,248 @@ export function extractAvailableTicks(tileData: BinaryData): number[] {
|
||||
}
|
||||
|
||||
return Array.from(ticks).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for tracking pixel changes in timelapse data
|
||||
*/
|
||||
export interface PixelChange {
|
||||
x: number;
|
||||
y: number;
|
||||
newColor: number;
|
||||
oldColor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for chunk records in timelapse data
|
||||
*/
|
||||
export interface ChunkRecord {
|
||||
tick: number;
|
||||
chunkX: number;
|
||||
chunkY: number;
|
||||
data: BinaryData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for pixel change records in timelapse data
|
||||
*/
|
||||
export interface PixelChangeRecord {
|
||||
tick: number;
|
||||
changes: PixelChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for parsed tile data with all change records
|
||||
*/
|
||||
export interface ParsedTileData {
|
||||
chunks: ChunkRecord[];
|
||||
pixelChanges: PixelChangeRecord[];
|
||||
allTicks: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tile data into structured change records for efficient timelapse navigation
|
||||
*/
|
||||
export function parseTileData(tileData: BinaryData): ParsedTileData {
|
||||
const chunks: ChunkRecord[] = [];
|
||||
const pixelChanges: PixelChangeRecord[] = [];
|
||||
const ticks = new Set<number>();
|
||||
|
||||
let offset = 0;
|
||||
while (offset < tileData.length) {
|
||||
if (offset + 1 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const type = readUInt8(tileData, offset);
|
||||
offset += 1;
|
||||
|
||||
if (type === 1) { // Chunk
|
||||
if (offset + 7 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
|
||||
ticks.add(tick);
|
||||
offset += 4;
|
||||
|
||||
const chunkCoordsByte = readUInt8(tileData, offset);
|
||||
offset += 1;
|
||||
const length = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
|
||||
if (offset + length > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunkX = chunkCoordsByte >> 4;
|
||||
const chunkY = chunkCoordsByte & 0x0F;
|
||||
const data = sliceData(tileData, offset, offset + length);
|
||||
|
||||
chunks.push({
|
||||
tick,
|
||||
chunkX,
|
||||
chunkY,
|
||||
data
|
||||
});
|
||||
|
||||
offset += length;
|
||||
} else if (type === 2) { // Pixels
|
||||
if (offset + 6 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
|
||||
ticks.add(tick);
|
||||
offset += 4;
|
||||
const pixelCount = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
|
||||
const expectedDataLength = pixelCount * 6;
|
||||
if (offset + expectedDataLength > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const changes: PixelChange[] = [];
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const pixelOffset = offset + i * 6;
|
||||
const x = readUInt8(tileData, pixelOffset);
|
||||
const y = readUInt8(tileData, pixelOffset + 1);
|
||||
const newColor = readUInt16BE(tileData, pixelOffset + 2);
|
||||
const oldColor = readUInt16BE(tileData, pixelOffset + 4);
|
||||
|
||||
if (x < 256 && y < 256) {
|
||||
changes.push({ x, y, newColor, oldColor });
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
pixelChanges.push({
|
||||
tick,
|
||||
changes
|
||||
});
|
||||
}
|
||||
|
||||
offset += expectedDataLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chunks,
|
||||
pixelChanges,
|
||||
allTicks: Array.from(ticks).sort((a, b) => a - b)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply chunk data to RGB565 pixel array
|
||||
*/
|
||||
export async function applyChunkToPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): Promise<void> {
|
||||
try {
|
||||
const chunkPixels = await renderChunkToPixels(chunkRecord.data);
|
||||
|
||||
// Update tile pixels with chunk data
|
||||
for (let y = 0; y < 32; y++) {
|
||||
for (let x = 0; x < 32; x++) {
|
||||
const chunkIndex = y * 32 + x;
|
||||
const tileX = chunkRecord.chunkX * 32 + x;
|
||||
const tileY = chunkRecord.chunkY * 32 + y;
|
||||
const tileIndex = tileY * 256 + tileX;
|
||||
pixels[tileIndex] = chunkPixels[chunkIndex];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to apply chunk ${chunkRecord.chunkX},${chunkRecord.chunkY}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear chunk area to black (for rewinding past chunk creation)
|
||||
*/
|
||||
export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): void {
|
||||
for (let y = 0; y < 32; y++) {
|
||||
for (let x = 0; x < 32; x++) {
|
||||
const tileX = chunkRecord.chunkX * 32 + x;
|
||||
const tileY = chunkRecord.chunkY * 32 + y;
|
||||
const tileIndex = tileY * 256 + tileX;
|
||||
pixels[tileIndex] = 0; // Black
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pixel changes to RGB565 pixel array (forward direction)
|
||||
*/
|
||||
export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void {
|
||||
for (const change of changes) {
|
||||
const tileIndex = change.y * 256 + change.x;
|
||||
pixels[tileIndex] = change.newColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert pixel changes from RGB565 pixel array (backward direction)
|
||||
*/
|
||||
export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void {
|
||||
for (const change of changes) {
|
||||
const tileIndex = change.y * 256 + change.x;
|
||||
pixels[tileIndex] = change.oldColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tile data incrementally from one tick to another
|
||||
* Much more efficient than re-rendering from scratch
|
||||
*/
|
||||
export async function renderTileIncremental(
|
||||
parsedData: ParsedTileData,
|
||||
currentPixels: Uint16Array,
|
||||
fromTick: number,
|
||||
toTick: number
|
||||
): Promise<void> {
|
||||
const { chunks, pixelChanges } = parsedData;
|
||||
|
||||
if (fromTick === toTick) {
|
||||
return; // No changes needed
|
||||
}
|
||||
|
||||
const forward = toTick > fromTick;
|
||||
|
||||
if (forward) {
|
||||
// Apply changes forward from fromTick to toTick
|
||||
|
||||
// Apply chunks that occur in this range
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.tick > fromTick && chunk.tick <= toTick) {
|
||||
await applyChunkToPixels(currentPixels, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pixel changes that occur in this range
|
||||
for (const record of pixelChanges) {
|
||||
if (record.tick > fromTick && record.tick <= toTick) {
|
||||
applyPixelChanges(currentPixels, record.changes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Revert changes backward from fromTick to toTick
|
||||
|
||||
// Revert pixel changes in reverse order
|
||||
for (let i = pixelChanges.length - 1; i >= 0; i--) {
|
||||
const record = pixelChanges[i];
|
||||
if (record.tick <= fromTick && record.tick > toTick) {
|
||||
revertPixelChanges(currentPixels, record.changes);
|
||||
}
|
||||
}
|
||||
|
||||
// Revert chunks (clear to black if we're going before their creation time)
|
||||
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||
const chunk = chunks[i];
|
||||
if (chunk.tick <= fromTick && chunk.tick > toTick) {
|
||||
clearChunkInPixels(currentPixels, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,15 @@ import { Row, Col, Button, Card, Space, Select, Switch, Slider, Typography, Tool
|
||||
import { ControlContext } from "@clusterio/web_ui";
|
||||
import { GetInstanceBoundsRequest, GetRawTileRequest, TileDataEvent } from "../messages";
|
||||
import * as zlib from "zlib";
|
||||
import { renderTileToPixels, pixelsToImageData, rgb565ToRgb888, extractAvailableTicks } from "../tile-utils";
|
||||
import {
|
||||
renderTileToPixels,
|
||||
pixelsToImageData,
|
||||
rgb565ToRgb888,
|
||||
extractAvailableTicks,
|
||||
parseTileData,
|
||||
renderTileIncremental,
|
||||
ParsedTileData
|
||||
} from "../tile-utils";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -29,6 +37,13 @@ interface ViewState {
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
interface TileState {
|
||||
currentTick: number;
|
||||
pixels: Uint16Array;
|
||||
parsedData: ParsedTileData;
|
||||
imageData: ImageData;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 32; // 32x32 pixels per chunk
|
||||
const TILE_SIZE = 256; // 256x256 pixels per tile (8x8 chunks)
|
||||
const CHUNKS_PER_TILE = 8;
|
||||
@@ -102,8 +117,8 @@ export default function CanvasMinimapPage() {
|
||||
// Virtual tile storage - maps "tileX,tileY" to canvas element
|
||||
const virtualTiles = useRef<Map<string, HTMLCanvasElement>>(new Map());
|
||||
|
||||
// Tile cache - maps "tileX,tileY" to ImageData for entire 512x512 tiles
|
||||
const tileCache = useRef<Map<string, ImageData>>(new Map());
|
||||
// Tile state cache - maps "tileX,tileY" to TileState for efficient timelapse navigation
|
||||
const tileStateCache = useRef<Map<string, TileState>>(new Map());
|
||||
|
||||
// Chunk cache - maps "chunkX,chunkY" to ImageData
|
||||
const chunkCache = useRef<Map<string, ImageData>>(new Map());
|
||||
@@ -238,36 +253,101 @@ export default function CanvasMinimapPage() {
|
||||
|
||||
// Clear caches when switching modes
|
||||
virtualTiles.current.clear();
|
||||
tileCache.current.clear();
|
||||
tileStateCache.current.clear();
|
||||
chunkCache.current.clear();
|
||||
loadingTiles.current.clear();
|
||||
};
|
||||
|
||||
const stepToTick = (tickIndex: number) => {
|
||||
const stepToTick = async (tickIndex: number) => {
|
||||
if (tickIndex >= 0 && tickIndex < availableTicks.length) {
|
||||
const newTick = availableTicks[tickIndex];
|
||||
const oldTick = currentTick;
|
||||
|
||||
// Clear caches to force reload with new tick
|
||||
virtualTiles.current.clear();
|
||||
tileCache.current.clear();
|
||||
chunkCache.current.clear();
|
||||
loadingTiles.current.clear();
|
||||
if (oldTick === newTick) {
|
||||
return; // No change needed
|
||||
}
|
||||
|
||||
// Use incremental updates for loaded tiles instead of clearing cache
|
||||
const updatePromises: Promise<void>[] = [];
|
||||
|
||||
for (const [tileKey, tileState] of tileStateCache.current) {
|
||||
updatePromises.push((async () => {
|
||||
try {
|
||||
// Apply incremental changes from current tick to new tick
|
||||
await renderTileIncremental(
|
||||
tileState.parsedData,
|
||||
tileState.pixels,
|
||||
tileState.currentTick,
|
||||
newTick
|
||||
);
|
||||
|
||||
// Update tile state
|
||||
tileState.currentTick = newTick;
|
||||
|
||||
// Convert to ImageData and update virtual tile
|
||||
const newImageData = pixelsToImageData(tileState.pixels);
|
||||
tileState.imageData = newImageData;
|
||||
|
||||
// Update virtual tile canvas if it exists
|
||||
const virtualTile = virtualTiles.current.get(tileKey);
|
||||
if (virtualTile) {
|
||||
const ctx = virtualTile.getContext('2d')!;
|
||||
ctx.putImageData(newImageData, 0, 0);
|
||||
}
|
||||
|
||||
// Update chunk cache for this tile
|
||||
const [tileX, tileY] = tileKey.split(',').map(Number);
|
||||
for (let cy = 0; cy < CHUNKS_PER_TILE; cy++) {
|
||||
for (let cx = 0; cx < CHUNKS_PER_TILE; cx++) {
|
||||
const actualChunkX = tileX * CHUNKS_PER_TILE + cx;
|
||||
const actualChunkY = tileY * CHUNKS_PER_TILE + cy;
|
||||
const actualChunkKey = `${actualChunkX},${actualChunkY}`;
|
||||
|
||||
// Extract chunk area from the full tile ImageData
|
||||
const chunkData = new Uint8ClampedArray(CHUNK_SIZE * CHUNK_SIZE * 4);
|
||||
for (let y = 0; y < CHUNK_SIZE; y++) {
|
||||
for (let x = 0; x < CHUNK_SIZE; x++) {
|
||||
const tilePixelIndex = ((cy * CHUNK_SIZE + y) * TILE_SIZE + (cx * CHUNK_SIZE + x)) * 4;
|
||||
const chunkPixelIndex = (y * CHUNK_SIZE + x) * 4;
|
||||
|
||||
chunkData[chunkPixelIndex] = newImageData.data[tilePixelIndex]; // R
|
||||
chunkData[chunkPixelIndex + 1] = newImageData.data[tilePixelIndex + 1]; // G
|
||||
chunkData[chunkPixelIndex + 2] = newImageData.data[tilePixelIndex + 2]; // B
|
||||
chunkData[chunkPixelIndex + 3] = newImageData.data[tilePixelIndex + 3]; // A
|
||||
}
|
||||
}
|
||||
|
||||
const chunkImageData = new ImageData(chunkData, CHUNK_SIZE, CHUNK_SIZE);
|
||||
chunkCache.current.set(actualChunkKey, chunkImageData);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to update tile ${tileKey} incrementally:`, err);
|
||||
// If incremental update fails, remove from cache to force full reload
|
||||
tileStateCache.current.delete(tileKey);
|
||||
virtualTiles.current.delete(tileKey);
|
||||
}
|
||||
})());
|
||||
}
|
||||
|
||||
// Wait for all tile updates to complete
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
setCurrentTick(newTick);
|
||||
}
|
||||
};
|
||||
|
||||
const stepForward = () => {
|
||||
const stepForward = async () => {
|
||||
const currentIndex = availableTicks.findIndex(tick => tick === currentTick);
|
||||
if (currentIndex >= 0 && currentIndex < availableTicks.length - 1) {
|
||||
stepToTick(currentIndex + 1);
|
||||
await stepToTick(currentIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const stepBackward = () => {
|
||||
const stepBackward = async () => {
|
||||
const currentIndex = availableTicks.findIndex(tick => tick === currentTick);
|
||||
if (currentIndex > 0) {
|
||||
stepToTick(currentIndex - 1);
|
||||
await stepToTick(currentIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,10 +358,10 @@ export default function CanvasMinimapPage() {
|
||||
// Handle playback timer
|
||||
useEffect(() => {
|
||||
if (isPlaying && isTimelapseMode && availableTicks.length > 0) {
|
||||
playbackTimerRef.current = setInterval(() => {
|
||||
playbackTimerRef.current = setInterval(async () => {
|
||||
const currentIndex = availableTicks.findIndex(tick => tick === currentTick);
|
||||
if (currentIndex >= 0 && currentIndex < availableTicks.length - 1) {
|
||||
stepToTick(currentIndex + 1);
|
||||
await stepToTick(currentIndex + 1);
|
||||
} else {
|
||||
// Reached the end, stop playing
|
||||
setIsPlaying(false);
|
||||
@@ -537,9 +617,28 @@ export default function CanvasMinimapPage() {
|
||||
const loadTile = async (tileX: number, tileY: number): Promise<ImageData | null> => {
|
||||
const tileKey = `${tileX},${tileY}`;
|
||||
|
||||
// Check tile cache first
|
||||
if (tileCache.current.has(tileKey)) {
|
||||
return tileCache.current.get(tileKey)!;
|
||||
// Check tile state cache first
|
||||
if (tileStateCache.current.has(tileKey)) {
|
||||
const tileState = tileStateCache.current.get(tileKey)!;
|
||||
|
||||
// Check if we need to update to current tick
|
||||
const targetTick = isTimelapseMode ? currentTick || 0 : tileState.parsedData.allTicks[tileState.parsedData.allTicks.length - 1] || 0;
|
||||
if (tileState.currentTick !== targetTick) {
|
||||
try {
|
||||
await renderTileIncremental(
|
||||
tileState.parsedData,
|
||||
tileState.pixels,
|
||||
tileState.currentTick,
|
||||
targetTick
|
||||
);
|
||||
tileState.currentTick = targetTick;
|
||||
tileState.imageData = pixelsToImageData(tileState.pixels);
|
||||
} catch (err) {
|
||||
console.error(`Failed to update tile ${tileKey} to tick ${targetTick}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return tileState.imageData;
|
||||
}
|
||||
|
||||
// Check if we're already loading this tile
|
||||
@@ -571,15 +670,29 @@ export default function CanvasMinimapPage() {
|
||||
tileData[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Parse the tile data to get RGB565 pixels
|
||||
const targetTick = isTimelapseMode ? currentTick || undefined : undefined;
|
||||
const pixels = await renderTileToPixels(tileData, targetTick);
|
||||
// Parse the tile data into structured change records
|
||||
const parsedData = parseTileData(tileData);
|
||||
|
||||
// Determine target tick
|
||||
const targetTick = isTimelapseMode ?
|
||||
(currentTick || 0) :
|
||||
(parsedData.allTicks[parsedData.allTicks.length - 1] || 0);
|
||||
|
||||
// Render to target tick (undefined means latest for non-timelapse mode)
|
||||
const renderTick = isTimelapseMode ? targetTick : undefined;
|
||||
const pixels = await renderTileToPixels(tileData, renderTick);
|
||||
|
||||
// Convert to ImageData
|
||||
const tileImageData = pixelsToImageData(pixels);
|
||||
|
||||
// Cache the tile
|
||||
tileCache.current.set(tileKey, tileImageData);
|
||||
// Create and cache the tile state
|
||||
const tileState: TileState = {
|
||||
currentTick: targetTick,
|
||||
pixels: pixels,
|
||||
parsedData: parsedData,
|
||||
imageData: tileImageData
|
||||
};
|
||||
tileStateCache.current.set(tileKey, tileState);
|
||||
|
||||
// Extract and cache all chunks from this tile
|
||||
for (let cy = 0; cy < CHUNKS_PER_TILE; cy++) {
|
||||
@@ -646,11 +759,11 @@ export default function CanvasMinimapPage() {
|
||||
ctx.putImageData(imageData, pixelX, pixelY);
|
||||
}
|
||||
|
||||
// Update the tile cache if it exists
|
||||
const cachedTile = tileCache.current.get(tileKey);
|
||||
if (cachedTile) {
|
||||
// Update the tile state if it exists
|
||||
const tileState = tileStateCache.current.get(tileKey);
|
||||
if (tileState) {
|
||||
// Update the tile ImageData directly
|
||||
const tileData = cachedTile.data;
|
||||
const tileData = tileState.imageData.data;
|
||||
const chunkData = imageData.data;
|
||||
|
||||
for (let y = 0; y < CHUNK_SIZE; y++) {
|
||||
@@ -910,22 +1023,12 @@ export default function CanvasMinimapPage() {
|
||||
|
||||
// Clear caches when switching instances
|
||||
virtualTiles.current.clear();
|
||||
tileCache.current.clear();
|
||||
tileStateCache.current.clear();
|
||||
chunkCache.current.clear();
|
||||
loadingTiles.current.clear();
|
||||
}
|
||||
}, [selectedInstance, instances]);
|
||||
|
||||
// Clear caches when currentTick changes (React state timing fix)
|
||||
useEffect(() => {
|
||||
if (isTimelapseMode) {
|
||||
virtualTiles.current.clear();
|
||||
tileCache.current.clear();
|
||||
chunkCache.current.clear();
|
||||
loadingTiles.current.clear();
|
||||
}
|
||||
}, [currentTick, isTimelapseMode]);
|
||||
|
||||
// Reload ticks when instance, surface, force, or view changes
|
||||
useEffect(() => {
|
||||
if (isTimelapseMode && selectedInstance && selectedSurface && selectedForce) {
|
||||
@@ -1108,7 +1211,7 @@ export default function CanvasMinimapPage() {
|
||||
min={0}
|
||||
max={availableTicks.length - 1}
|
||||
value={availableTicks.findIndex(tick => tick === currentTick)}
|
||||
onChange={(value) => stepToTick(value)}
|
||||
onChange={async (value) => await stepToTick(value)}
|
||||
style={{ marginTop: "8px" }}
|
||||
tooltip={{
|
||||
formatter: (value) => {
|
||||
|
||||
Reference in new issue
Block a user