From ac1396304fb9226e57da1fbccffa9d38477e895e Mon Sep 17 00:00:00 2001 From: Dennis Postma Date: Sat, 1 Feb 2025 03:11:13 +0100 Subject: [PATCH] Added web worker to improve tile analysis performance --- .../mapEditor/partials/TileList.vue | 109 +++--------------- .../useTileProcessingComposable.ts | 107 +++++++++++++++++ src/types/tileTypes.ts | 20 ++++ src/workers/tileAnalyzerWorker.ts | 64 ++++++++++ 4 files changed, 205 insertions(+), 95 deletions(-) create mode 100644 src/composables/useTileProcessingComposable.ts create mode 100644 src/types/tileTypes.ts create mode 100644 src/workers/tileAnalyzerWorker.ts diff --git a/src/components/gameMaster/mapEditor/partials/TileList.vue b/src/components/gameMaster/mapEditor/partials/TileList.vue index 4e9f33c..cc5da4b 100644 --- a/src/components/gameMaster/mapEditor/partials/TileList.vue +++ b/src/components/gameMaster/mapEditor/partials/TileList.vue @@ -29,7 +29,7 @@ :src="`${config.server_endpoint}/textures/tiles/${group.parent.id}.png`" :alt="group.parent.name" @click="openGroup(group)" - @load="() => processTile(group.parent)" + @load="() => tileProcessor.processTile(group.parent)" :class="{ 'border-cyan shadow-lg': isActiveTile(group.parent), 'border-transparent hover:border-gray-300': !isActiveTile(group.parent) @@ -88,13 +88,14 @@ import config from '@/application/config' import type { Tile } from '@/application/types' import { useMapEditorComposable } from '@/composables/useMapEditorComposable' +import { useTileProcessingComposable } from '@/composables/useTileProcessingComposable' import { TileStorage } from '@/storage/storages' -import { liveQuery } from 'dexie' import { computed, onMounted, onUnmounted, ref } from 'vue' const isOpen = ref(false) const tileStorage = new TileStorage() const mapEditor = useMapEditorComposable() +const tileProcessor = useTileProcessingComposable() const searchQuery = ref('') const selectedTags = ref([]) const tileCategories = ref>(new Map()) @@ -121,7 +122,7 @@ const groupedTiles = computed(() => { }) filteredTiles.forEach((tile) => { - const parentGroup = groups.find((group) => areTilesRelated(group.parent, tile)) + const parentGroup = groups.find((group) => tileProcessor.areTilesRelated(group.parent, tile)) if (parentGroup && parentGroup.parent.id !== tile.id) { parentGroup.children.push(tile) } else { @@ -132,32 +133,6 @@ const groupedTiles = computed(() => { return groups }) -const tileColorData = ref>(new Map()) -const tileEdgeData = ref>(new Map()) - -function areTilesRelated(tile1: Tile, tile2: Tile): boolean { - const colorSimilarityThreshold = 30 // Adjust this value as needed - const edgeComplexitySimilarityThreshold = 20 // Adjust this value as needed - - const color1 = tileColorData.value.get(tile1.id) - const color2 = tileColorData.value.get(tile2.id) - const edge1 = tileEdgeData.value.get(tile1.id) - const edge2 = tileEdgeData.value.get(tile2.id) - - if (!color1 || !color2 || edge1 === undefined || edge2 === undefined) { - return false - } - - const colorDifference = Math.sqrt(Math.pow(color1.r - color2.r, 2) + Math.pow(color1.g - color2.g, 2) + Math.pow(color1.b - color2.b, 2)) - - const edgeComplexityDifference = Math.abs(edge1 - edge2) - - const namePrefix1 = tile1.name.split('_')[0] - const namePrefix2 = tile2.name.split('_')[0] - - return colorDifference <= colorSimilarityThreshold && edgeComplexityDifference <= edgeComplexitySimilarityThreshold && namePrefix1 === namePrefix2 -} - const toggleTag = (tag: string) => { if (selectedTags.value.includes(tag)) { selectedTags.value = selectedTags.value.filter((t) => t !== tag) @@ -166,59 +141,6 @@ const toggleTag = (tag: string) => { } } -function processTile(tile: Tile) { - const img = new Image() - img.crossOrigin = 'Anonymous' - img.onload = () => { - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - canvas.width = img.width - canvas.height = img.height - ctx!.drawImage(img, 0, 0, img.width, img.height) - - const imageData = ctx!.getImageData(0, 0, canvas.width, canvas.height) - tileColorData.value.set(tile.id, getDominantColor(imageData)) - tileEdgeData.value.set(tile.id, getEdgeComplexity(imageData)) - } - img.src = `${config.server_endpoint}/textures/tiles/${tile.id}.png` -} - -function getDominantColor(imageData: ImageData) { - let r = 0, - g = 0, - b = 0, - total = 0 - - for (let i = 0; i < imageData.data.length; i += 4) { - if (imageData.data[i + 3] > 0) { - // Only consider non-transparent pixels - r += imageData.data[i] - g += imageData.data[i + 1] - b += imageData.data[i + 2] - total++ - } - } - - return { - r: Math.round(r / total), - g: Math.round(g / total), - b: Math.round(b / total) - } -} - -function getEdgeComplexity(imageData: ImageData) { - let edgePixels = 0 - for (let y = 0; y < imageData.height; y++) { - for (let x = 0; x < imageData.width; x++) { - const i = (y * imageData.width + x) * 4 - if (imageData.data[i + 3] > 0 && (x === 0 || y === 0 || x === imageData.width - 1 || y === imageData.height - 1 || imageData.data[i - 1] === 0 || imageData.data[i + 7] === 0)) { - edgePixels++ - } - } - } - return edgePixels -} - function getTileCategory(tile: Tile): string { return tileCategories.value.get(tile.id) || '' } @@ -239,22 +161,19 @@ function isActiveTile(tile: Tile): boolean { return mapEditor.selectedTile.value === tile.id } -let subscription: any = null +onMounted(async () => { + tiles.value = await tileStorage.getAll() + const initialBatchSize = 20 + const initialTiles = tiles.value.slice(0, initialBatchSize) + initialTiles.forEach(tile => tileProcessor.processTile(tile)) -onMounted(() => { - subscription = liveQuery(() => tileStorage.liveQuery()).subscribe({ - next: (result) => { - tiles.value = result - }, - error: (error) => { - console.error('Failed to fetch tiles:', error) - } - }) + // Process remaining tiles in background + setTimeout(() => { + tiles.value.slice(initialBatchSize).forEach(tile => tileProcessor.processTile(tile)) + }, 1000) }) onUnmounted(() => { - if (subscription) { - subscription.unsubscribe() - } + tileProcessor.cleanup() }) diff --git a/src/composables/useTileProcessingComposable.ts b/src/composables/useTileProcessingComposable.ts new file mode 100644 index 0000000..d1f9462 --- /dev/null +++ b/src/composables/useTileProcessingComposable.ts @@ -0,0 +1,107 @@ +import { ref } from 'vue' +import config from '@/application/config' +import type { Tile } from '@/application/types' +import type { TileAnalysisResult, TileWorkerMessage } from '@/types/tileTypes' + +// Constants for image processing +const DOWNSCALE_WIDTH = 32 +const DOWNSCALE_HEIGHT = 16 +const COLOR_SIMILARITY_THRESHOLD = 30 +const EDGE_SIMILARITY_THRESHOLD = 20 +const BATCH_SIZE = 4 + +export function useTileProcessingComposable() { + const tileAnalysisCache = ref>(new Map()) + const processingQueue = ref([]) + let isProcessing = false + const worker = new Worker(new URL('@/workers/tileAnalyzerWorker.ts', import.meta.url), { type: 'module' }) + + worker.onmessage = (e: MessageEvent) => { + const { tileId, color, edge, namePrefix } = e.data + tileAnalysisCache.value.set(tileId, { color, edge, namePrefix }) + isProcessing = false + processBatch() + } + + async function processTileAsync(tile: Tile): Promise { + if (tileAnalysisCache.value.has(tile.id)) return + + return new Promise((resolve) => { + const img = new Image() + img.crossOrigin = 'Anonymous' + img.onload = () => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) { + resolve() + return + } + + canvas.width = DOWNSCALE_WIDTH + canvas.height = DOWNSCALE_HEIGHT + ctx.drawImage(img, 0, 0, DOWNSCALE_WIDTH, DOWNSCALE_HEIGHT) + + const imageData = ctx.getImageData(0, 0, DOWNSCALE_WIDTH, DOWNSCALE_HEIGHT) + const message: TileWorkerMessage = { + imageData, + tileId: tile.id, + tileName: tile.name + } + worker.postMessage(message) + resolve() + } + img.onerror = () => resolve() + img.src = `${config.server_endpoint}/textures/tiles/${tile.id}.png` + }) + } + + function processBatch() { + if (isProcessing || processingQueue.value.length === 0) return + isProcessing = true + + const batch = processingQueue.value.splice(0, BATCH_SIZE) + Promise.all(batch.map(tile => processTileAsync(tile))) + .then(() => { + isProcessing = false + if (processingQueue.value.length > 0) { + setTimeout(processBatch, 0) + } + }) + } + + function processTile(tile: Tile) { + if (!processingQueue.value.includes(tile)) { + processingQueue.value.push(tile) + processBatch() + } + } + + function areTilesRelated(tile1: Tile, tile2: Tile): boolean { + const data1 = tileAnalysisCache.value.get(tile1.id) + const data2 = tileAnalysisCache.value.get(tile2.id) + + if (!data1 || !data2) return false + + const colorDifference = Math.sqrt( + Math.pow(data1.color.r - data2.color.r, 2) + + Math.pow(data1.color.g - data2.color.g, 2) + + Math.pow(data1.color.b - data2.color.b, 2) + ) + + return ( + colorDifference <= COLOR_SIMILARITY_THRESHOLD && + Math.abs(data1.edge - data2.edge) <= EDGE_SIMILARITY_THRESHOLD && + data1.namePrefix === data2.namePrefix + ) + } + + function cleanup() { + worker.terminate() + } + + return { + processTile, + areTilesRelated, + cleanup + } +} \ No newline at end of file diff --git a/src/types/tileTypes.ts b/src/types/tileTypes.ts new file mode 100644 index 0000000..c17a5eb --- /dev/null +++ b/src/types/tileTypes.ts @@ -0,0 +1,20 @@ +export interface TileAnalysisResult { + tileId: string + color: { + r: number + g: number + b: number + } + edge: number + namePrefix: string +} + +export interface TileWorkerMessage { + imageData: ImageData + tileId: string + tileName: string +} + +export interface TileCache { + [key: string]: TileAnalysisResult +} \ No newline at end of file diff --git a/src/workers/tileAnalyzerWorker.ts b/src/workers/tileAnalyzerWorker.ts new file mode 100644 index 0000000..701a1a8 --- /dev/null +++ b/src/workers/tileAnalyzerWorker.ts @@ -0,0 +1,64 @@ +import type { TileAnalysisResult } from '@/types/tileTypes' + +const PIXEL_SAMPLE_RATE = 4 + +self.onmessage = async (e: MessageEvent) => { + const { imageData, tileId, tileName } = e.data + const result = analyzeTile(imageData, tileId, tileName) + self.postMessage(result) +} + +function analyzeTile(imageData: ImageData, tileId: string, tileName: string): TileAnalysisResult { + const { r, g, b } = getDominantColorFast(imageData) + const edge = getEdgeComplexityFast(imageData) + const namePrefix = tileName.split('_')[0] + + return { + tileId, + color: { r, g, b }, + edge, + namePrefix + } +} + +function getDominantColorFast(imageData: ImageData) { + const data = new Uint8ClampedArray(imageData.data.buffer) + let r = 0, g = 0, b = 0, total = 0 + const length = data.length + + for (let i = 0; i < length; i += 4 * PIXEL_SAMPLE_RATE) { + if (data[i + 3] > 0) { + r += data[i] + g += data[i + 1] + b += data[i + 2] + total++ + } + } + + return total > 0 ? { + r: Math.round(r / total), + g: Math.round(g / total), + b: Math.round(b / total) + } : { r: 0, g: 0, b: 0 } +} + +function getEdgeComplexityFast(imageData: ImageData) { + const data = new Uint8ClampedArray(imageData.data.buffer) + const width = imageData.width + const height = imageData.height + let edgePixels = 0 + + for (let y = 0; y < height; y += PIXEL_SAMPLE_RATE) { + for (let x = 0; x < width; x += PIXEL_SAMPLE_RATE) { + const i = (y * width + x) * 4 + if (data[i + 3] > 0 && ( + x === 0 || y === 0 || x >= width - PIXEL_SAMPLE_RATE || y >= height - PIXEL_SAMPLE_RATE || + data[i - 4 * PIXEL_SAMPLE_RATE + 3] === 0 || data[i + 4 * PIXEL_SAMPLE_RATE + 3] === 0 || + data[i - width * 4 * PIXEL_SAMPLE_RATE + 3] === 0 || data[i + width * 4 * PIXEL_SAMPLE_RATE + 3] === 0 + )) { + edgePixels++ + } + } + } + return edgePixels * PIXEL_SAMPLE_RATE +} \ No newline at end of file