nandi/bresenham-circlepublic Fork 0
686a373
Commits
Clone
git clone https://git.rickub.com/nandi/bresenham-circle.git
git clone ssh://git@rickub.com/nandi/bresenham-circle.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Replace the random sprinkle with a golden-angle sequence

The right pane was labelled a Poisson sprinkle and was neither. It drew a
fixed number of samples rather than a Poisson-distributed count, which makes
it a binomial point process, and the causal-set analogy in the README leaned
on exactly the independence that fixed-count sampling does not have.

Golden angle is the better demonstration anyway. theta_k = k * 2pi/phi^2 is
wholly deterministic, yet still has a trivial automorphism group, which makes
the actual point visible: what destroys the 8-fold symmetry is
incommensurability with the lattice, not unpredictability. Being low
discrepancy it also reaches Bresenham's lattice-limited angular gap at 2x
oversample, where uniform random has not got there by 6x.

Adds a worst-angular-gap readout under each pane, since that is the metric
that separates them. README now reports measured numbers and is explicit that
golden angle is not spectrally special: both samplers inherit a 4-fold
component from snapping to a square lattice, and what is distinctive about
Bresenham is its exact zeros at every other harmonic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T10:16:34-07:00 Browse files
686a373 parent: d1c5bb8
modified README.md +61 -27
@@ -1,7 +1,8 @@
11 # bresenham-circle
22
33 Bresenham's midpoint circle algorithm, stepped one decision at a time, next to a
4-Poisson sprinkle that draws the same circle with **zero** exact symmetries.
4+golden-angle sampler that draws the same circle with **zero** lattice
5+symmetries — and no randomness at all.
56
67 Written in [Nim](https://nim-lang.org) with [naylib](https://github.com/planetis-m/naylib).
78
@@ -25,7 +26,7 @@ Flags: `--r=N` start at a radius, `--shot` render to `shot.png` and exit,
2526 | `A` | auto-step |
2627 | `R` | reset |
2728 | `UP` / `DOWN` | radius (hold to scrub, accelerating) |
28-| `[` `]` | sprinkle oversample factor |
29+| `[` `]` | golden-angle oversample factor |
2930 | `ESC` | quit |
3031
3132 The window is resizable, and the maximum radius is whatever currently fits —
@@ -69,44 +70,77 @@ Cost is **O(r)**: the loop runs `r/√2 ≈ 0.707r` times, so the full circle is
6970 `2πr`, because roughly half the steps are diagonal and cover √2 of arc for one
7071 pixel.)
7172
72-## Right pane: Poisson sprinkle
73+## Right pane: golden angle
7374
7475 ```nim
75-for _ in 1 .. n:
76- let t = rng.rand(2.0 * PI)
76+const golden = 2.0 * PI / ((1.0 + sqrt(5.0)) / 2.0) ^ 2 # ~137.507 deg
77+for k in 0 ..< n:
78+ let t = float(k) * golden
7779 samples.add (int(round(r * cos(t))), int(round(r * sin(t))))
7880 ```
7981
80-No octants, no mirroring, no decision variable. Its output has a **trivial
81-automorphism group** — no rotation or reflection maps the pixel set to itself —
82-yet it is isotropic *in distribution*, because uniform sampling on the circle is
83-rotation-invariant. This is the causal-set trick: give up exact symmetry, keep
84-symmetry of the measure.
82+No octants, no mirroring, no decision variable — and **no randomness**. It is as
83+deterministic as Bresenham. Yet its output has a *trivial automorphism group*:
84+no rotation or reflection of the lattice maps the pixel set to itself.
85+
86+That is the point of using the golden angle rather than a random sprinkle. What
87+destroys the 8-fold symmetry is not unpredictability, it is **incommensurability
88+with the grid**. 2pi/phi^2 is the "most irrational" rotation available, so the
89+sequence never falls into step with the axes.
8590
8691 Both panes print a live symmetry count, computed by testing all 8 lattice
87-operations against the drawn cells. Bresenham reports 8/8, the sprinkle 1/8.
92+operations against the drawn cells. Bresenham reports 8/8, golden angle 1/8.
93+
94+### Coverage
95+
96+Low discrepancy also means it never clumps, which independent random sampling
97+does. Worst angular hole in the ring at r = 2000, against Bresenham's
98+lattice-limited 0.041 deg:
99+
100+| oversample | golden angle | uniform random |
101+|---|---|---|
102+| 1x | 0.064 deg | 0.382 deg |
103+| 2x | **0.041 deg** | 0.165 deg |
104+| 4x | **0.041 deg** | 0.103 deg |
105+| 6x | **0.041 deg** | 0.064 deg |
106+
107+Golden angle reaches the lattice limit at 2x oversample. Uniform random has not
108+got there by 6x. Press `[` to drop toward 1x and watch holes open in the ring.
88109
89-### The measurable difference
110+### The spectral signature
90111
91112 Take the radial error as a function of angle and transform it. Bresenham's
92113 spectrum has power **only** at harmonics that are multiples of 4 — the rotation
93-subgroup C₄ forces 90° periodicity — and is exactly zero elsewhere. Forbidden
94-harmonics, like a crystal's forbidden diffraction peaks. At r = 2000:
114+subgroup C4 forces 90-degree periodicity — and is *exactly zero* everywhere
115+else. Forbidden harmonics, like a crystal's forbidden diffraction peaks. At
116+r = 2000, mean power per harmonic over k = 1..60:
95117
96-| | power at k = 4, 8, 12, … | everywhere else |
118+| | multiples of 4 | every other harmonic |
97119 |---|---|---|
98-| Bresenham | 0.00354 | **0.000000** |
99-| sprinkle | 0.00250 | 0.00085 |
100-
101-The sprinkle is broadband: no structure, no preferred directions.
102-
103-That trade is why production renderers use stochastic and blue-noise sampling —
104-structured aliasing (moiré, banding, visible staircases) is far more
105-objectionable than unstructured noise of the same magnitude. The price is
106-visible in the panel: several samples rolled per cell landed, more cells for the
107-same circle, no determinism, and trig plus an RNG where Bresenham used integer
108-adds. Drop the oversample to 1× with `[` and holes open in the ring — the
109-coupon-collector problem, on screen.
120+| Bresenham | 0.00420 | **0.000000** |
121+| golden angle | 0.00239 | 0.00064 |
122+| uniform random | 0.00248 | 0.00109 |
123+
124+Worth being precise about what this does and does not show. Golden angle still
125+carries a 4-fold component, and so does uniform random — that part is inherited
126+from snapping to a square lattice at all, not from the sampling rule. What
127+separates Bresenham is the *exact zeros*: it has harmonics that are structurally
128+forbidden, and the other two have no forbidden harmonics at all.
129+
130+Golden angle is also not spectrally special here. Its advantage over random is
131+coverage and determinism, not a flatter spectrum.
132+
133+### What it costs
134+
135+Visible in the panel: several angles taken per cell landed, ~25% more cells for
136+the same circle (rounding independent directions sometimes picks a cell further
137+from the curve than the midpoint test would), and trig per sample where
138+Bresenham used integer adds. For drawing one circle, Bresenham wins outright.
139+
140+The trade only pays when structured error is worse than unstructured error of
141+the same size — which is exactly why production renderers reach for blue-noise
142+and low-discrepancy sampling, and why causal set theory in physics gives up a
143+regular lattice to keep Lorentz invariance.
110144
111145 ## License
112146
@@ -1,7 +1,8 @@
1 # bresenham-circle1 # bresenham-circle
2 2
3 Bresenham's midpoint circle algorithm, stepped one decision at a time, next to a3 Bresenham's midpoint circle algorithm, stepped one decision at a time, next to a
4-Poisson sprinkle that draws the same circle with **zero** exact symmetries.4+golden-angle sampler that draws the same circle with **zero** lattice
5+symmetries — and no randomness at all.
5 6
6 Written in [Nim](https://nim-lang.org) with [naylib](https://github.com/planetis-m/naylib).7 Written in [Nim](https://nim-lang.org) with [naylib](https://github.com/planetis-m/naylib).
7 8
@@ -25,7 +26,7 @@ Flags: `--r=N` start at a radius, `--shot` render to `shot.png` and exit,
25 | `A` | auto-step |26 | `A` | auto-step |
26 | `R` | reset |27 | `R` | reset |
27 | `UP` / `DOWN` | radius (hold to scrub, accelerating) |28 | `UP` / `DOWN` | radius (hold to scrub, accelerating) |
28-| `[` `]` | sprinkle oversample factor |29+| `[` `]` | golden-angle oversample factor |
29 | `ESC` | quit |30 | `ESC` | quit |
30 31
31 The window is resizable, and the maximum radius is whatever currently fits —32 The window is resizable, and the maximum radius is whatever currently fits —
@@ -69,44 +70,77 @@ Cost is **O(r)**: the loop runs `r/√2 ≈ 0.707r` times, so the full circle is
69 `2πr`, because roughly half the steps are diagonal and cover √2 of arc for one70 `2πr`, because roughly half the steps are diagonal and cover √2 of arc for one
70 pixel.)71 pixel.)
71 72
72-## Right pane: Poisson sprinkle73+## Right pane: golden angle
73 74
74 ```nim75 ```nim
75-for _ in 1 .. n:76+const golden = 2.0 * PI / ((1.0 + sqrt(5.0)) / 2.0) ^ 2 # ~137.507 deg
76- let t = rng.rand(2.0 * PI)77+for k in 0 ..< n:
78+ let t = float(k) * golden
77 samples.add (int(round(r * cos(t))), int(round(r * sin(t))))79 samples.add (int(round(r * cos(t))), int(round(r * sin(t))))
78 ```80 ```
79 81
80-No octants, no mirroring, no decision variable. Its output has a **trivial82+No octants, no mirroring, no decision variable — and **no randomness**. It is as
81-automorphism group** — no rotation or reflection maps the pixel set to itself —83+deterministic as Bresenham. Yet its output has a *trivial automorphism group*:
82-yet it is isotropic *in distribution*, because uniform sampling on the circle is84+no rotation or reflection of the lattice maps the pixel set to itself.
83-rotation-invariant. This is the causal-set trick: give up exact symmetry, keep85+
84-symmetry of the measure.86+That is the point of using the golden angle rather than a random sprinkle. What
87+destroys the 8-fold symmetry is not unpredictability, it is **incommensurability
88+with the grid**. 2pi/phi^2 is the "most irrational" rotation available, so the
89+sequence never falls into step with the axes.
85 90
86 Both panes print a live symmetry count, computed by testing all 8 lattice91 Both panes print a live symmetry count, computed by testing all 8 lattice
87-operations against the drawn cells. Bresenham reports 8/8, the sprinkle 1/8.92+operations against the drawn cells. Bresenham reports 8/8, golden angle 1/8.
93+
94+### Coverage
95+
96+Low discrepancy also means it never clumps, which independent random sampling
97+does. Worst angular hole in the ring at r = 2000, against Bresenham's
98+lattice-limited 0.041 deg:
99+
100+| oversample | golden angle | uniform random |
101+|---|---|---|
102+| 1x | 0.064 deg | 0.382 deg |
103+| 2x | **0.041 deg** | 0.165 deg |
104+| 4x | **0.041 deg** | 0.103 deg |
105+| 6x | **0.041 deg** | 0.064 deg |
106+
107+Golden angle reaches the lattice limit at 2x oversample. Uniform random has not
108+got there by 6x. Press `[` to drop toward 1x and watch holes open in the ring.
88 109
89-### The measurable difference110+### The spectral signature
90 111
91 Take the radial error as a function of angle and transform it. Bresenham's112 Take the radial error as a function of angle and transform it. Bresenham's
92 spectrum has power **only** at harmonics that are multiples of 4 — the rotation113 spectrum has power **only** at harmonics that are multiples of 4 — the rotation
93-subgroup C₄ forces 90° periodicity — and is exactly zero elsewhere. Forbidden114+subgroup C4 forces 90-degree periodicity — and is *exactly zero* everywhere
94-harmonics, like a crystal's forbidden diffraction peaks. At r = 2000:115+else. Forbidden harmonics, like a crystal's forbidden diffraction peaks. At
116+r = 2000, mean power per harmonic over k = 1..60:
95 117
96-| | power at k = 4, 8, 12, … | everywhere else |118+| | multiples of 4 | every other harmonic |
97 |---|---|---|119 |---|---|---|
98-| Bresenham | 0.00354 | **0.000000** |120+| Bresenham | 0.00420 | **0.000000** |
99-| sprinkle | 0.00250 | 0.00085 |121+| golden angle | 0.00239 | 0.00064 |
100-122+| uniform random | 0.00248 | 0.00109 |
101-The sprinkle is broadband: no structure, no preferred directions.123+
102-124+Worth being precise about what this does and does not show. Golden angle still
103-That trade is why production renderers use stochastic and blue-noise sampling —125+carries a 4-fold component, and so does uniform random — that part is inherited
104-structured aliasing (moiré, banding, visible staircases) is far more126+from snapping to a square lattice at all, not from the sampling rule. What
105-objectionable than unstructured noise of the same magnitude. The price is127+separates Bresenham is the *exact zeros*: it has harmonics that are structurally
106-visible in the panel: several samples rolled per cell landed, more cells for the128+forbidden, and the other two have no forbidden harmonics at all.
107-same circle, no determinism, and trig plus an RNG where Bresenham used integer129+
108-adds. Drop the oversample to 1× with `[` and holes open in the ring — the130+Golden angle is also not spectrally special here. Its advantage over random is
109-coupon-collector problem, on screen.131+coverage and determinism, not a flatter spectrum.
132+
133+### What it costs
134+
135+Visible in the panel: several angles taken per cell landed, ~25% more cells for
136+the same circle (rounding independent directions sometimes picks a cell further
137+from the curve than the midpoint test would), and trig per sample where
138+Bresenham used integer adds. For drawing one circle, Bresenham wins outright.
139+
140+The trade only pays when structured error is worse than unstructured error of
141+the same size — which is exactly why production renderers reach for blue-noise
142+and low-discrepancy sampling, and why causal set theory in physics gives up a
143+regular lattice to keep Lorentz invariance.
110 144
111 ## License145 ## License
112 146
modified bresenham.nimble +1 -1
@@ -1,6 +1,6 @@
11 version = "0.1.0"
22 author = "nandi"
3-description = "Midpoint circle algorithm stepped one decision at a time, beside a zero-symmetry Poisson sprinkle"
3+description = "Midpoint circle algorithm stepped one decision at a time, beside a deterministic golden-angle sampler with no lattice symmetry"
44 license = "MIT"
55 srcDir = "src"
66 bin = @["bresenham"]
@@ -1,6 +1,6 @@
1 version = "0.1.0"1 version = "0.1.0"
2 author = "nandi"2 author = "nandi"
3-description = "Midpoint circle algorithm stepped one decision at a time, beside a zero-symmetry Poisson sprinkle"3+description = "Midpoint circle algorithm stepped one decision at a time, beside a deterministic golden-angle sampler with no lattice symmetry"
4 license = "MIT"4 license = "MIT"
5 srcDir = "src"5 srcDir = "src"
6 bin = @["bresenham"]6 bin = @["bresenham"]
modified docs/screenshot.png +0 -0
Binary files a/docs/screenshot.png and b/docs/screenshot.png differ
Binary files a/docs/screenshot.png and b/docs/screenshot.png differBinary files a/docs/screenshot.png and b/docs/screenshot.png differ
modified src/bresenham.nim +44 -27
@@ -1,14 +1,16 @@
11 ## Two ways to put a circle on a grid, side by side.
22 ##
33 ## left -- Bresenham: integer decisions, one octant, mirrored 8 ways.
4-## right -- Poisson sprinkle: uniform directions snapped to cells.
4+## right -- Golden angle: k * 137.5 deg, snapped to cells.
55 ##
6-## Both land on the same lattice. The difference is the symmetry of the
7-## *selection*: Bresenham's is exactly 8-fold, the sprinkle's is trivial.
6+## Both land on the same lattice, and both are fully deterministic. The
7+## difference is the symmetry of the *selection*: Bresenham's is exactly
8+## 8-fold, the golden-angle sequence's is trivial. Randomness was never what
9+## bought that -- incommensurability with the grid is.
810 ##
911 ## Controls: SPACE step A auto R reset UP/DOWN radius [ ] oversample ESC
1012
11-import raylib, std/[strformat, os, math, strutils, random, sets, hashes]
13+import raylib, std/[strformat, os, math, strutils, algorithm, sets, hashes]
1214
1315 const
1416 InitW = 1400
@@ -23,7 +25,7 @@ const
2325 TrueArc = Color(r: 100, g: 100, b: 128, a: 255)
2426 Octant = Color(r: 88, g: 166, b: 255, a: 255)
2527 Mirror = Color(r: 88, g: 166, b: 255, a: 105)
26- Sprink = Color(r: 118, g: 222, b: 160, a: 210)
28+ Golden = Color(r: 118, g: 222, b: 160, a: 210)
2729 Cursor = Color(r: 255, g: 176, b: 64, a: 255)
2830 Candidate = Color(r: 255, g: 176, b: 64, a: 60)
2931 Mid = Color(r: 255, g: 92, b: 92, a: 255)
@@ -45,9 +47,9 @@ type
4547 branch: Branch
4648 lastUpdate: string
4749 done: bool
48- # -- Poisson sprinkle
50+ # -- Golden angle
4951 over: int ## samples per Bresenham pixel
50- samples: seq[(int, int)] ## pre-rolled, revealed progressively
52+ samples: seq[(int, int)] ## precomputed, revealed progressively
5153 revealed: int
5254 scells: Cells
5355 perStep: int
@@ -71,13 +73,15 @@ proc reset(s: var State, r: int, over = -1) =
7173 s.lastUpdate = ""
7274 s.done = false
7375
74- # Sprinkle: uniform in angle, so isotropic in distribution but with no
75- # exact symmetry at all. Fixed seed per radius so runs are reproducible.
76- var rng = initRand(0xC0FFEE + r * 7919)
76+ # Golden angle: theta_k = k * 2*pi/phi^2, about 137.507 degrees. Wholly
77+ # deterministic, yet incommensurable with the lattice -- so no rotation or
78+ # reflection maps the result to itself. Low discrepancy, so it also never
79+ # clumps the way independent sampling does.
80+ const golden = 2.0 * PI / ((1.0 + sqrt(5.0)) / 2.0) ^ 2
7781 let n = int(s.over.float * 4.0 * sqrt(2.0) * r.float)
7882 s.samples = newSeqOfCap[(int, int)](n)
79- for _ in 1 .. n:
80- let t = rng.rand(2.0 * PI)
83+ for k in 0 ..< n:
84+ let t = float(k) * golden
8185 s.samples.add (int(round(r.float * cos(t))), int(round(r.float * sin(t))))
8286 s.revealed = 0
8387 s.scells = initHashSet[(int, int)]()
@@ -90,8 +94,8 @@ proc reveal(s: var State, k: int) =
9094 inc s.revealed
9195
9296 proc step(s: var State) =
93- ## Exactly one iteration of the Bresenham loop body, plus the sprinkle's
94- ## proportional share of samples so the two fill at a comparable rate.
97+ ## Exactly one iteration of the Bresenham loop body, plus the golden-angle
98+ ## sequence's proportional share so the two fill at a comparable rate.
9599 if s.done: return
96100 if s.x > s.y:
97101 s.done = true
@@ -120,9 +124,20 @@ proc step(s: var State) =
120124 s.done = true
121125 s.reveal(s.samples.len)
122126
127+proc largestGap(c: Cells): float =
128+ ## Worst angular hole in the ring, in degrees. Bresenham is lattice-limited;
129+ ## anything that samples directions has to buy its way down to that.
130+ if c.len < 2: return 360.0
131+ var a = newSeqOfCap[float](c.len)
132+ for (x, y) in c: a.add arctan2(y.float, x.float).floorMod(2.0 * PI)
133+ a.sort()
134+ for i in 0 ..< a.high: result = max(result, a[i + 1] - a[i])
135+ result = max(result, a[0] + 2.0 * PI - a[^1])
136+ result = radToDeg(result)
137+
123138 proc symmetries(c: Cells): int =
124139 ## How many of the lattice's 8 symmetries actually map this pixel set to
125- ## itself. Bresenham: 8. A sprinkle: 1 (the identity), essentially always.
140+ ## itself. Bresenham: 8. The golden-angle set: 1, the identity alone.
126141 if c.len == 0: return 0
127142 for k in 0 ..< 8:
128143 var ok = true
@@ -175,7 +190,7 @@ proc backdrop(v: View, r: int) =
175190
176191 proc main =
177192 setConfigFlags(flags(WindowResizable))
178- initWindow(InitW, InitH, "Bresenham vs Poisson sprinkle")
193+ initWindow(InitW, InitH, "Bresenham vs golden angle")
179194 defer: closeWindow()
180195 setWindowMinSize(900, 640)
181196 setTargetFPS(60)
@@ -258,10 +273,10 @@ proc main =
258273 drawCircle(int32(left.cx(s.x + 1) + left.half), int32(left.cy(s.y) + cell),
259274 float32(max(3, cell div 5)), Mid)
260275
261- # ================= right: Poisson sprinkle =================
276+ # ================= right: golden angle =================
262277 backdrop(right, s.r)
263278 for (mx, my) in s.scells:
264- drawRect(right.cx(mx), right.cy(my), cell, cell, Sprink)
279+ drawRect(right.cx(mx), right.cy(my), cell, cell, Golden)
265280
266281 # ---- titles and per-pane stats
267282 let
@@ -271,14 +286,16 @@ proc main =
271286 rx = halfW + 24
272287 text("BRESENHAM", lx, 16, 20, Octant)
273288 text("one octant, mirrored 8 ways", lx, 40, 14, Dim)
274- text("POISSON SPRINKLE", rx, 16, 20, Sprink)
275- text(fmt"uniform directions, snapped ({s.over}x oversample)", rx, 40, 14, Dim)
289+ text("GOLDEN ANGLE", rx, 16, 20, Golden)
290+ text(fmt"k x 137.507 deg, snapped ({s.over}x oversample)", rx, 40, 14, Dim)
276291
277292 let by = sh - 66
278293 text(fmt"{s.bcells.len} cells {s.plotted.len} computed, rest free", lx, by, 16, Ink)
279- text(fmt"exact symmetries: {bSym} / 8", lx, by + 22, 16, Octant)
280- text(fmt"{s.scells.len} cells {s.revealed} samples rolled", rx, by, 16, Ink)
281- text(fmt"exact symmetries: {sSym} / 8", rx, by + 22, 16, Sprink)
294+ text(fmt"symmetries {bSym}/8 worst gap {largestGap(s.bcells):.2f} deg",
295+ lx, by + 22, 16, Octant)
296+ text(fmt"{s.scells.len} cells {s.revealed} angles taken", rx, by, 16, Ink)
297+ text(fmt"symmetries {sSym}/8 worst gap {largestGap(s.scells):.2f} deg",
298+ rx, by + 22, 16, Golden)
282299
283300 drawLn(halfW, 0, halfW, sh, GridLine)
284301
@@ -322,18 +339,18 @@ proc main =
322339 text(fmt"testing midpoint ({s.x + 1}, {s.y}-1/2)", px, sy, 15, Mid)
323340
324341 var ly = sh - 170
325- text("cost of zero symmetry", px, ly, 15, Dim); ly += 24
342+ text("cost of no lattice symmetry", px, ly, 15, Dim); ly += 24
326343 if s.done and s.scells.len > 0:
327344 let waste = s.revealed.float / s.scells.len.float
328- text(fmt"{waste:.1f} samples per cell drawn", px, ly, 15, Sprink); ly += 22
345+ text(fmt"{waste:.1f} angles per cell drawn", px, ly, 15, Golden); ly += 22
329346 let gap = 100.0 * (s.scells.len.float / max(1, s.bcells.len).float - 1.0)
330- text(fmt"{gap:+.0f}% cells vs bresenham", px, ly, 15, Sprink); ly += 30
347+ text(fmt"{gap:+.0f}% cells vs bresenham", px, ly, 15, Golden); ly += 30
331348 else:
332349 ly += 52
333350
334351 text("SPACE step A auto R reset", px, ly, 15, (if auto: Cursor else: Dim)); ly += 20
335352 text("UP/DOWN radius (hold to scrub)", px, ly, 15, Dim); ly += 20
336- text("[ ] sprinkle oversample", px, ly, 15, Dim)
353+ text("[ ] golden-angle oversample", px, ly, 15, Dim)
337354
338355 if shot and frames == shotFrame:
339356 takeScreenshot("shot.png")
@@ -1,14 +1,16 @@
1 ## Two ways to put a circle on a grid, side by side.1 ## Two ways to put a circle on a grid, side by side.
2 ##2 ##
3 ## left -- Bresenham: integer decisions, one octant, mirrored 8 ways.3 ## left -- Bresenham: integer decisions, one octant, mirrored 8 ways.
4-## right -- Poisson sprinkle: uniform directions snapped to cells.4+## right -- Golden angle: k * 137.5 deg, snapped to cells.
5 ##5 ##
6-## Both land on the same lattice. The difference is the symmetry of the6+## Both land on the same lattice, and both are fully deterministic. The
7-## *selection*: Bresenham's is exactly 8-fold, the sprinkle's is trivial.7+## difference is the symmetry of the *selection*: Bresenham's is exactly
8+## 8-fold, the golden-angle sequence's is trivial. Randomness was never what
9+## bought that -- incommensurability with the grid is.
8 ##10 ##
9 ## Controls: SPACE step A auto R reset UP/DOWN radius [ ] oversample ESC11 ## Controls: SPACE step A auto R reset UP/DOWN radius [ ] oversample ESC
10 12
11-import raylib, std/[strformat, os, math, strutils, random, sets, hashes]13+import raylib, std/[strformat, os, math, strutils, algorithm, sets, hashes]
12 14
13 const15 const
14 InitW = 140016 InitW = 1400
@@ -23,7 +25,7 @@ const
23 TrueArc = Color(r: 100, g: 100, b: 128, a: 255)25 TrueArc = Color(r: 100, g: 100, b: 128, a: 255)
24 Octant = Color(r: 88, g: 166, b: 255, a: 255)26 Octant = Color(r: 88, g: 166, b: 255, a: 255)
25 Mirror = Color(r: 88, g: 166, b: 255, a: 105)27 Mirror = Color(r: 88, g: 166, b: 255, a: 105)
26- Sprink = Color(r: 118, g: 222, b: 160, a: 210)28+ Golden = Color(r: 118, g: 222, b: 160, a: 210)
27 Cursor = Color(r: 255, g: 176, b: 64, a: 255)29 Cursor = Color(r: 255, g: 176, b: 64, a: 255)
28 Candidate = Color(r: 255, g: 176, b: 64, a: 60)30 Candidate = Color(r: 255, g: 176, b: 64, a: 60)
29 Mid = Color(r: 255, g: 92, b: 92, a: 255)31 Mid = Color(r: 255, g: 92, b: 92, a: 255)
@@ -45,9 +47,9 @@ type
45 branch: Branch47 branch: Branch
46 lastUpdate: string48 lastUpdate: string
47 done: bool49 done: bool
48- # -- Poisson sprinkle50+ # -- Golden angle
49 over: int ## samples per Bresenham pixel51 over: int ## samples per Bresenham pixel
50- samples: seq[(int, int)] ## pre-rolled, revealed progressively52+ samples: seq[(int, int)] ## precomputed, revealed progressively
51 revealed: int53 revealed: int
52 scells: Cells54 scells: Cells
53 perStep: int55 perStep: int
@@ -71,13 +73,15 @@ proc reset(s: var State, r: int, over = -1) =
71 s.lastUpdate = ""73 s.lastUpdate = ""
72 s.done = false74 s.done = false
73 75
74- # Sprinkle: uniform in angle, so isotropic in distribution but with no76+ # Golden angle: theta_k = k * 2*pi/phi^2, about 137.507 degrees. Wholly
75- # exact symmetry at all. Fixed seed per radius so runs are reproducible.77+ # deterministic, yet incommensurable with the lattice -- so no rotation or
76- var rng = initRand(0xC0FFEE + r * 7919)78+ # reflection maps the result to itself. Low discrepancy, so it also never
79+ # clumps the way independent sampling does.
80+ const golden = 2.0 * PI / ((1.0 + sqrt(5.0)) / 2.0) ^ 2
77 let n = int(s.over.float * 4.0 * sqrt(2.0) * r.float)81 let n = int(s.over.float * 4.0 * sqrt(2.0) * r.float)
78 s.samples = newSeqOfCap[(int, int)](n)82 s.samples = newSeqOfCap[(int, int)](n)
79- for _ in 1 .. n:83+ for k in 0 ..< n:
80- let t = rng.rand(2.0 * PI)84+ let t = float(k) * golden
81 s.samples.add (int(round(r.float * cos(t))), int(round(r.float * sin(t))))85 s.samples.add (int(round(r.float * cos(t))), int(round(r.float * sin(t))))
82 s.revealed = 086 s.revealed = 0
83 s.scells = initHashSet[(int, int)]()87 s.scells = initHashSet[(int, int)]()
@@ -90,8 +94,8 @@ proc reveal(s: var State, k: int) =
90 inc s.revealed94 inc s.revealed
91 95
92 proc step(s: var State) =96 proc step(s: var State) =
93- ## Exactly one iteration of the Bresenham loop body, plus the sprinkle's97+ ## Exactly one iteration of the Bresenham loop body, plus the golden-angle
94- ## proportional share of samples so the two fill at a comparable rate.98+ ## sequence's proportional share so the two fill at a comparable rate.
95 if s.done: return99 if s.done: return
96 if s.x > s.y:100 if s.x > s.y:
97 s.done = true101 s.done = true
@@ -120,9 +124,20 @@ proc step(s: var State) =
120 s.done = true124 s.done = true
121 s.reveal(s.samples.len)125 s.reveal(s.samples.len)
122 126
127+proc largestGap(c: Cells): float =
128+ ## Worst angular hole in the ring, in degrees. Bresenham is lattice-limited;
129+ ## anything that samples directions has to buy its way down to that.
130+ if c.len < 2: return 360.0
131+ var a = newSeqOfCap[float](c.len)
132+ for (x, y) in c: a.add arctan2(y.float, x.float).floorMod(2.0 * PI)
133+ a.sort()
134+ for i in 0 ..< a.high: result = max(result, a[i + 1] - a[i])
135+ result = max(result, a[0] + 2.0 * PI - a[^1])
136+ result = radToDeg(result)
137+
123 proc symmetries(c: Cells): int =138 proc symmetries(c: Cells): int =
124 ## How many of the lattice's 8 symmetries actually map this pixel set to139 ## How many of the lattice's 8 symmetries actually map this pixel set to
125- ## itself. Bresenham: 8. A sprinkle: 1 (the identity), essentially always.140+ ## itself. Bresenham: 8. The golden-angle set: 1, the identity alone.
126 if c.len == 0: return 0141 if c.len == 0: return 0
127 for k in 0 ..< 8:142 for k in 0 ..< 8:
128 var ok = true143 var ok = true
@@ -175,7 +190,7 @@ proc backdrop(v: View, r: int) =
175 190
176 proc main =191 proc main =
177 setConfigFlags(flags(WindowResizable))192 setConfigFlags(flags(WindowResizable))
178- initWindow(InitW, InitH, "Bresenham vs Poisson sprinkle")193+ initWindow(InitW, InitH, "Bresenham vs golden angle")
179 defer: closeWindow()194 defer: closeWindow()
180 setWindowMinSize(900, 640)195 setWindowMinSize(900, 640)
181 setTargetFPS(60)196 setTargetFPS(60)
@@ -258,10 +273,10 @@ proc main =
258 drawCircle(int32(left.cx(s.x + 1) + left.half), int32(left.cy(s.y) + cell),273 drawCircle(int32(left.cx(s.x + 1) + left.half), int32(left.cy(s.y) + cell),
259 float32(max(3, cell div 5)), Mid)274 float32(max(3, cell div 5)), Mid)
260 275
261- # ================= right: Poisson sprinkle =================276+ # ================= right: golden angle =================
262 backdrop(right, s.r)277 backdrop(right, s.r)
263 for (mx, my) in s.scells:278 for (mx, my) in s.scells:
264- drawRect(right.cx(mx), right.cy(my), cell, cell, Sprink)279+ drawRect(right.cx(mx), right.cy(my), cell, cell, Golden)
265 280
266 # ---- titles and per-pane stats281 # ---- titles and per-pane stats
267 let282 let
@@ -271,14 +286,16 @@ proc main =
271 rx = halfW + 24286 rx = halfW + 24
272 text("BRESENHAM", lx, 16, 20, Octant)287 text("BRESENHAM", lx, 16, 20, Octant)
273 text("one octant, mirrored 8 ways", lx, 40, 14, Dim)288 text("one octant, mirrored 8 ways", lx, 40, 14, Dim)
274- text("POISSON SPRINKLE", rx, 16, 20, Sprink)289+ text("GOLDEN ANGLE", rx, 16, 20, Golden)
275- text(fmt"uniform directions, snapped ({s.over}x oversample)", rx, 40, 14, Dim)290+ text(fmt"k x 137.507 deg, snapped ({s.over}x oversample)", rx, 40, 14, Dim)
276 291
277 let by = sh - 66292 let by = sh - 66
278 text(fmt"{s.bcells.len} cells {s.plotted.len} computed, rest free", lx, by, 16, Ink)293 text(fmt"{s.bcells.len} cells {s.plotted.len} computed, rest free", lx, by, 16, Ink)
279- text(fmt"exact symmetries: {bSym} / 8", lx, by + 22, 16, Octant)294+ text(fmt"symmetries {bSym}/8 worst gap {largestGap(s.bcells):.2f} deg",
280- text(fmt"{s.scells.len} cells {s.revealed} samples rolled", rx, by, 16, Ink)295+ lx, by + 22, 16, Octant)
281- text(fmt"exact symmetries: {sSym} / 8", rx, by + 22, 16, Sprink)296+ text(fmt"{s.scells.len} cells {s.revealed} angles taken", rx, by, 16, Ink)
297+ text(fmt"symmetries {sSym}/8 worst gap {largestGap(s.scells):.2f} deg",
298+ rx, by + 22, 16, Golden)
282 299
283 drawLn(halfW, 0, halfW, sh, GridLine)300 drawLn(halfW, 0, halfW, sh, GridLine)
284 301
@@ -322,18 +339,18 @@ proc main =
322 text(fmt"testing midpoint ({s.x + 1}, {s.y}-1/2)", px, sy, 15, Mid)339 text(fmt"testing midpoint ({s.x + 1}, {s.y}-1/2)", px, sy, 15, Mid)
323 340
324 var ly = sh - 170341 var ly = sh - 170
325- text("cost of zero symmetry", px, ly, 15, Dim); ly += 24342+ text("cost of no lattice symmetry", px, ly, 15, Dim); ly += 24
326 if s.done and s.scells.len > 0:343 if s.done and s.scells.len > 0:
327 let waste = s.revealed.float / s.scells.len.float344 let waste = s.revealed.float / s.scells.len.float
328- text(fmt"{waste:.1f} samples per cell drawn", px, ly, 15, Sprink); ly += 22345+ text(fmt"{waste:.1f} angles per cell drawn", px, ly, 15, Golden); ly += 22
329 let gap = 100.0 * (s.scells.len.float / max(1, s.bcells.len).float - 1.0)346 let gap = 100.0 * (s.scells.len.float / max(1, s.bcells.len).float - 1.0)
330- text(fmt"{gap:+.0f}% cells vs bresenham", px, ly, 15, Sprink); ly += 30347+ text(fmt"{gap:+.0f}% cells vs bresenham", px, ly, 15, Golden); ly += 30
331 else:348 else:
332 ly += 52349 ly += 52
333 350
334 text("SPACE step A auto R reset", px, ly, 15, (if auto: Cursor else: Dim)); ly += 20351 text("SPACE step A auto R reset", px, ly, 15, (if auto: Cursor else: Dim)); ly += 20
335 text("UP/DOWN radius (hold to scrub)", px, ly, 15, Dim); ly += 20352 text("UP/DOWN radius (hold to scrub)", px, ly, 15, Dim); ly += 20
336- text("[ ] sprinkle oversample", px, ly, 15, Dim)353+ text("[ ] golden-angle oversample", px, ly, 15, Dim)
337 354
338 if shot and frames == shotFrame:355 if shot and frames == shotFrame:
339 takeScreenshot("shot.png")356 takeScreenshot("shot.png")