pixelplanet/src/core/Tile.js

691 lines
18 KiB
JavaScript

/*
* basic functions for creating zoomed tiles
* Used by tilewriter worker thread, so dont import too much.
*
* */
/* eslint-disable no-console */
// Tile creation is allowed to be slow
/* eslint-disable no-await-in-loop */
import sharp from 'sharp';
import fs from 'fs';
import RedisCanvas from '../data/redis/RedisCanvas';
import Palette from './Palette';
import { getMaxTiledZoom } from './utils';
import { TILE_SIZE, TILE_ZOOM_LEVEL } from './constants';
/*
* Deletes a subtile from a tile (paints it in color 0),
* if we wouldn't do it, it would be black
* @param tileSize size of the tile within the chunk
* @param palette Palette to use
* @param subtilesInTile how many subtiles are in a tile (per dimension)
* @param cell subtile to delete [dx, dy]
* @param buffer Uint8Array for RGB values of tile
*/
function deleteSubtilefromTile(
tileSize,
palette,
subtilesInTile,
cell,
buffer,
) {
const [dx, dy] = cell;
const offset = (dx + dy * tileSize * subtilesInTile) * tileSize;
const [blankR, blankG, blankB] = palette.rgb;
for (let row = 0; row < tileSize; row += 1) {
let channelOffset = (offset + row * tileSize * subtilesInTile) * 3;
const max = channelOffset + tileSize * 3;
while (channelOffset < max) {
buffer[channelOffset++] = blankR;
buffer[channelOffset++] = blankG;
buffer[channelOffset++] = blankB;
}
}
}
/*
* adds an RGB tile as a shrunken tile to another
* @param subtilesInTile width of a subtile compared to the tile (power of 2)
* @param cell subtile coordinates [dx, dy]
* @param inpTile input tile
* @param tile output tile
*/
function addShrunkenSubtileToTile(
subtilesInTile,
cell,
inpTile,
tile,
) {
const tileSize = TILE_SIZE;
const [dx, dy] = cell;
const target = tileSize / subtilesInTile;
const offset = (dx + dy * tileSize) * target;
let posA = 0;
let posB;
const pxlPad = (subtilesInTile - 1) * 3;
const linePad = (tileSize * (subtilesInTile - 1) - 1) * 3;
for (let row = 0; row < target; row += 1) {
let channelOffset = (offset + row * tileSize) * 3;
const max = channelOffset + target * 3;
while (channelOffset < max) {
const tr = inpTile[posA++];
const tg = inpTile[posA++];
const tb = inpTile[posA++];
posA += pxlPad;
posB = posA + linePad;
tile[channelOffset++] = (inpTile[posB++] + tr) >>> 1;
tile[channelOffset++] = (inpTile[posB++] + tg) >>> 1;
tile[channelOffset++] = (inpTile[posB] + tb) >>> 1;
}
posA = posB + 1;
}
}
/*
* this was a failed try, it ended up being slow
* and low quality
function addShrunkenIndexedSubtilesToTile(
palette,
tileSize,
subtilesInTile,
cell,
inpTile,
buffer,
) {
const [dx, dy] = cell;
const subtileSize = tileSize / subtilesInTile;
const inpTileLength = inpTile.length;
const offset = (dx + dy * tileSize) * subtileSize;
const { rgb } = palette;
let tr;
let tg;
let tb;
let channelOffset;
let posA;
let posB;
let clr;
const linePad = (tileSize + 1) * (subtilesInTile - 1);
let amountFullRows = Math.floor(inpTileLength / subtilesInTile);
// use available data
for (let row = 0; row < amountFullRows; row += 1) {
channelOffset = (offset + row * tileSize) * 3;
const max = channelOffset + subtileSize * 3;
posA = row * tileSize * subtilesInTile;
posB = posA + linePad;
while (channelOffset < max) {
clr = (inpTile[posA] & 0x3F) * 3;
tr = rgb[clr++];
tg = rgb[clr++];
tb = rgb[clr];
posA += subtilesInTile;
clr = (inpTile[posB] & 0x3F) * 3;
buffer[channelOffset++] = (rgb[clr++] + tr) / 2;
buffer[channelOffset++] = (rgb[clr++] + tg) / 2;
buffer[channelOffset++] = (rgb[clr] + tb) / 2;
posB += subtilesInTile;
}
}
// padding the rest
[tr, tg, tb] = rgb;
if (inpTileLength % subtilesInTile) {
channelOffset = (offset + amountFullRows * tileSize) * 3;
const max = channelOffset + subtileSize * 3;
posA = amountFullRows * tileSize * subtilesInTile;
while (channelOffset < max) {
if (posA < inpTileLength) {
clr = (inpTile[posA] & 0x3F) * 3;
buffer[channelOffset++] = (rgb[clr++] + tr) / 2;
buffer[channelOffset++] = (rgb[clr++] + tg) / 2;
buffer[channelOffset++] = (rgb[clr] + tb) / 2;
posA += subtilesInTile;
} else {
buffer[channelOffset++] = tr;
buffer[channelOffset++] = tg;
buffer[channelOffset++] = tb;
}
}
amountFullRows += 1;
}
if (amountFullRows < subtileSize) {
for (let row = amountFullRows; row < subtileSize; row += 1) {
channelOffset = (offset + row * tileSize) * 3;
const max = channelOffset + subtileSize * 3;
while (channelOffset < max) {
buffer[channelOffset++] = tr;
buffer[channelOffset++] = tg;
buffer[channelOffset++] = tb;
}
}
}
}
*/
/*
* @param subtilesInTile how many subtiles are in a tile (per dimension)
* @param cell where to add the tile [dx, dy]
* @param subtile RGB buffer of subtile
* @param buffer Uint8Array for RGB values of tile
*/
function addRGBSubtiletoTile(
subtilesInTile,
cell,
subtile,
buffer,
) {
const tileSize = TILE_SIZE;
const [dx, dy] = cell;
const chunkOffset = (dx + dy * subtilesInTile * tileSize) * tileSize;
let pos = 0;
for (let row = 0; row < tileSize; row += 1) {
let channelOffset = (chunkOffset + row * tileSize * subtilesInTile) * 3;
const max = channelOffset + tileSize * 3;
while (channelOffset < max) {
buffer[channelOffset++] = subtile[pos++];
buffer[channelOffset++] = subtile[pos++];
buffer[channelOffset++] = subtile[pos++];
}
}
}
/*
* @param palette Palette to use
* @param subtilesInTile how many subtiles are in a tile (per dimension)
* @param cell subtile to delete [dx, dy]
* @param subtile RGB buffer of subtile
* @param buffer RGB Buffer of tile
*/
function addIndexedSubtiletoTile(
palette,
subtilesInTile,
cell,
subtile,
buffer,
) {
const tileSize = TILE_SIZE;
const [dx, dy] = cell;
const chunkOffset = (dx + dy * subtilesInTile * tileSize) * tileSize;
const { rgb } = palette;
const emptyR = rgb[0];
const emptyG = rgb[1];
const emptyB = rgb[2];
let pos = 0;
let clr;
for (let row = 0; row < tileSize; row += 1) {
let channelOffset = (chunkOffset + row * tileSize * subtilesInTile) * 3;
const max = channelOffset + tileSize * 3;
while (channelOffset < max) {
if (pos < subtile.length) {
clr = (subtile[pos++] & 0x3F) * 3;
buffer[channelOffset++] = rgb[clr++];
buffer[channelOffset++] = rgb[clr++];
buffer[channelOffset++] = rgb[clr];
} else {
buffer[channelOffset++] = emptyR;
buffer[channelOffset++] = emptyG;
buffer[channelOffset++] = emptyB;
}
}
}
}
/*
* @param canvasTileFolder root folder where to save tiles
* @param cell tile [z, x, y]
* @return filename of tile
*/
function tileFileName(canvasTileFolder, cell) {
const [z, x, y] = cell;
const filename = `${canvasTileFolder}/${z}/${x}/${y}.webp`;
try {
const mtime = new Date(fs.statSync(filename).mtime).getTime();
if (Date.now() - mtime < 120000) {
return null;
}
} catch {
// file doesn't exist
}
return filename;
}
/*
* @param canvasId id of the canvas
* @param canvas canvas data
* @param canvasTileFolder root folder where to save tiles
* @param cell tile to create [x, y]
* @return true if successfully created tile, false if tile empty
*/
export async function createZoomTileFromChunk(
canvasId,
canvas,
canvasTileFolder,
cell,
gPalette = null,
) {
const palette = gPalette || new Palette(canvas.colors);
const canvasSize = canvas.size;
const [x, y] = cell;
const maxTiledZoom = getMaxTiledZoom(canvasSize);
const filename = tileFileName(canvasTileFolder, [maxTiledZoom - 1, x, y]);
if (!filename) return true;
const tileRGBBuffer = new Uint8Array(
TILE_SIZE * TILE_SIZE * TILE_ZOOM_LEVEL * TILE_ZOOM_LEVEL * 3,
);
const startTime = Date.now();
const xabs = x * TILE_ZOOM_LEVEL;
const yabs = y * TILE_ZOOM_LEVEL;
const na = [];
const prom = async (dx, dy) => {
try {
const chunk = await RedisCanvas.getChunk(
canvasId,
xabs + dx,
yabs + dy,
);
if (!chunk || !chunk.length) {
na.push([dx, dy]);
return;
}
addIndexedSubtiletoTile(
palette,
TILE_ZOOM_LEVEL,
[dx, dy],
chunk,
tileRGBBuffer,
);
} catch (error) {
na.push([dx, dy]);
console.error(
// eslint-disable-next-line max-len
`Tiling: Failed to get Chunk ch:${canvasId}:${xabs + dx}${yabs + dy} with error ${error.message}`,
);
}
};
const promises = [];
for (let dy = 0; dy < TILE_ZOOM_LEVEL; dy += 1) {
for (let dx = 0; dx < TILE_ZOOM_LEVEL; dx += 1) {
promises.push(prom(dx, dy));
}
}
await Promise.all(promises);
if (na.length !== TILE_ZOOM_LEVEL * TILE_ZOOM_LEVEL) {
na.forEach((element) => {
deleteSubtilefromTile(
TILE_SIZE,
palette,
TILE_ZOOM_LEVEL,
element,
tileRGBBuffer,
);
});
try {
await sharp(tileRGBBuffer, {
raw: {
width: TILE_SIZE * TILE_ZOOM_LEVEL,
height: TILE_SIZE * TILE_ZOOM_LEVEL,
channels: 3,
},
})
.resize(TILE_SIZE)
.webp({ quality: 100, smartSubsample: true })
.toFile(filename);
} catch (error) {
console.error(
`Tiling: Error on createZoomTileFromChunk: ${error.message}`,
);
return false;
}
console.log(
// eslint-disable-next-line max-len
`Tiling: Created Tile ${filename} with ${na.length} empty chunks in ${Date.now() - startTime}ms`,
);
return true;
}
return false;
}
/*
* @param canvas canvas data
* @param canvasTileFolder root folder where to save tiles
* @param cell tile to create [z, x, y]
* @return trie if successfully created tile, false if tile empty
*/
export async function createZoomedTile(
canvas,
canvasTileFolder,
cell,
gPalette = null,
) {
const palette = gPalette || new Palette(canvas.colors);
const tileRGBBuffer = new Uint8Array(
TILE_SIZE * TILE_SIZE * 3,
);
const [z, x, y] = cell;
const filename = tileFileName(canvasTileFolder, [z, x, y]);
if (!filename) return true;
const startTime = Date.now();
const na = [];
const prom = async (dx, dy) => {
// eslint-disable-next-line max-len
const chunkfile = `${canvasTileFolder}/${z + 1}/${x * TILE_ZOOM_LEVEL + dx}/${y * TILE_ZOOM_LEVEL + dy}.webp`;
try {
if (!fs.existsSync(chunkfile)) {
na.push([dx, dy]);
return;
}
const chunk = await sharp(chunkfile).removeAlpha().raw().toBuffer();
addShrunkenSubtileToTile(
TILE_ZOOM_LEVEL,
[dx, dy],
chunk,
tileRGBBuffer,
);
} catch (error) {
na.push([dx, dy]);
console.error(
// eslint-disable-next-line max-len
`Tiling: Error on createZoomedTile on chunk ${chunkfile}: ${error.message}`,
);
}
};
const promises = [];
for (let dy = 0; dy < TILE_ZOOM_LEVEL; dy += 1) {
for (let dx = 0; dx < TILE_ZOOM_LEVEL; dx += 1) {
promises.push(prom(dx, dy));
}
}
await Promise.all(promises);
if (na.length !== TILE_ZOOM_LEVEL * TILE_ZOOM_LEVEL) {
na.forEach((element) => {
deleteSubtilefromTile(
TILE_SIZE / TILE_ZOOM_LEVEL,
palette,
TILE_ZOOM_LEVEL,
element,
tileRGBBuffer,
);
});
try {
await sharp(tileRGBBuffer, {
raw: {
width: TILE_SIZE,
height: TILE_SIZE,
channels: 3,
},
})
.webp({ quality: 100, smartSubsample: true })
.toFile(filename);
} catch (error) {
console.error(
`Tiling: Error on createZoomedTile: ${error.message}`,
);
return false;
}
console.log(
// eslint-disable-next-line max-len
`Tiling: Created tile ${filename} with ${na.length} empty subtiles in ${Date.now() - startTime}ms.`,
);
return true;
}
return false;
}
/*
* create an empty image tile with just one color
* @param canvasTileFolder root folder where to save texture
* @param palette Palette to use
*/
async function createEmptyTile(
canvasTileFolder,
palette,
) {
const tileRGBBuffer = new Uint8Array(
TILE_SIZE * TILE_SIZE * 3,
);
let i = 0;
const max = TILE_SIZE * TILE_SIZE * 3;
while (i < max) {
// eslint-disable-next-line prefer-destructuring
tileRGBBuffer[i++] = palette.rgb[0];
// eslint-disable-next-line prefer-destructuring
tileRGBBuffer[i++] = palette.rgb[1];
// eslint-disable-next-line prefer-destructuring
tileRGBBuffer[i++] = palette.rgb[2];
}
const filename = `${canvasTileFolder}/emptytile.webp`;
try {
await sharp(tileRGBBuffer, {
raw: {
width: TILE_SIZE,
height: TILE_SIZE,
channels: 3,
},
})
.webp({ quality: 100, smartSubsample: true })
.toFile(filename);
} catch (error) {
console.error(
`Tiling: Error on createEmptyTile: ${error.message}`,
);
return;
}
console.log(`Tiling: Created empty tile at ${filename}`);
}
/*
* created 4096x4096 texture of default canvas
* @param canvasId numerical Id of canvas
* @param canvas canvas data
* @param canvasTileFolder root folder where to save texture
*
*/
export async function createTexture(
canvasId,
canvas,
canvasTileFolder,
) {
const palette = new Palette(canvas.colors);
const canvasSize = canvas.size;
// dont create textures larger than 4096
const targetSize = Math.min(canvasSize, 4096);
const amount = targetSize / TILE_SIZE;
const zoom = Math.log2(amount) * 2 / TILE_ZOOM_LEVEL;
const textureBuffer = new Uint8Array(targetSize * targetSize * 3);
const startTime = Date.now();
const na = [];
const prom = (targetSize !== canvasSize)
? async (dx, dy) => {
const chunkfile = `${canvasTileFolder}/${zoom}/${dx}/${dy}.webp`;
try {
if (!fs.existsSync(chunkfile)) {
na.push([dx, dy]);
return;
}
const chunk = await sharp(chunkfile).removeAlpha().raw().toBuffer();
addRGBSubtiletoTile(amount, [dx, dy], chunk, textureBuffer);
} catch (error) {
na.push([dx, dy]);
console.error(
// eslint-disable-next-line max-len
`Tiling: Error on createTexture in chunk ${chunkfile}: ${error.message}`,
);
}
}
: async (dx, dy) => {
try {
let chunk = null;
chunk = await RedisCanvas.getChunk(
canvasId,
dx,
dy,
);
if (!chunk || !chunk.length) {
na.push([dx, dy]);
return;
}
addIndexedSubtiletoTile(
palette,
amount,
[dx, dy],
chunk,
textureBuffer,
);
} catch (error) {
na.push([dx, dy]);
console.error(
// eslint-disable-next-line max-len
`Tiling: Failed to get Chunk ch:${canvasId}:${dx}${dy} with error ${error.message}`,
);
}
};
const promises = [];
for (let dy = 0; dy < amount; dy += 1) {
for (let dx = 0; dx < amount; dx += 1) {
promises.push(prom(dx, dy));
}
}
await Promise.all(promises);
na.forEach((element) => {
deleteSubtilefromTile(TILE_SIZE, palette, amount, element, textureBuffer);
});
const filename = `${canvasTileFolder}/texture.webp`;
try {
await sharp(textureBuffer, {
raw: {
width: targetSize,
height: targetSize,
channels: 3,
},
}).toFile(filename);
} catch (error) {
console.error(
`Tiling: Error on createTexture: ${error.message}`,
);
return;
}
console.log(
`Tiling: Created texture in ${(Date.now() - startTime) / 1000}s.`,
);
}
/*
* Create all tiles
* @param canvasId id of the canvas
* @param canvas canvas data
* @param canvasTileFolder folder for tiles
* @param force overwrite existing tiles
*/
export async function initializeTiles(
canvasId,
canvas,
canvasTileFolder,
force = false,
) {
console.log(
`Tiling: Initializing tiles in ${canvasTileFolder}, forceint = ${force}`,
);
const startTime = Date.now();
const palette = new Palette(canvas.colors);
const canvasSize = canvas.size;
const maxTiledZoom = getMaxTiledZoom(canvasSize);
// empty tile
await createEmptyTile(canvasTileFolder, palette);
// base zoomlevel
let zoom = maxTiledZoom - 1;
let zoomDir = `${canvasTileFolder}/${zoom}`;
console.log(`Tiling: Checking zoomlevel ${zoomDir}`);
if (!fs.existsSync(zoomDir)) fs.mkdirSync(zoomDir);
let cnt = 0;
let cnts = 0;
const maxBase = TILE_ZOOM_LEVEL ** zoom;
for (let cx = 0; cx < maxBase; cx += 1) {
const tileDir = `${canvasTileFolder}/${zoom}/${cx}`;
if (!fs.existsSync(tileDir)) {
fs.mkdirSync(tileDir);
}
for (let cy = 0; cy < maxBase; cy += 1) {
const filename = `${canvasTileFolder}/${zoom}/${cx}/${cy}.webp`;
if (force || !fs.existsSync(filename)) {
const ret = await createZoomTileFromChunk(
canvasId,
canvas,
canvasTileFolder,
[cx, cy],
palette,
);
if (ret) cnts += 1;
cnt += 1;
}
}
}
console.log(
`Tiling: Created ${cnts} / ${cnt} tiles for basezoom of canvas${canvasId}`,
);
// zoomlevels that are created from other zoomlevels
for (zoom = maxTiledZoom - 2; zoom >= 0; zoom -= 1) {
cnt = 0;
cnts = 0;
zoomDir = `${canvasTileFolder}/${zoom}`;
console.log(`Tiling: Checking zoomlevel ${zoomDir}`);
if (!fs.existsSync(zoomDir)) fs.mkdirSync(zoomDir);
const maxZ = TILE_ZOOM_LEVEL ** zoom;
for (let cx = 0; cx < maxZ; cx += 1) {
const tileDir = `${canvasTileFolder}/${zoom}/${cx}`;
if (!fs.existsSync(tileDir)) {
fs.mkdirSync(tileDir);
}
for (let cy = 0; cy < maxZ; cy += 1) {
const filename = `${canvasTileFolder}/${zoom}/${cx}/${cy}.webp`;
if (force || !fs.existsSync(filename)) {
const ret = await createZoomedTile(
canvas,
canvasTileFolder,
[zoom, cx, cy],
palette,
);
if (ret) cnts += 1;
cnt += 1;
}
}
}
console.log(
// eslint-disable-next-line max-len
`Tiling: Created ${cnts} / ${cnt} tiles for zoom ${zoom} for canvas${canvasId}`,
);
}
// create snapshot texture
await createTexture(
canvasId,
canvas,
canvasTileFolder,
);
//--
console.log(
// eslint-disable-next-line max-len
`Tiling: Elapsed Time: ${Math.round((Date.now() - startTime) / 1000)} for canvas${canvasId}`,
);
}