Skip to content

Commit

Permalink
wfc blog
Browse files Browse the repository at this point in the history
  • Loading branch information
stevenctl committed Sep 15, 2023
1 parent c941b24 commit ff413eb
Show file tree
Hide file tree
Showing 28 changed files with 288 additions and 0 deletions.
203 changes: 203 additions & 0 deletions content/posts/tech/3d-autotiling/02-basic-wfc/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
---
title: Basic Wave Function Collapse
weight: 2
---

![wfc generation in blender](generate.mp4)

This 3D autotiling project started when I stumbled on [Martin Donald's Wave
Function Collapse video](https://www.youtube.com/watch?v=2SuvO4Gi7uY). The idea
was explained so well that I had to try it out.

In this post I will attempt to outline the high level structure of a Wave
Function Collapse implementation with a bit of psudo-Python. The following
isn't really intended to be a tutorial, but more of an introduction that sets
the stage for some future posts.

## Types

We have a few foundational types that make up the implementation:

### Prototypes

```python
class Prototype:
name: str
mesh: str
rotation: int

# sockets
north: str
east: str
south: str
west: str
top: str
bottom: str
```

A prototype represents a possible choice to put somewhere in the grid. We will
have 4 prototypes for each tile in our tileset, one for each 90 degree
rotation.

### Sockets

On each of the 6 faces we will have a socket. These identify which `Prototype`s
can connect to eachother in each direction. The socket is a representation of
what we see when we look at one face of the tile's bounding box.


![highligted socket](socket.jpg)

For the north and south or east and west sockets to match, they must be mirrors
of one another. For a vertical face's socket to match (top to bottom) the
sockets must be identical. Sockets are arbitrary strings, except for the
suffixes on horizontal faces' sockets: `s` (symmetrical) and `f` (flipped).

```python
def compatible_sockets(face, a, b) -> bool:
# symmetrical faces should be identical
if face in {"top", "bottom"} or a.endswith("s"):
return a == b
flipped_b = socket[:-1] if socket.endswith("f") else b + "f"
return a == flipped(b)
```

### Cells

```python
class Cell:
possibilities: list[Prototype]
```

A cell is one element in a `Grid`, which is just a collection of `Cell`s.

### Grid

Turns out there are a lot of ways to represent the `Grid`.

It could be a 3D array:

```python
class Grid:
grid: list[list[list[Cell]]]
```

Or for an infinite grid, a map that lazily populated:

```python
class Grid:
grid: dict[Vec3, Cell]

def __getitem__(self, coord: Vec3):
if key not in self.grid:
self.grid[coord] = Cell()
return self.grid[coord]
```

It doesn't really batter as long as we can look things up using a 3D integer coordinate.

## Implementation

```python
def solve():
work_list = []
solved = False
iteration = 0
while not solved and iteration < MAX_ITERATIONS:
iteration += 1

cell, coord = grid.find_min_entropy()
if cell is None:
solved = True
break

cell.collapse()
work_list.append(coord)
while len(work_list) > 0:
work_list += propagate(work_list.pop())
```

This is the main structure of the program. Make a random selection in one of the cells
recursively propagate that out and repeat until every cell has only one possibility.
You can run the outer loop by hand in this [web demo](https://bolddunkley.itch.io/wfc-mixed)
from Martin Donald.

### Collapse

In this algorithm, "entropy" is a cute name for the length of a `Cell`'s
possibility list. Zero means there is a contradiction and we will never be able
to solve the `Grid`. One means we know the `Prototype` that we've chosen for
this cell. In each iteration, we find the unsolved Cell we're most certain
about:

```python
def min_entropy(self) -> Cell:
def entropy(cell):
if cell.entropy() < 2:
return math.inf
return cell.entropy()
return min(self.grid, key=entropy)

```

Then we "collapse" it's possibilities into a random choice:

```python
def collapse(self):
idx = randint(0, len(self.possibilities) - 1)
self.possibilities = [self.possibilities[idx]]
```

### Propagate

```python
def propagate(coord):
cur_cell = self.grid.get(cur_coord)
changed_neighbors = []
for direction in opposing_faces.keys():
next_coord = add_vec3(cur_coord, face_deltas[direction])
if not self.grid.is_in_bounds(next_coord):
continue
next_cell = self.grid.get(next_coord)
changed = grid[next_cell].constrain_to_neighbor(cur_cell, direction)
if changed:
changed_neighbors.append(next_coord)
self.propagation_stack.append(next_coord)
return changed_neighbors
```

Any time we change the possibility list of one cell,
the neighboring cells are possibly affected. We reduce the possibility list
of each neighboring cell with `constrain_to_neighbor` and if that causes
a change, we will have to propagate to _that_ cell's neighbors as well.


### Constrain

```python
def constrain_to_neighbor(self, cell: Cell, face: str):
old = len(self.possibilities)
self.possibilities = [
p
for p in self.possibilities[]
if compatible_sockets(my_face, p[my_face], cell[face])
]
return old != len(self.possibilities)
```

Here we reduce the possiblity list to only include `Prototype`s that are compatible
on the opposing face.

There are far more efficient ways of doing this, like setting up an adjacency
table. This is also the part of the algorithm that is most likely to have edge
cases to help guide the algorithm's behavior to produce specific results.


## Conclusion

While this post was mostly psuedo code, it matches up pretty well to my first
WFC attempt. The code is available
[here](https://github.com/stevenctl/basic-wfc-blender) as well as a sample
Blender file you can use to run it. This implementation has a lot of issues and
isn't scalable, but I did find I use for it that's covered in a later post.

Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
85 changes: 85 additions & 0 deletions content/posts/tech/3d-autotiling/03-tileset/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
title: Generating a 3D Tileset
weight: 3
---

## Enumerating Tiles

In the [last post](../01-marching-squares/), we built a basic autotiler for a 2D
square grid. We will need a tileset before we can extend it to support a 3D cube
grid. For each cell, there are now eight corners and therefore 2⁸ (256) possible
ways to fill that cell.

Rather than thinking in corners, I prefer to consider the octants after dividing
the cube in half in each dimension. In the same way as before, we assign one bit
of a now 8-bit integer to each octant.

![bits per octant](bits.jpg)

We can take an integer between `0` and `255` and convert it
to or from a `2x2x2` array of "filled" or "empty".

```python
def int_to_cube(c_int: int) -> np.ndarray:
s = format(c_int, "#010b")
o = np.array([int(s[i]) for i in range(2, len(s))]).reshape((2, 2, 2))
return o

def cube_to_int(cube: np.ndarray) -> int:
return int("".join(cube.reshape(8).astype(str)), 2)
```

We're using Python 3 with NumPy because `ndarray` is very convenient.
This code doesn't need to run in our game engine and will end up
being more useful in Blender scripts, which must be Python.

Similar to 2D, many of cases are transformations of other cases. We can generate
the unique cases pretty easily:

```python
def possible_tiles():
seen = set()
unique_cubes = set()

def add(cube, unique=False):
v = cube_to_int(cube)
if unique and v not in seen:
unique_cubes.add(v)
seen.add(v)

# skip 0 and 255 because they don't need a model
# they're empty and interior tiles
for i in range(1, 255):
if i not in seen:
unique_cubes.add(i)
seen.add(i)

# register the transformations as "seen"
# so that we will ignore them in future interations
rc = int_to_cube(i)
for rot in range(4):
rc = np.rot90(rc, axes=(0, 1), k=rot)
seen.add(cube_to_int(rc))
seen.add(cube_to_int(np.flip(rc, axis=1)))

return unique_cubes


if __name__ == "__main__":
tiles = possible_tiles()
print(len(tiles))
```

53 unique cases. Not bad! We can safely ignore the `0` and `255` cases. Because
0 is empty and 255 is completely on the interior, so we can't actually see it,
there is no need to provide a model for them.

## Generating Meshes

// TODO blender script to place some cubes

## Combining Handmande Meshes

// TODO 26 subtiles to generate the rest
// Should I figure out rotations or use WFC
// Should I write a basic WFC post first using vertex data sockets?
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit ff413eb

Please sign in to comment.