diff --git a/README.md b/README.md index 85188a2..9bd4dd1 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Wave Function Collapse is a constraint-satisfaction algorithm inspired by quantu - [Highly optimized version supporting both single-image and pre-tiled source images](https://github.com/pierrebai/Wave-Function-Collapse) A detailed write-up of how the changes were done and why can be found in its own [readme here](https://github.com/pierrebai/Wave-Function-Collapse/tree/main/p5js/hybrid-model/README.md) +- An optimised overlaping model using queues (instead of recurcion) and backtracking to avoid paradoxes. [Version @alin256](https://github.com/alin256/Wave-Function-Collapse/tree/efficientQueueBranch). The version also adds some GUI improvements. ## Key Resources diff --git a/p5js/overlapping-model/cell.js b/p5js/overlapping-model/cell.js index 93edc20..9e14b9c 100644 --- a/p5js/overlapping-model/cell.js +++ b/p5js/overlapping-model/cell.js @@ -1,5 +1,6 @@ // Saving the log of 2 for shannon entropy calculation const log2 = Math.log(2); +const SHOW_OPTION_COUNT_IN_CELL = false; // A Cell is a single element of the grid class Cell { @@ -16,8 +17,6 @@ class Cell { // Has it been collapsed to a single tile? this.collapsed = false; - // Has it already been checked during recursion? - this.checked = false; // Initialize the options with all possible tile indices for (let i = 0; i < tiles.length; i++) { @@ -25,7 +24,8 @@ class Cell { } // This keeps track of what the previous options were - // Saves recalculating entropy if nothing has changed + // Saves time recalculating entropy if nothing has changed + // TODO (Sergey): I think this should not be needed, but let's keep until someone varifies that this.previousTotalOptions = -1; // Variable to track if cell needs to be redrawn @@ -87,6 +87,15 @@ class Cell { fill(sumR, sumG, sumB); noStroke(); square(this.x, this.y, this.w); + + if (SHOW_OPTION_COUNT_IN_CELL) { + fill(0); + noStroke(); + textSize(this.w / 2); + textAlign(CENTER, CENTER); + text(this.options.length, this.x + this.w / 2, this.y + this.w / 2); + } + } // No need to redraw until something has changed this.needsRedraw = false; diff --git a/p5js/overlapping-model/images/list.txt b/p5js/overlapping-model/images/list.txt new file mode 100644 index 0000000..3d13d06 --- /dev/null +++ b/p5js/overlapping-model/images/list.txt @@ -0,0 +1,39 @@ +3Bricks.png +Angular.png +BrownFox.png +Cat.png +city.png +ColoredCity.png +ct_logo.png +Disk.png +Dungeon.png +example.png +Flowers.png +Font.png +Hogs.png +Knot.png +Lake.png +Link2.png +MagicOffice.png +Mazelike.png +Mountains.png +Office.png +Office2.png +Paths.png +Platformer.png +RedDot.png +RedMaze.png +Rooms.png +Rule126.png +Sand.png +Sewers.png +SimpleKnot.png +SimpleWall.png +Skew2.png +Skyline.png +SmileCity.png +Spirals.png +Town.png +Village.png +WalledDot.png +water.png diff --git a/p5js/overlapping-model/sketch.js b/p5js/overlapping-model/sketch.js index df840e1..33dab06 100644 --- a/p5js/overlapping-model/sketch.js +++ b/p5js/overlapping-model/sketch.js @@ -7,26 +7,92 @@ let grid; // Refactored variables names // Number of cells along one dimension of the grid -let GRID_SIZE = 50; +let GRID_SIZE = 60; // Maximum depth for recursive checking of cells -let MAX_RECURSION_DEPTH = 16; +let MAX_RECURSION_DEPTH = 1000000000; +// const REDUCTIONS_PER_FRAME = 10000; +let reductionPerFrame = 1000; +const TARGET_UPDATE_TIME_MS = 15; // Target frame rate of 60 FPS // Size of each tile (3x3 by default) let TILE_SIZE = 3; +let PARADOX = "paradox"; let w; +let chooseModelDropDown; +let queueLengthTextBox; +let gridCopy; +let chosenCellIndex; + +let recoveringParadox = false; +let reductionQueue = []; +let shuffledOptions = []; + // Turn on or off rotations and reflections -const ROTATIONS = true; +const ROTATIONS = false; const REFLECTIONS = false; function preload() { - sourceImage = loadImage('images/flowers.png'); + sourceImage = loadImage('images/3Bricks.png'); } function setup() { - createCanvas(500, 500); + createCanvas(720, 720); // Cell width based on canvas size and grid size w = width / GRID_SIZE; + setupTiles(); + + // add pause checkbox + let pauseCheckbox = createCheckbox('Pause', false); + pauseCheckbox.changed(() => { + if (pauseCheckbox.checked()) { + noLoop(); + } else { + loop(); + } + }); + + chooseModelDropDown = createSelect(); + // add logic to selection + chooseModelDropDown.changed(() => { + // Get the selected value + const selectedValue = chooseModelDropDown.value(); + // check if it is the file name ending with png + if (selectedValue.endsWith('.png')) { + // Load the selected image + sourceImage = loadImage(`images/${selectedValue}`, () => { + // Setup tiles again with the new image + console.log(`Loading new image.`); + setupTiles(); + }); + } else { + setupTiles(); + } + }); + + chooseModelDropDown.option("-deafault-"); + + fetch('images/list.txt') + .then(response => response.text()) + .then(text => { + const images = text.split('\n').filter(name => name.trim() !== ''); + images.forEach(image => { + chooseModelDropDown.option(image); + }); + }); + + // Add restart button + let restartButton = createButton('Restart'); + restartButton.mousePressed(() => { + setupTiles(); + }); + + // Add a textbox that will show queue length + queueLengthTextBox = createP("Processed queue: " + reductionQueue.length); + +} + +function setupTiles() { // Extract tiles and calculate their adjacencies tiles = extractTiles(sourceImage); for (let tile of tiles) { @@ -36,15 +102,27 @@ function setup() { // Create the grid initializeGrid(); - // Perform initial wave function collapse step - wfc(); + // resetting simulation state variables + recoveringParadox = false; + reductionQueue = []; + shuffledOptions = []; - // The WFC function only collapses one cell at a time - // This extra bit collapses any other cells that can be - for (let cell of grid) { - if (cell.options.length == 1) { - cell.collapsed = true; - reduceEntropy(grid, cell, 0); + // start the loop if not already + loop(); +} + +function reInitializeGrid(gridSave) { + grid = []; + // Initialize the grid with cells from the saved grid + let count = 0; + for (let j = 0; j < GRID_SIZE; j++) { + for (let i = 0; i < GRID_SIZE; i++) { + let cell = new Cell(tiles, i * w, j * w, w, count); + cell.options = gridSave[count].options; + cell.collapsed = gridSave[count].collapsed; + cell.needsRedraw = true; + grid.push(cell); + count++; } } } @@ -63,6 +141,7 @@ function initializeGrid() { count++; } } + } function draw() { @@ -74,132 +153,214 @@ function draw() { // Draw each cell grid[i].show(); - // Reset all cells to "unchecked" - grid[i].checked = false; } } // The Wave Function Collapse algorithm function wfc() { - // Calculate entropy for each cell - for (let cell of grid) { - cell.calculateEntropy(); - } + if (reductionQueue.length == 0) { + if (!recoveringParadox) { + // Calculate entropy for each cell + for (let cell of grid) { + cell.calculateEntropy(); + } - // Find cells with the lowest entropy (simplified as fewest options left) - // Thie refactored method to find the lowest entropy cells avoids sorting - let minEntropy = Infinity; - let lowestEntropyCells = []; - - for (let cell of grid) { - if (!cell.collapsed) { - if (cell.entropy < minEntropy) { - minEntropy = cell.entropy; - lowestEntropyCells = [cell]; - } else if (cell.entropy === minEntropy) { - lowestEntropyCells.push(cell); + // Find cells with the lowest entropy (simplified as fewest options left) + // Thie refactored method to find the lowest entropy cells avoids sorting + let minEntropy = Infinity; + let lowestEntropyCells = []; + + for (let cell of grid) { + if (!cell.collapsed) { + if (cell.entropy < minEntropy) { + minEntropy = cell.entropy; + lowestEntropyCells = [cell]; + } else if (cell.entropy === minEntropy) { + lowestEntropyCells.push(cell); + } + } } + + // We're done if all cells are collapsed! + if (lowestEntropyCells.length == 0) { + noLoop(); + return; + } + + // Randomly select one of the lowest entropy cells to collapse + const cell = random(lowestEntropyCells); + cell.collapsed = true; + + // Need to redraw this cell + cell.needsRedraw = true; + + // copying in case something would go wrong + chosenCellIndex = cell.index; + gridCopy = JSON.parse(JSON.stringify(grid)); + shuffledOptions = shuffle(cell.options); } - } + // TODO - rerun this code if we did not converge - // We're done if all cells are collapsed! - if (lowestEntropyCells.length == 0) { - noLoop(); - return; - } + // Choose one option randomly from the cell's options + const pick = shuffledOptions.pop(); + recoveringParadox = false; - // Randomly select one of the lowest entropy cells to collapse - const cell = random(lowestEntropyCells); - cell.collapsed = true; - // Need to redraw this cell - cell.needsRedraw = true; + // If there are no possible tiles that fit there! + if (pick == undefined) { + console.log('Pick undefined: ran into a conflict'); + console.log("This should not happend if we have paradox recovery"); + // initializeGrid(); + return; + } - // Choose one option randomly from the cell's options - const pick = random(cell.options); + // Changing logic to gradually reduce entropy - // If there are no possible tiles that fit there! - if (pick == undefined) { - console.log('ran into a conflict'); - initializeGrid(); - return; - } + // Set the final tile + let workingCell = grid[chosenCellIndex]; + workingCell.options = [pick]; - // Set the final tile - cell.options = [pick]; + // add to queue + addToQueue(reductionQueue, workingCell, 0); + } + else { + const startTime = performance.now(); + let endTime = performance.now(); + + // Propagate entropy reduction to neighbors + let reductionCount = 0; + while (reductionQueue.length > 0) { + let result = reduceEntropyOnce(grid, reductionQueue); + if (result === PARADOX) { + reductionQueue = []; + recoveringParadox = true; + reInitializeGrid(gridCopy); + break; + } + reductionCount++; + endTime = performance.now(); + if (endTime - startTime >= TARGET_UPDATE_TIME_MS) { + break; + } + } - // Propagate entropy reduction to neighbors - reduceEntropy(grid, cell, 0); + queueLengthTextBox.html(`Processed queue: ${reductionCount}`); + } +} - // Collapse anything that can be! - for (let cell of grid) { - if (cell.options.length == 1) { - cell.collapsed = true; - reduceEntropy(grid, cell, 0); +function addToQueue(cellDepthQueueArray, cell, depth) { + // TODO implment a O(1) queue for better performance + // Check if the cell is already in the queue + for (let i = 0; i < cellDepthQueueArray.length; i++) { + if (cellDepthQueueArray[i].cell.index == cell.index) { + return; } } + + // Add the cell and depth to the queue + cellDepthQueueArray.push({ + cell: cell, + depth: depth + }); } -function reduceEntropy(grid, cell, depth) { + + +function reduceEntropyOnce(grid, cellDepthQueueArray) { + cellDepth = cellDepthQueueArray.shift(); + let cell = cellDepth.cell; + let depth = cellDepth.depth; + // Stop propagation if max depth is reached or cell already checked - if (depth > MAX_RECURSION_DEPTH || cell.checked) return; + if (depth > MAX_RECURSION_DEPTH) return "Recursion limit reached"; + // console.log("Recursion depth limit reached at " + depth); + + if (cell.options.length == 0) { + // Ignore conflicts + console.log("Updating cell: ran into a conflict"); + // Need to redraw this cell + cell.needsRedraw = true; + return PARADOX; + } - // Mark cell as checked - cell.checked = true; + if (cell.options.length == 1) { + cell.collapsed = true; + } - // Need to redraw this cell cell.needsRedraw = true; let index = cell.index; let i = floor(index % GRID_SIZE); let j = floor(index / GRID_SIZE); + let needsPropogation = 0; + // Update neighboring cells based on adjacency rules // RIGHT if (i + 1 < GRID_SIZE) { let rightCell = grid[i + 1 + j * GRID_SIZE]; - if (checkOptions(cell, rightCell, EAST)) { - reduceEntropy(grid, rightCell, depth + 1); + if (checkOptionsReduced(cell, rightCell, EAST)) { + addToQueue(cellDepthQueueArray, rightCell, depth + 1); + needsPropogation++; } } // LEFT if (i - 1 >= 0) { let leftCell = grid[i - 1 + j * GRID_SIZE]; - if (checkOptions(cell, leftCell, WEST)) { - reduceEntropy(grid, leftCell, depth + 1); + if (checkOptionsReduced(cell, leftCell, WEST)) { + addToQueue(cellDepthQueueArray, leftCell, depth + 1); + needsPropogation++; } } // DOWN if (j + 1 < GRID_SIZE) { let downCell = grid[i + (j + 1) * GRID_SIZE]; - if (checkOptions(cell, downCell, SOUTH)) { - reduceEntropy(grid, downCell, depth + 1); + if (checkOptionsReduced(cell, downCell, SOUTH)) { + addToQueue(cellDepthQueueArray, downCell, depth + 1); + needsPropogation++; } } // UP if (j - 1 >= 0) { let upCell = grid[i + (j - 1) * GRID_SIZE]; - if (checkOptions(cell, upCell, NORTH)) { - reduceEntropy(grid, upCell, depth + 1); + if (checkOptionsReduced(cell, upCell, NORTH)) { + addToQueue(cellDepthQueueArray, upCell, depth + 1); + needsPropogation++; } } + + if (needsPropogation > 0) { + return "Need to reduce entropy"; + } else { + return "Entropy reduced"; + } } -function checkOptions(cell, neighbor, direction) { +function checkOptionsReduced(cell, neighbor, direction) { // Check if the neighbor is valid and not already collapsed if (neighbor && !neighbor.collapsed) { // Collect valid options based on the current cell's adjacency rules + // TODO implement options as sets with O(min(n, k)) for intersection for faster performance let validOptions = []; for (let option of cell.options) { + if (!tiles[option]) { + continue; + } validOptions = validOptions.concat(tiles[option].neighbors[direction]); } + let oldOptLength = neighbor.options.length; // Filter the neighbor's options to retain only those that are valid neighbor.options = neighbor.options.filter((elt) => validOptions.includes(elt)); - return true; + + if (neighbor.options.length < oldOptLength) { + return true; + } else { + return false; + } } else { return false; }