What is a wave function and why does it collapse?
Wave function collapse is a algorithm that can procedurally generate images, text, audio and almost anything that follows a pattern of a simple set of initial parameters and a set of rules. But the neat part is, wfc(short for wave function collapse) does all these without any artificial intelligence shenanigans. The output of wfc is a random showcase of the emergent behavior/rules built by you. This emergent behavior is what fascinates me! Now let's dive deeper into the nuances of wfc!
The "wave" in wfc!
In this article we'll talk about image generation only, unless specified otherwise. The "wave function" is the initial state of the image where no pixel or "cell" has been "collapsed" yet. There are many quotes involved in the last sentence, so let's break it down to simpler parts.
The wave function:
The wave function is the grid representation of the to-be generated image. This grid will hold the final image after the implementation of the algorithm is complete. When we say the function is being collapsed, it means that a cell is being assigned a specific sub image. For example,
These tiles are the basic constituents of the final image. Note that by randomly assigning tiles to each cell in the image, we'll not get the desired "pattern" since will cases where tiles connect into nothingness.
To improve this haphazard placement of tiles while also making large scale images efficiently, we use the wave function collapse algorithm.
The "collapse" in wave function:
Finally, it's time for the fun part. I am going to use Python(3.8) in this article, but you can use any language of your choice after you understand the underlying algorithm. Let's go.
Step 1: Building the display
The visualization part is going to be handled by Pygame, a python library for game development. But we'll use it to just display some images onto the screen(and also other little functionalities that'll come later).
To install Pygame,
in windows
pip install pygame
and in unix systems,
pip3 install pygame
To build a basic display,
import pygame pygame.init() # global variables width = 600 height = 600 display = pygame.display.set_mode((width, height)) def main(): # game loop loop = True while loop: display.fill((0, 0, 0)) # event handler for event in pygame.event.get(): if event.type == pygame.QUIT: loop = False if event.type == pygame.KEYDOWN: if event.key == pygame.K_d: hover_toggle = not hover_toggle if event.key == pygame.K_q: loop = False exit() # update the display pygame.display.flip() # calling the main function if __name__ == "__main__": main()
Step 2: Creating the Cell class
Each individual "pixel" in the final image is going to be represented as a cell object. Therefore we are going to write a cell class that looks like this,
# Cell class class Cell: def __init__(self, x, y, rez, options): self.x = x self.y = y self.rez = rez self.options = options self.collapsed = False # method for drawing the cell def draw(self, win): if len(self.options) == 1: self.options[0].draw(win, self.y * self.rez, self.x * self.rez) # return the entropy of the cell def entropy(self): pass # update the collapsed variable def update(self): pass # observe the cell/collapse the cell def observe(self): pass
You'll notice that the methods 'entropy()', 'update()', and 'observe()' lack any actual code in them. Before writing the code let's understand what they actually do.
Entropy:
The entropy of a cell is a way of quantizing the number of choices for a cell. The choices are also called the states of the cell.Observe:
By observing a cell, we are going to select a random state from all the possible states. This is done after we calculate the possible states by inferring from the nearby cells. This is the crust of the wfc algorithm.Update:
In essence, update() is used to update the "collapsed" data variable of the cell object. The collapsed variable isTrue
if the number of possible states of the cell is 1(i.e., the length of the options variable is 1) andFalse
if otherwise.
Now, the content of the methods become,
# return the entropy/the length of options def entropy(self): return len(self.options) # update the collapsed variable def update(self): self.collapsed = bool(self.entropy() == 1) # observe the cell/collapse the cell def observe(self): try: self.options = [random.choice(self.options)] self.collapsed = True except: return
With this the cell class is finally complete.
Step 3: Creating the Tile class
The tile class is used for managing the individual tiles and setting the rules for assembling them in the image. The rules for each tile set may vary, so make sure to update the rulesets if you wanna change the tile set. There is also algorithms to generate rulesets based on the given images. But we'll not cover that in this article.
The tile class is simple. Each tile instance is assigned an index variable and is represented visually by the tile image. The possible tile that can placed above, to the right, below and to the left are stored in self.up, self.right, self.down and self.left data variables respectively. These variables are used to update each cell and make the "collapsing" of the wave function possible.
class Tile: def __init__(self, img): self.img = img self.index = -1 self.edges = [] self.up = [] self.right = [] self.down = [] self.left = [] # draw a single tile def draw(self, win, x, y): win.blit(self.img, (x, y)) # set the rules for each tile def set_rules(self, tiles): for tile in tiles: # upper edge if self.edges[0] == tile.edges[2]: self.up.append(tile) # right edge if self.edges[1] == tile.edges[3]: self.right.append(tile) # lower edge if self.edges[2] == tile.edges[0]: self.down.append(tile) # left edge if self.edges[3] == tile.edges[1]: self.left.append(tile)
Step 4: Creating the Grid class
The grid id going to hold the "wave", i.e., the image grid of the final output. This is the main core of the algorithm. So, let's get started.
The grid is going to hold a width, a height, a resolution variable(rez), a 2D array to hold all the cells in the output and a list of all the options(tiles) in the image.
Hence, the Grid class is initiated by,
class Grid: def __init__(self, width, height, rez, options): self.width = width self.height = height self.rez = rez self.w = self.width // self.rez self.h = self.height // self.rez self.grid = [] self.options = options
And a simple method for displaying the 2D array in image format to the pygame display.
# draw each cell in the grid def draw(self, win): for row in self.grid: for cell in row: cell.draw(win)
Then we need to initiate all the cells in the grid 2D array. We are going to make use of a method called "initiate()" to do that.
# initiate each spot in the grid with a cell object def initiate(self): for i in range(self.w): self.grid.append([]) for j in range(self.h): cell = Cell(i, j, self.rez, self.options) self.grid[i].append(cell)
Now, during each frame/cycle of the program, we are going to loop through all the cells in the grid array and calculate the entropy of each cell. Then we "collapse" the cell with minimum entropy to a single state. "Collapse" refers to the action of arbitrarily selecting a state from the possible options of the cell, based on its neighbors. The entropy of each cell in updated using the update method for every frame.
The heuristic_pick (picking cell with minimum entropy) method is,
# arbitrarily pick a cell using [entropy heuristic] def heuristic_pick(self): # shallow copy of a grid grid_copy = [i for row in self.grid for i in row] grid_copy.sort(key = lambda x:x.entropy()) filtered_grid = list(filter(lambda x:x.entropy() > 1, grid_copy)) if filtered_grid == []: return None least_entropy_cell = filtered_grid[0] filtered_grid = list(filter(lambda x:x.entropy()==least_entropy_cell.entropy(), filtered_grid)) # return a pick if filtered copy i s not empty pick = random.choice(filtered_grid) return pick
Finally, we are going to perform the "collapsing" phase of the algorithm. Here we are going to loop through all the cells and update their entropy based on their neighbors. In the initial state, the algorithm will collapse a random cell to a random state. Then on, the cell entropy updates will guide the way. We'll check the top, right, bottom and left neighbors of each cell and change its self.options
data variable accordingly. This options variable is used to quantify the entropy of each cell.
The code is given by,
# [WAVE FUNCTION COLLAPSE] algorithm def collapse(self): # pick a random cell using entropy heuristic pick = self.heuristic_pick() if pick: self.grid[pick.x][pick.y].options self.grid[pick.x][pick.y].observe() else: return # shallow copy of the gris next_grid = copy.copy(self.grid) # update the entropy values and superpositions of each cell in the grid for i in range(len(self.grid)): for j in range(len(self.grid[0])): if self.grid[i][j].collapsed: next_grid[i][j] = self.grid[i][j] else: # cumulative_valid_options will hold the options that will satisfy the "down", "right", "up", "left" # conditions of each cell in the grid. The cumulative_valid_options is computed by, cumulative_valid_options = self.options # check above cell cell_above = self.grid[(i - 1) % self.w][j] valid_options = [] # holds the valid options for the current cell to fit with the above cell for option in cell_above.options: valid_options.extend(option.down) cumulative_valid_options = [option for option in cumulative_valid_options if option in valid_options] # check right cell cell_right = self.grid[i][(j + 1) % self.h] valid_options = [] # holds the valid options for the current cell to fit with the right cell for option in cell_right.options: valid_options.extend(option.left) cumulative_valid_options = [option for option in cumulative_valid_options if option in valid_options] # check down cell cell_down = self.grid[(i + 1) % self.w][j] valid_options = [] # holds the valid options for the current cell to fit with the down cell for option in cell_down.options: valid_options.extend(option.up) cumulative_valid_options = [option for option in cumulative_valid_options if option in valid_options] # check left cell cell_left = self.grid[i][(j - 1) % self.h] valid_options = [] # holds the valid options for the current cell to fit with the left cell for option in cell_left.options: valid_options.extend(option.right) cumulative_valid_options = [option for option in cumulative_valid_options if option in valid_options] # finally assign the cumulative_valid_options options to be the current cells valid options next_grid[i][j].options = cumulative_valid_options next_grid[i][j].update() # re-assign the grid value after cell evaluation self.grid = copy.copy(next_grid)
I've added comments to make it a little more understandable.
But before testing this out, we have make our front-end layer compatible with out algorithm in the back-end.
For that we have to add a little more to our code. First for loading images with padding,
# function for loading images with given resolution/size def load_image(path, rez_, padding = 0): img = pygame.image.load(path).convert_alpha() img = pygame.transform.scale(img, (rez_ - padding, rez_ - padding)) return img
Then, we'll update the main function to initiate and update the grid("wave").
# main game function def main(): # loading tile images options = [] for i in range(5): # load tetris tile img = load_image(f"./assets/{i}.png", rez) # edge conditions for tetris tiles options[0].edges = [0, 0, 0, 0] options[1].edges = [1, 1, 0, 1] options[2].edges = [1, 1, 1, 0] options[3].edges = [0, 1, 1, 1] options[4].edges = [1, 0, 1, 1] # update tile rules for each tile for i, tile in enumerate(options): tile.index = i tile.set_rules(options) # wave grid wave = Grid(width, height, rez, options) wave.initiate() # game loop loop = True while loop: display.fill((0, 0, 0)) # event handler for event in pygame.event.get(): if event.type == pygame.QUIT: loop = False if event.type == pygame.KEYDOWN: if event.key == pygame.K_d: hover_toggle = not hover_toggle if event.key == pygame.K_q: loop = False exit() # grid draw function wave.draw(display) # grid collapse method to run the alogorithm wave.collapse() # update the display pygame.display.flip()
Finally, call the main function at last,
# calling the main function if __name__ == "__main__": main()
Note that the algorithm is not guaranteed to find the solution. It will fill the image as far as possible. To guarantee a solution, we can use backtracking algorithm and undo previously collapsed cell states and continue until a solution is reached.
Thank you for reading. Feel free to comment your thoughts and opinions about this article.
Top comments (1)
Just to give a little more background on this. WFC is based on the Model Synthesis Algorithm published in 2007. For more info see: paulmerrell.org/model-synthesis/