Building a High-Performance Wave Function Collapse Solver for three.js
Hi everyone,
I’m here after this pleasant exchange of ideas with @agargaro.
I’ve been working on a highly optimized Wave Function Collapse (WFC) solver, tailored for use with three.js, especially for procedural content generation inside WebGL apps.
I thought I’d share the full technical breakdown of the optimization journey — from naive code to ultra-fast performance!
WFC is a surprisingly simple concept. You can think of it like a fancy version of Sudoku.
Rather than explaining all the details myself, I’ll point you to some people who do a much better job at it:
-
As a starting point, I suggest this excellent video by Martin Donald: Superpositions, Sudoku, the Wave Function Collapse algorithm.
-
I highly recommend reading Boris The Brave’s dev log articles: Wave Function Collapse Explained and Wave Function Collapse Tips and Tricks.
-
Check out this live demo by Oskar Stålberg (creator of Townscaper): Live Wave Function Collapse Demo.
-
Dive deeper with Oskar’s talk: Beyond Townscapers.
-
Finally, this fun (and chaotic) one-hour+ live coding session where I got some of my testing tiles: Coding Challenge 171: Wave Function Collapse.
Step 1: Naive Implementation
The initial prototype was textbook WFC:
-
Grid = 2D array of tile option arrays.
-
Propagation = full array scans with push/pop/shift operations.
-
Collapse picking = linear scan over all cells to find the lowest entropy.
Problems:
-
Massive memory churn (array slicing, object creation)
-
Grids over 50×50 took minutes to collapse
-
Completely impractical for real-time use
Step 2: Full Typed Array Rewrite
First big improvement: Buffers everywhere!
-
Replaced dynamic arrays with preallocated
Uint8Array
,Uint16Array
, etc. -
Option sets encoded as fixed-size buffers.
-
No temporary objects or per-frame GC pressure.
-
Operations done via direct index math (
grid[i]
instead of nested arrays).
Result:
Minutes → ~30 seconds for a 100×100 grid.
Step 3: Bitmask Encoding for Options
Next bottleneck: tile compatibility checks.
Previously:
Each neighbor would iterate over its allowed tiles, checking compatibility manually.
New system:
-
Each tile is represented as a bitmask.
-
Possible tiles per cell = a bitfield.
-
Neighbor constraints = precomputed bitfield compatibility matrices.
-
Collapse & propagation = simple bitwise ANDs and ORs.
Benefits:
-
Tile constraint checks are constant time.
-
Propagation becomes just bit ops.
-
Massive memory savings: 1 bit per tile instead of 1+ objects.
Result:
~30 seconds → ~2–3 seconds.
Step 4: Min-Heap for Entropy Picking
In WFC, you must find the cell with the fewest options (lowest entropy) at each collapse step.
Original method:
Linear scan (O(n)
) over all grid cells.
Optimized method:
-
Maintain a Min-Heap (binary heap) storing cells sorted by entropy.
-
When a cell’s options shrink, update its position (
O(log n)
heapify). -
Picking the next collapse cell becomes effectively O(1).
Heap Details:
-
Implemented using Typed Arrays (buffers everywhere!)
-
Stores entropy and cell index pairs compactly
-
Separate heap position tracking for fast updates
Result:
Seconds → Hundreds of milliseconds for big grids.
Step 5: Circular Stack for Propagation Queue
Propagation originally used recursion and array .shift()
/.unshift()
, creating call stack overhead and allocations.
Optimized propagation system:
-
Circular Stack Buffer using a fixed-size
Uint32Array
-
Head/tail pointers wrap around the buffer
-
Constant-time push/pop, no shifting
-
No recursion = no call stack growth
Propagation is now:
-
Zero allocations
-
Linear in the number of affected cells
-
Constant memory usage
Result:
Solver runtime cut even further — now fitting almost fully inside a single render frame (even on medium-sized grids)!
Work in Progress
To aid debugging and tuning:
-
Building a tile edges editor to define tile relations and constraints.
-
Building a canvas-based WFC visualizer:
-
Configurable grid size, seed, cell size
-
Real-time collapse display
-
Click-to-force-collapse cells
-
Live logs and solver timing logs
Current Status & Next Steps
-
2D WFC solver finished (*almost)
-
Reasonable real-time collapse even for large grids
-
2D Renderer with
THREE.Points
and texture atlases -
2.5D Renderer (still using
THREE.Points
) -
Full 3D WFC variant using
BatchedMesh
and layered texture atlases (thanks again to @agargaro and @gkjohnson) -
Backtracking for better contradiction handling
-
Web workers with transferable and shared arrays
-
Explore the overlay model
Closing Thoughts
This was an intense but super rewarding project.
Key takeaways:
-
Avoid memory churn: preallocate everything early
-
Think bitwise: bitmasks are a huge win for option management
-
Move expensive operations (like entropy picking) from O(n) → O(log n) structures
I’ll be sharing my advancements here as I continue optimizing and extending procedural generation for three.js!