parent
14dc57c110
commit
5b0cc1f13c
@ -0,0 +1,440 @@ |
||||
--- |
||||
title: >- |
||||
Visualization 7 |
||||
description: >- |
||||
Feedback Loop. |
||||
series: viz |
||||
tags: tech art |
||||
--- |
||||
|
||||
<script type="text/javascript"> |
||||
|
||||
function randn(n) { |
||||
return Math.floor(Math.random() * n); |
||||
} |
||||
|
||||
const w = 100; |
||||
const h = 60; |
||||
|
||||
class Canvas { |
||||
constructor(canvasDOM) { |
||||
this.dom = canvasDOM; |
||||
this.ctx = canvasDOM.getContext("2d"); |
||||
|
||||
// expand canvas element's width to match parent. |
||||
this.dom.width = this.dom.parentElement.offsetWidth; |
||||
|
||||
// rectSize must be an even number or the pixels don't display nicely. |
||||
this.rectSize = Math.floor(this.dom.width / w /2) * 2; |
||||
|
||||
this.dom.width = w * this.rectSize; |
||||
this.dom.height = h * this.rectSize; |
||||
} |
||||
|
||||
rectSize() { |
||||
return Math.floor(this.dom.width / w); |
||||
} |
||||
} |
||||
|
||||
class UniverseState { |
||||
constructor(layers) { |
||||
this.tick = 0; |
||||
this.layers = layers; |
||||
} |
||||
|
||||
neighboringLayers(layerIndex) { |
||||
const prevIndex = layerIndex-1; |
||||
const prev = prevIndex < 0 ? null : this.layers[prevIndex]; |
||||
|
||||
const nextIndex = layerIndex+1; |
||||
const next = nextIndex >= this.layers.length ? null : this.layers[nextIndex]; |
||||
|
||||
return [prev, next]; |
||||
} |
||||
} |
||||
|
||||
const defaultKnobs = { |
||||
maxNewElsPerTick: 10, |
||||
ageOfDeath: 30, |
||||
drift: 30, |
||||
neighborScalar: 0, |
||||
prevLayerScalar: 0, |
||||
prevLayerLikenessScalar: 0, |
||||
nextLayerScalar: 0, |
||||
nextLayerLikenessScalar: 0, |
||||
chaos: 0, |
||||
}; |
||||
|
||||
class Layer { |
||||
constructor(className, newEl, knobs = {}) { |
||||
this.className = className; |
||||
this.els = {}; |
||||
this.diff = {}; |
||||
this.newEl = newEl; |
||||
this.knobs = { ...defaultKnobs, ...knobs }; |
||||
} |
||||
|
||||
_normCoord(coord) { |
||||
if (typeof coord !== 'string') coord = JSON.stringify(coord); |
||||
return coord; |
||||
} |
||||
|
||||
get(coord) { |
||||
return this.els[this._normCoord(coord)]; |
||||
} |
||||
|
||||
getAll() { |
||||
return Object.values(this.els); |
||||
} |
||||
|
||||
set(coord, el) { |
||||
this.diff[this._normCoord(coord)] = {action: "set", coord: coord, ...el}; |
||||
} |
||||
|
||||
unset(coord) { |
||||
this.diff[this._normCoord(coord)] = {action: "unset"}; |
||||
} |
||||
|
||||
applyDiff() { |
||||
for (const coordStr in this.diff) { |
||||
const el = this.diff[coordStr]; |
||||
delete this.diff[coordStr]; |
||||
|
||||
if (el.action == "set") { |
||||
delete el.action; |
||||
this.els[coordStr] = el; |
||||
} else { |
||||
delete this.els[coordStr]; |
||||
} |
||||
} |
||||
} |
||||
|
||||
update(state, thisLayerIndex) { |
||||
// Apply diff from previous update first. The diff can't be applied last |
||||
// because it needs to be present during the draw phase. |
||||
this.applyDiff(); |
||||
|
||||
const allEls = this.getAll().sort(() => Math.random() - 0.5); |
||||
|
||||
if (allEls.length == 0) { |
||||
const newEl = this.newEl(this, []) |
||||
newEl.tick = state.tick; |
||||
this.set([w/2, h/2], newEl); |
||||
return; |
||||
} |
||||
|
||||
let newEls = 0; |
||||
for (const el of allEls) { |
||||
const nCoord = randEmptyNeighboringCoord(this, el.coord); |
||||
if (!nCoord) continue; // el has no empty neighboring spots |
||||
|
||||
const newEl = this.newEl(this, neighboringElsOf(this, nCoord)) |
||||
newEl.tick = state.tick; |
||||
this.set(nCoord, newEl); |
||||
|
||||
newEls++; |
||||
if (newEls >= this.knobs.maxNewElsPerTick) break; |
||||
} |
||||
|
||||
const calcLayerBonus = (el, layer, scalar, likenessScalar) => { |
||||
if (!layer) return 0; |
||||
const nEls = neighboringElsOf(layer, el.coord, true) |
||||
|
||||
const likeness = nEls.reduce((likeness, nEl) => { |
||||
const diff = Math.abs(nEl.c - el.c); |
||||
return likeness + Math.max(diff, Math.abs(1 - diff)); |
||||
}, 0); |
||||
|
||||
return (nEls.length * scalar) + (likeness * likenessScalar); |
||||
}; |
||||
|
||||
const [prevLayer, nextLayer] = state.neighboringLayers(thisLayerIndex); |
||||
|
||||
for (const el of allEls) { |
||||
const age = state.tick - el.tick; |
||||
const neighborBonus = neighboringElsOf(this, el.coord).length * this.knobs.neighborScalar; |
||||
const prevLayerBonus = calcLayerBonus(el, prevLayer, this.knobs.prevLayerScalar, this.knobs.prevLayerLikenessScalar); |
||||
const nextLayerBonus = calcLayerBonus(el, nextLayer, this.knobs.nextLayerScalar, this.knobs.nextLayerLikenessScalar); |
||||
const chaos = (this.chaos > 0) ? randn(this.knobs.chaos) : 0; |
||||
|
||||
if (age - neighborBonus - prevLayerBonus - nextLayerBonus + chaos >= this.knobs.ageOfDeath) { |
||||
this.unset(el.coord); |
||||
} |
||||
} |
||||
} |
||||
|
||||
draw(canvas) { |
||||
for (const coordStr in this.diff) { |
||||
const el = this.diff[coordStr]; |
||||
const coord = JSON.parse(coordStr); |
||||
|
||||
if (el.action == "set") { |
||||
canvas.ctx.fillStyle = `hsl(${el.h}, ${el.s}, ${el.l})`; |
||||
canvas.ctx.fillRect( |
||||
coord[0]*canvas.rectSize, coord[1]*canvas.rectSize, |
||||
canvas.rectSize, canvas.rectSize, |
||||
); |
||||
|
||||
} else { |
||||
canvas.ctx.clearRect( |
||||
coord[0]*canvas.rectSize, coord[1]*canvas.rectSize, |
||||
canvas.rectSize, canvas.rectSize, |
||||
); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
const neighbors = [ |
||||
[-1, -1], [0, -1], [1, -1], |
||||
[-1, 0], /* [0, 0], */ [1, 0], |
||||
[-1, 1], [0, 1], [1, 1], |
||||
]; |
||||
|
||||
function neighborsOf(coord) { |
||||
return neighbors.map((n) => { |
||||
let nX = coord[0]+n[0]; |
||||
let nY = coord[1]+n[1]; |
||||
nX = (nX + w) % w; |
||||
nY = (nY + h) % h; |
||||
return [nX, nY]; |
||||
}); |
||||
} |
||||
|
||||
function randEmptyNeighboringCoord(layer, coord) { |
||||
const neighbors = neighborsOf(coord).sort(() => Math.random() - 0.5); |
||||
for (const nCoord of neighbors) { |
||||
if (!layer.get(nCoord)) return nCoord; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
function neighboringElsOf(layer, coord, includeCoord = false) { |
||||
const neighboringEls = []; |
||||
|
||||
const neighboringCoords = neighborsOf(coord); |
||||
if (includeCoord) neighboringCoords.push(coord); |
||||
|
||||
for (const nCoord of neighboringCoords) { |
||||
const el = layer.get(nCoord); |
||||
if (el) neighboringEls.push(el); |
||||
} |
||||
return neighboringEls; |
||||
} |
||||
|
||||
function newEl(h, l) { |
||||
return { |
||||
h: h, |
||||
s: "100%", |
||||
l: l, |
||||
c: h / 360, // c is used to compare the element to others |
||||
}; |
||||
} |
||||
|
||||
function mkNewEl(l) { |
||||
return (layer, nEls) => { |
||||
const s = "100%"; |
||||
if (nEls.length == 0) { |
||||
const h = randn(360); |
||||
return newEl(h, l); |
||||
} |
||||
|
||||
// for each h (which can be considered as degrees around a circle) break the |
||||
// h down into x and y vectors, and add those up separately. Then find the |
||||
// angle between those two resulting vectors, and that's the "average" h |
||||
// value. |
||||
let x = 0; |
||||
let y = 0; |
||||
nEls.forEach((el) => { |
||||
const hRad = el.h * Math.PI / 180; |
||||
x += Math.cos(hRad); |
||||
y += Math.sin(hRad); |
||||
}); |
||||
|
||||
let h = Math.atan2(y, x); |
||||
h = h / Math.PI * 180; |
||||
|
||||
// apply some random drift, normalize |
||||
h += (Math.random() * layer.knobs.drift * 2) - layer.knobs.drift; |
||||
h = (h + 360) % 360; |
||||
|
||||
return newEl(h, l); |
||||
} |
||||
} |
||||
|
||||
class Universe { |
||||
constructor(canvasesByClass, layers) { |
||||
this.canvasesByClass = canvasesByClass; |
||||
this.state = new UniverseState(layers); |
||||
} |
||||
|
||||
update() { |
||||
this.state.tick++; |
||||
let prevLayer; |
||||
this.state.layers.forEach((layer, i) => { |
||||
layer.update(this.state, i); |
||||
prevLayer = layer; |
||||
}); |
||||
} |
||||
|
||||
draw() { |
||||
this.state.layers.forEach((layer) => { |
||||
if (!this.canvasesByClass[layer.className]) return; |
||||
this.canvasesByClass[layer.className].forEach((canvas) => { |
||||
layer.draw(canvas); |
||||
}); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
</script> |
||||
|
||||
<style> |
||||
|
||||
.canvasContainer { |
||||
display: grid; |
||||
margin-bottom: 2rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
canvas { |
||||
border: 1px dashed #AAA; |
||||
width: 100%; |
||||
grid-area: 1/1/2/2; |
||||
} |
||||
|
||||
</style> |
||||
|
||||
<div class="canvasContainer"> |
||||
<canvas class="layer1"></canvas> |
||||
<canvas class="layer2"></canvas> |
||||
</div> |
||||
|
||||
<div class="row"> |
||||
|
||||
<div class="columns six"> |
||||
<h3>Bottom Layer</h3> |
||||
<div class="canvasContainer"><canvas class="layer1"></canvas></div> |
||||
<div class="layer1 layerParams"> |
||||
<label>Max New Elements Per Tick</label><input type="text" param="maxNewElsPerTick" /> |
||||
<label>Color Drift</label><input type="text" param="drift" /> |
||||
<label>Age of Death</label><input type="text" param="ageOfDeath" /> |
||||
<label>Neighbor Scalar</label><input type="text" param="neighborScalar" /> |
||||
<label>Top Layer Neighbor Scalar</label><input type="text" param="nextLayerScalar" /> |
||||
<label>Top Layer Neighbor Likeness Scalar</label><input type="text" param="nextLayerLikenessScalar" /> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="columns six"> |
||||
<h3>Top Layer</h3> |
||||
<div class="canvasContainer"><canvas class="layer2"></canvas></div> |
||||
<div class="layer2 layerParams"> |
||||
<label>Max New Elements Per Tick</label><input type="text" param="maxNewElsPerTick" /> |
||||
<label>Color Drift</label><input type="text" param="drift" /> |
||||
<label>Age of Death</label><input type="text" param="ageOfDeath" /> |
||||
<label>Neighbor Scalar</label><input type="text" param="neighborScalar" /> |
||||
<label>Bottom Layer Neighbor Scalar</label><input type="text" param="prevLayerScalar" /> |
||||
<label>Bottom Layer Neighbor Likeness Scalar</label><input type="text" param="prevLayerLikenessScalar" /> |
||||
</div> |
||||
</div> |
||||
|
||||
</div> |
||||
|
||||
Once again, this visualization iterates upon the previous. In the last one the |
||||
top layer was able to "see" the bottom, and was therefore able to bolster or |
||||
penalize its own elements which were on or near bottom layer elements, but not |
||||
vice-versa. This time both layers can see each other, and the "Layer Neighbor |
||||
Scalar" can be used to adjust lifetime of elements which are on/near elements of |
||||
the neighboring layer. |
||||
|
||||
By default, the bottom layer has a high affinity to the top, and the top layer |
||||
has a some (but not as much) affinity in return. |
||||
|
||||
Another addition is the "likeness" scalar. Likeness is defined as the degree to |
||||
which one element is like another. In this visualization likeness is determined |
||||
by color. The "Layer Neighbor Likeness Scalar" adjusts the lifetime of elements |
||||
based on how like they are to nearby elements on the neighboring layer. |
||||
|
||||
By default, the top layer has a high affinity for the bottom's color, but the |
||||
bottom doesn't care about the top's color at all (and so its color will drift |
||||
aimlessly). |
||||
|
||||
And finally "Color Drift" can be used to adjust the degree to which the color of |
||||
new elements can diverge from its parents. This has always been hardcoded, but |
||||
can now be adjusted separately across the different layers. |
||||
|
||||
In the default configuration the top layer will (eventually) converge to roughly |
||||
match the bottom both in shape and color. When I first implemented the likeness |
||||
scaling I thought it was broken, because the top would never converge to the |
||||
bottom's color. |
||||
|
||||
What I eventually realized was that the top must have a higher color drift than |
||||
the bottom in order for it to do so, otherwise the top would always be playing |
||||
catchup. However, if the drift difference is _too_ high then the top layer |
||||
becomes chaos and also doesn't really follow the color of the bottom. A |
||||
difference of 10 (degrees out of 360) is seemingly enough. |
||||
|
||||
<script> |
||||
|
||||
const canvasesByClass = {}; |
||||
[...document.getElementsByTagName("canvas")].forEach((canvasDOM) => { |
||||
|
||||
const canvas = new Canvas(canvasDOM); |
||||
canvasDOM.classList.forEach((name) => { |
||||
if (!canvasesByClass[name]) canvasesByClass[name] = []; |
||||
canvasesByClass[name].push(canvas); |
||||
}) |
||||
}); |
||||
|
||||
const layers = [ |
||||
|
||||
new Layer("layer1", mkNewEl("90%"), { |
||||
maxNewElsPerTick: 2, |
||||
ageOfDeath: 30, |
||||
drift: 40, |
||||
neighborScalar: 50, |
||||
nextLayerScalar: 20, |
||||
}), |
||||
|
||||
new Layer("layer2", mkNewEl("50%", ), { |
||||
maxNewElsPerTick: 15, |
||||
ageOfDeath: 1, |
||||
drift: 50, |
||||
neighborScalar: 5, |
||||
prevLayerScalar: 5, |
||||
prevLayerLikenessScalar: 20, |
||||
}), |
||||
|
||||
]; |
||||
|
||||
for (const layer of layers) { |
||||
document.querySelectorAll(`.${layer.className}.layerParams > input`).forEach((input) => { |
||||
const param = input.getAttribute("param"); |
||||
|
||||
// pre-fill input values |
||||
input.value = layer.knobs[param]; |
||||
|
||||
input.onchange = () => { |
||||
console.log(`setting ${layer.className}.${param} to ${input.value}`); |
||||
layer.knobs[param] = input.value; |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
const universe = new Universe(canvasesByClass, layers); |
||||
|
||||
const requestAnimationFrame = |
||||
window.requestAnimationFrame || |
||||
window.mozRequestAnimationFrame || |
||||
window.webkitRequestAnimationFrame || |
||||
window.msRequestAnimationFrame; |
||||
|
||||
function doTick() { |
||||
universe.update(); |
||||
universe.draw(); |
||||
requestAnimationFrame(doTick); |
||||
} |
||||
|
||||
doTick(); |
||||
|
||||
</script> |
Loading…
Reference in new issue