Skip to content

Commit b1b5c53

Browse files
committed
add spectral diffusion mode and controls to the canvas
1 parent 1ba2d2c commit b1b5c53

3 files changed

Lines changed: 650 additions & 25 deletions

File tree

experiments/symmetry_simple/README.md

Lines changed: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Symmetry Diffusion
22

3-
An interactive pixel-art canvas where you can draw with symmetry and watch colors diffuse across the grid in real time.
3+
An interactive pixel-art canvas where you can draw with symmetry and watch colors diffuse across the grid in real
4+
time. The canvas is a small RGB grid (32–256 pixels wide) treated as a discrete field; symmetry operations and a
5+
diffusion operator act on this field every frame.
46

57
## Features
68

@@ -54,6 +56,141 @@ spreads along the symmetry axes as well as spatially.
5456
- **Save PNG** – download the current canvas as a PNG file
5557
- **Show Grid** – overlay a faint white grid (visible when display scale ≥ 4×)
5658

59+
## Theory
60+
61+
### The Field
62+
63+
The canvas is an RGB scalar field $u : \Omega \to [0,1]^3$ on a discrete grid $\Omega = \{0,\dots,W-1\} \times
64+
\{0,\dots,H-1\}$. Every operation — drawing, symmetrising, diffusing — is a map $u \mapsto u'$ on this field.
65+
66+
### Symmetry as a Group Action
67+
68+
Each enabled symmetry option contributes a generator to a group $G$ acting on $\Omega$:
69+
70+
- **Mirror X / Mirror Y** generate the Klein four-group $\mathbb{Z}_2 \times \mathbb{Z}_2$ via the involutions
71+
$(x,y) \mapsto (W{-}1{-}x, y)$ and $(x,y) \mapsto (x, H{-}1{-}y)$.
72+
- **Rotation 90° / 60° / 30°** generate cyclic groups $C_4$, $C_6$, $C_{12}$ acting around the centre
73+
$\mathbf{c} = ((W{-}1)/2, (H{-}1)/2)$ via rotation matrices $R_\theta$.
74+
- **Diagonal Mirror** is the involution $(x,y) \mapsto (y,x)$.
75+
- **Translation X / Y** convert the grid into a torus $\mathbb{Z}_W \times \mathbb{Z}_H$ and add half-period
76+
translations $(x,y) \mapsto (x + W/2, y)$ etc.
77+
78+
For a position $p \in \Omega$ the **orbit** $G \cdot p = \{g \cdot p : g \in G\}$ is the set of all positions that
79+
must share the same colour for the image to respect the chosen symmetry. When you paint at $p$, the app paints the
80+
entire orbit; this is implemented by `symmetryPositions(x, y)`.
81+
82+
Because rotations by non-axis-aligned angles produce non-integer pixel coordinates, orbits are computed on $\mathbb
83+
R^2$ and then snapped to the grid. This breaks the group law slightly (orbits of orbits are not always closed) but
84+
is visually indistinguishable for the moderate grid sizes used here.
85+
86+
### Diffusion
87+
88+
The simulation step is a discrete heat-equation update. For each pixel $p$ with neighbour set $N(p)$ and edge
89+
weights $w_{pq}$, the update is
90+
91+
$$u'(p) = u(p) + \alpha \sum_{q \in N(p)} \frac{w_{pq}}{\sum_{r} w_{pr}} \big(u(q) - u(p)\big),$$
92+
93+
where $\alpha$ is the **rate**. This is a normalised graph-Laplacian smoothing $u' = u - \alpha L u$ with the
94+
convention that $L$'s rows sum to zero. As $\alpha \to 0$ the dynamics approach the continuous heat equation
95+
$\partial_t u = \Delta u$ on the graph.
96+
97+
Spatial neighbours are weighted by inverse Euclidean distance ($1$ for axial, $1/\sqrt 2$ for diagonal, $1/2$ for
98+
distance-2). Symmetry-peer neighbours are added with weight $1$, and when a peer falls between pixels (rotated
99+
rotations), its contribution is **bilinearly distributed** over the four surrounding pixels. This keeps diffusion
100+
smooth even when the symmetry orbit doesn't land on integer coordinates.
101+
102+
Combining the symmetry-peer edges into the graph means the heat equation is solved on a quotient-like manifold:
103+
points that are far apart in pixel space but close under symmetry exchange colour directly, producing the
104+
characteristic kaleidoscope flow.
105+
106+
### Moment-Preserving Renormalisation
107+
108+
Plain diffusion is energy-dissipating: each step reduces the variance of $u$, so over time the image converges to a
109+
constant grey ($u \equiv \bar u$). To counteract this, after each diffusion step we compute per-channel means
110+
$\mu_c, \mu'_c$ and standard deviations $\sigma_c, \sigma'_c$ before and after the step, and rescale:
111+
112+
$$\tilde u_c(p) = \mu_c + \big(u'_c(p) - \mu'_c\big) \cdot \frac{\sigma_c}{\sigma'_c}.$$
113+
114+
The final value is a convex combination $u^{\text{out}} = (1{-}\rho)\,u' + \rho\,\tilde u$, where $\rho$ is the
115+
**Renorm** slider. With $\rho = 1$ the first two moments of every channel are exact invariants of the dynamics, so
116+
colours redistribute and swirl indefinitely without fading. With $\rho = 0$ the system is purely dissipative and
117+
converges to grey.
118+
119+
This is the same trick used in instance/feature normalisation: standardise, then re-inject target moments. Because
120+
only mean and variance are preserved, higher-order structure (edges, contrast distribution) still diffuses, which is
121+
exactly what produces the slow, organic mixing you see.
122+
123+
## Implementation Notes
124+
125+
### State
126+
127+
`pixels` is a single `Float32Array` of length $3WH$ in row-major RGB layout. The helpers `idx(x,y)`, `getPixel`,
128+
`setPixel` translate between $(x,y,c)$ and the flat index. Floats in $[0,1]$ are used internally; the conversion to
129+
8-bit happens once per render in `render()` via a reused `ImageData` buffer.
130+
131+
### Rendering
132+
133+
The canvas backing store is exactly $W \times H$ pixels; CSS scales it up by an integer factor `displayScale`
134+
computed in `resizeCanvas()`. This gives crisp, nearest-neighbour pixel art for free (the browser's default scaling
135+
on a small canvas blown up via `style.width` is nearest on most platforms; if you need to guarantee it, add
136+
`image-rendering: pixelated` in CSS).
137+
138+
### Symmetry Application
139+
140+
`symmetryPositions(x, y)` builds the orbit of $(x,y)$ for the currently enabled symmetries:
141+
142+
1. Start with the seed point $(x,y)$.
143+
2. Append axis-aligned reflections (mirror X, mirror Y, their composition, 180° rotation) — these are exact integer
144+
maps.
145+
3. Append rotational images for the active rotation group, computed in floating point and rounded.
146+
4. Append the diagonal reflection $(y,x)$ (and its composition with both mirrors) if enabled.
147+
5. For every seed thus generated, add half-period translations if Translation X / Y is on, then wrap or clip
148+
depending on whether wrapping is enabled.
149+
150+
The result is deduplicated via a `Set` keyed by `"x,y"` and returned as integer pairs. Drawing operations iterate
151+
over this list.
152+
153+
### Neighbour Graph
154+
155+
`getNeighbors(x, y)` returns the weighted edge list for pixel $(x,y)$:
156+
157+
- Spatial offsets from the chosen connectivity (4 / 8 / 12), wrapped or clipped per translation flags, weighted by
158+
inverse Euclidean distance.
159+
- Symmetry peers from the same generators as `symmetryPositions`, but kept as floating-point coordinates and routed
160+
through `addBilinearNeighbors` so each fractional peer contributes to the four surrounding integer pixels with
161+
bilinear weights.
162+
163+
The diffusion loop normalises by the sum of weights per pixel, so absolute weight magnitudes don't matter — only
164+
their ratios do.
165+
166+
### Diffusion Step
167+
168+
`diffuseStep()` is a single explicit Euler step:
169+
170+
1. Compute per-channel mean $\mu$ and std $\sigma$ of `pixels`.
171+
2. Allocate `next` and fill it from the Laplacian update.
172+
3. Compute new moments $\mu', \sigma'$ of `next`.
173+
4. Blend each pixel with its moment-rescaled version using the `renorm` weight.
174+
5. Clamp to $[0,1]$ and swap `pixels = next`.
175+
176+
Stability requires roughly $\alpha \cdot \deg_{\max} < 1$. The default $\alpha = 0.30$ with 4–12 neighbours is well
177+
inside this bound.
178+
179+
### Drawing
180+
181+
`paintAt(x, y)` fills a disc of radius `brushSize` centred at $(x,y)$, wrapping coordinates around the grid (so
182+
drawing near an edge with translation symmetry is seamless). For symmetric drawing, the caller iterates over
183+
`symmetryPositions` and invokes `paintAt` at every orbit point.
184+
185+
`floodFill` is a stack-based 4-connected flood with a small RGB tolerance for matching, so anti-aliased boundaries
186+
fill cleanly.
187+
188+
### Animation
189+
190+
`animLoop(ts)` is a `requestAnimationFrame` driver that throttles itself to the requested fps by checking
191+
`ts - lastFrameTime`. This keeps simulation rate decoupled from monitor refresh and avoids busy-spinning on fast
192+
displays.
193+
57194
## Getting Started
58195

59196
No build step is required. Open `index.html` directly in any modern browser:
@@ -72,14 +209,17 @@ npx serve symmetry_simple
72209
## Usage Tips
73210

74211
1. Enable **Mirror X + Mirror Y + Rotation 90°** before drawing to get instant kaleidoscope patterns.
75-
2. Draw a few coloured blobs, then hit **Play** with **Rate ≈ 0.30** and **Renorm = 1.00** to watch the colours swirl
76-
without fading.
212+
2. Draw a few coloured blobs, then hit **Play** with **Rate ≈ 0.30** and **Renorm = 1.00** to watch the colours
213+
swirl without fading.
77214
3. Use **Random** followed by **Play** at high speed to generate organic, tie-dye textures.
78215
4. Lower **Renorm** toward 0 to let the image slowly converge to a uniform colour — useful for blending transitions.
79216
5. Combine **Translation X + Translation Y** with any rotational symmetry to tile the pattern seamlessly across the
80217
canvas.
81-
6. Increase the grid to **256** and the aspect ratio to **2.00** for a wide, high-resolution canvas; decrease to **32**
82-
for a chunky pixel-art look.
218+
6. Increase the grid to **256** and the aspect ratio to **2.00** for a wide, high-resolution canvas; decrease to
219+
**32** for a chunky pixel-art look.
220+
7. Try **Rotation 60°** with **Translation X + Y** off and a single off-centre dot to grow a snowflake; switch on
221+
**12-radius** neighbourhood for fluffier branches.
222+
8. For meditative loops: **Random**, **Rate = 0.05**, **Renorm = 1.00**, **Rotation 90°**, fps ≈ 30.
83223

84224
## File Structure
85225

@@ -89,4 +229,16 @@ symmetry_simple/
89229
├── style.css # layout and dark-theme styles
90230
├── app.js # all application logic (single self-contained IIFE)
91231
└── README.md # this file
92-
```
232+
```
233+
234+
## Possible Extensions
235+
236+
- **Implicit / semi-implicit time stepping** for larger stable rates (solve $(I + \alpha L) u' = u$ with a few
237+
Jacobi sweeps).
238+
- **Anisotropic diffusion** — modulate edge weights by local gradient magnitude to preserve edges (Perona–Malik).
239+
- **Reaction–diffusion** — add a per-pixel non-linear term ($u^3 - u$, Gray–Scott, etc.) to grow patterns instead of
240+
just smoothing.
241+
- **Higher-order moment matching** — preserve skew/kurtosis or full per-channel histograms via histogram
242+
specification.
243+
- **GPU implementation** — port `diffuseStep` to a fragment shader; the operation is embarrassingly parallel and
244+
would scale to 1024² grids easily.

0 commit comments

Comments
 (0)