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
59196No build step is required. Open ` index.html ` directly in any modern browser:
@@ -72,14 +209,17 @@ npx serve symmetry_simple
72209## Usage Tips
73210
742111 . 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.
772143 . Use ** Random** followed by ** Play** at high speed to generate organic, tie-dye textures.
782154 . Lower ** Renorm** toward 0 to let the image slowly converge to a uniform colour — useful for blending transitions.
792165 . 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