1
0
mirror of https://github.com/osmarks/website synced 2024-12-25 01:20:33 +00:00
website/experiments/wave-function-collapse/index.html
2024-10-27 20:18:37 +00:00

335 lines
11 KiB
HTML

---
title: Wave Function Collapse
description: A basic implementation of the WFC procedural generation algorithm.
slug: wfc
---
<style>
canvas {
border: 1px solid gray;
}
#colors .color {
display: inline-block;
width: 20px;
height: 20px;
margin: 5px;
border: 1px solid gray;
}
</style>
<canvas id="output"></canvas>
<canvas id="pattern"></canvas>
<div id="controls">
<button id="step">Step</button>
<button id="run">Run</button>
<button id="reset">Reset</button>
<input type="checkbox" id="hard-borders" checked><label for="hard-borders">Hard borders</label>
<input type="checkbox" id="ignore-directions" checked><label for="ignore-directions">Ignore directions</label>
<input type="checkbox" id="diagonals" checked><label for="diagonals">Diagonals</label>
</div>
<div id="colors"></div>
<div id="out"></div>
<script>
const write = line => {
const out = document.querySelector("#out")
out.innerHTML = ""
out.appendChild(document.createTextNode(line))
out.appendChild(document.createElement("br"))
}
const PATTERN_W = 6
const W = 16
const PX = 24
const PATTERN_PX = 64
const map = ([x, y]) => x + y * W
const unmap = a => [a % W, Math.floor(a / W)]
const vsum = ([a, b], [c, d]) => [a + c, b + d]
const inRange = ([x, y]) => x >= 0 && x < W && y >= 0 && y < W
const modclamp = x => x < 0 ? 10 + x : x % 10
let currentConstraintSet = {}
let colorsInUse = new Set()
let ignoreDirections = false
const readIgnoreDirections = () => ignoreDirections = document.querySelector("#ignore-directions").checked
readIgnoreDirections()
document.querySelector("#ignore-directions").addEventListener("change", readIgnoreDirections)
let hardBorders = false
const readHardBorders = () => hardBorders = document.querySelector("#hard-borders").checked
readHardBorders()
document.querySelector("#hard-borders").addEventListener("change", readHardBorders)
const getAdjacent = diagonals => diagonals ? [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [1, -1], [-1, 1], [-1, -1]] : [[0, 1], [0, -1], [1, 0], [-1, 0]]
let adj = null
const ctx = document.querySelector("canvas#output").getContext("2d")
ctx.canvas.width = PX * W
ctx.canvas.height = PX * W
let grid = Array(W * W).fill(null).map(x => ({ value: null, options: new Set() }))
const joinConstraintKey = adjs => {
if (ignoreDirections) adjs.sort()
return adjs.join(",")
}
const regenerateOptionsAt = (coord) => {
const data = grid[map(coord)]
const slots = []
for (const a of adj) {
const n = vsum(a, coord)
if (inRange(n)) {
const value = grid[map(n)].value
slots.push(value === null ? undefined : value)
} else {
slots.push(null)
}
}
if (slots.every(x => x === undefined)) {
data.options = colorsInUse // no constraints apply - shortcut
return
}
const out = new Set()
let hasMatch = false
// N-way Cartesian product of slots
const go = (slots, acc) => {
if (slots.length === 0) {
for (const prod of acc) {
const ckey = joinConstraintKey(prod)
if (currentConstraintSet[ckey]) {
for (const val of currentConstraintSet[ckey]) {
out.add(val)
}
hasMatch = true
}
}
} else {
const [fst, ...rest] = slots
if (fst === undefined) {
return go(rest, Array.from(colorsInUse).concat(hardBorders ? [] : [null]).flatMap(x => acc.map(xs => [x].concat(xs))))
} else {
return go(rest, acc.map(xs => [fst].concat(xs)))
}
}
}
go(slots, [[]])
data.options = out
}
const regenerateOptions = () => {
for (let x = 0; x < W; x++) {
for (let y = 0; y < W; y++) {
regenerateOptionsAt([x, y])
}
}
}
const readAdjacent = () => {
adj = getAdjacent(document.querySelector("#diagonals").checked)
regenerateOptions()
}
readAdjacent()
document.querySelector("#diagonals").addEventListener("change", readAdjacent)
const updatePos = (pos, value) => {
grid[map(pos)].value = value
grid[map(pos)].options = null
for (const a of adj) {
const n = vsum(a, pos)
if (inRange(n) && grid[map(n)].value === null && grid[map(n)].options !== null) {
regenerateOptionsAt(n)
}
}
}
const findBestCandidates = grid => grid.reduce(([bestQty, bestPos], val, index) => {
if (val.value !== null) {
return [bestQty, bestPos]
}
if (val.options.size < bestQty) {
return [val.options.size, [unmap(index)]]
}
if (val.options.size == bestQty) {
bestPos.push(unmap(index))
}
return [bestQty, bestPos]
}, [colorsInUse.size + 1, []])
const render = grid => {
for (let x = 0; x < W; x++) {
for (let y = 0; y < W; y++) {
const data = grid[map([x, y])]
const level = data.options && Math.floor((data.options.size + 1) / (colorsInUse.size + 1) * 255).toString(16).padStart(2, "0") || "00"
ctx.fillStyle = data.value !== null ? data.value : `#${level}${level}${level}`
ctx.fillRect(x * PX, y * PX, PX, PX)
if (data.value === null) {
ctx.strokeStyle = "#000000"
ctx.beginPath()
ctx.moveTo(x * PX, y * PX)
ctx.lineTo((x + 1) * PX, (y + 1) * PX)
ctx.closePath()
ctx.stroke()
ctx.strokeStyle = null
}
}
}
}
let recentColors = ["#ffffff", "#000000", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#ff00ff"]
const recentColorsDiv = document.querySelector("#colors")
let currentColor = recentColors[0]
const pushColor = color => {
recentColors = [color].concat(recentColors.filter(x => x !== color))
if (recentColors.length > 10) {
recentColors.shift()
}
currentColor = color
updateRecentColors()
}
const updateRecentColors = () => {
recentColorsDiv.innerHTML = ""
for (const color of recentColors) {
recentColorsDiv.appendChild(document.createElement("div"))
recentColorsDiv.lastChild.classList.add("color")
recentColorsDiv.lastChild.style.backgroundColor = color
recentColorsDiv.lastChild.addEventListener("click", () => {
pushColor(color)
})
}
}
const handleCanvasMouse = ev => {
if ((ev.buttons & 1) === 0) {
return
}
const [x, y] = [Math.floor(ev.offsetX / PX), Math.floor(ev.offsetY / PX)]
if (x < 0 || x >= W || y < 0 || y >= W) {
return
}
const coord = [x, y]
const data = grid[map(coord)]
updatePos(coord, currentColor)
render(grid)
}
ctx.canvas.addEventListener("mousedown", handleCanvasMouse)
ctx.canvas.addEventListener("mousemove", handleCanvasMouse)
const pick = arr => arr[Math.floor(Math.random() * arr.length)]
render(grid)
step.onclick = () => {
const [qty, pos] = findBestCandidates(grid)
if (pos.length === 0) {
write("Done.")
return
}
if (qty === 0) {
write("Contradiction.")
return
}
write(`${qty} options on ${pos.length} tiles.`)
if (qty === 1) {
write(`Resolving ${pos.length} tiles.`)
for (const p of pos) {
const newValue = Array.from(grid[map(p)].options)[0]
updatePos(p, newValue)
}
} else {
const p = pick(pos)
const newValue = pick(Array.from(grid[map(p)].options))
updatePos(p, newValue)
}
render(grid)
}
let timer
const runButton = document.querySelector("#run")
runButton.addEventListener("click", () => {
if (runButton.innerHTML === "Run") {
runButton.innerHTML = "Stop"
timer = setInterval(step.onclick, 100)
} else {
runButton.innerHTML = "Run"
clearInterval(timer)
}
})
const reset = () => {
grid = Array(W * W).fill(null).map(x => ({ value: null, options: new Set() }))
clearInterval(timer)
runButton.innerHTML = "Run"
render(grid)
regenerateOptions()
}
document.querySelector("#reset").addEventListener("click", reset)
const patternEditor = () => {
const map = ([x, y]) => x + y * PATTERN_W
const pattern = document.querySelector("canvas#pattern")
const ctx = pattern.getContext("2d")
let grid = Array(PATTERN_W * PATTERN_W).fill(null).map(x => ({ value: currentColor }))
const inRange = ([x, y]) => x >= 0 && x < PATTERN_W && y >= 0 && y < PATTERN_W
pattern.width = PATTERN_PX * PATTERN_W
pattern.height = PATTERN_PX * PATTERN_W
ctx.fillStyle = currentColor
ctx.fillRect(0, 0, PATTERN_PX * PATTERN_W, PATTERN_PX * PATTERN_W)
const constraintKeyFor = coord => {
let constraintKey = []
for (const a of adj) {
const n = vsum(a, coord)
if (inRange(n)) {
constraintKey.push(grid[map(n)].value)
} else {
constraintKey.push(null)
}
}
return joinConstraintKey(constraintKey)
}
const recomputeConstraintSet = () => {
currentConstraintSet = new Set()
colorsInUse = new Set()
for (let x = 0; x < PATTERN_W; x++) {
for (let y = 0; y < PATTERN_W; y++) {
const coord = [x, y]
const data = grid[map(coord)]
const constraintKey = constraintKeyFor(coord)
currentConstraintSet[constraintKey] ??= new Set()
currentConstraintSet[constraintKey].add(data.value)
colorsInUse.add(data.value)
}
}
regenerateOptions()
}
const handlePatternMouse = ev => {
if ((ev.buttons & 1) === 0) {
return
}
const [x, y] = [Math.floor(ev.offsetX / PATTERN_PX), Math.floor(ev.offsetY / PATTERN_PX)]
if (x < 0 || x >= PATTERN_W || y < 0 || y >= PATTERN_W) {
return
}
ctx.fillStyle = currentColor
ctx.fillRect(PATTERN_PX * x, PATTERN_PX * y, PATTERN_PX, PATTERN_PX)
grid[map([x, y])].value = currentColor
recomputeConstraintSet()
}
pattern.addEventListener("mousedown", handlePatternMouse)
pattern.addEventListener("mousemove", handlePatternMouse)
recomputeConstraintSet()
}
updateRecentColors()
patternEditor()
</script>