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>
686a373 parent: d1c5bb8 modified
README.md +61 -27 | @@ -1,7 +1,8 @@ | ||
| 1 | 1 | # bresenham-circle |
| 2 | 2 | |
| 3 | 3 | 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 | 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 | 26 | | `A` | auto-step | |
| 26 | 27 | | `R` | reset | |
| 27 | 28 | | `UP` / `DOWN` | radius (hold to scrub, accelerating) | |
| 28 | -| `[` `]` | sprinkle oversample factor | | |
| 29 | +| `[` `]` | golden-angle oversample factor | | |
| 29 | 30 | | `ESC` | quit | |
| 30 | 31 | |
| 31 | 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 | 70 | `2πr`, because roughly half the steps are diagonal and cover √2 of arc for one |
| 70 | 71 | pixel.) |
| 71 | 72 | |
| 72 | -## Right pane: Poisson sprinkle | |
| 73 | +## Right pane: golden angle | |
| 73 | 74 | |
| 74 | 75 | ```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 | |
| 77 | 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 **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. | |
| 85 | 90 | |
| 86 | 91 | 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 difference | |
| 110 | +### The spectral signature | |
| 90 | 111 | |
| 91 | 112 | Take the radial error as a function of angle and transform it. Bresenham's |
| 92 | 113 | 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: | |
| 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** | | |
| 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. | |
| 110 | 144 | |
| 111 | 145 | ## License |
| 112 | 146 | |
| @@ -1,7 +1,8 @@ | |||
| 1 | # bresenham-circle | 1 | # bresenham-circle |
| 2 | 2 | ||
| 3 | Bresenham's midpoint circle algorithm, stepped one decision at a time, next to a | 3 | 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 one | 70 | `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 sprinkle | 73 | +## Right pane: golden angle |
| 73 | 74 | ||
| 74 | ```nim | 75 | ```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 **trivial | 82 | +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 is | 84 | +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, keep | 85 | + |
| 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 lattice | 91 | 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 difference | 110 | +### The spectral signature |
| 90 | 111 | ||
| 91 | Take the radial error as a function of angle and transform it. Bresenham's | 112 | 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 rotation | 113 | 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 | 114 | +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 more | 126 | +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 is | 127 | +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 the | 128 | +forbidden, and the other two have no forbidden harmonics at all. |
| 107 | -same circle, no determinism, and trig plus an RNG where Bresenham used integer | 129 | + |
| 108 | -adds. Drop the oversample to 1× with `[` and holes open in the ring — the | 130 | +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 | ## License | 145 | ## License |
| 112 | 146 | ||
modified
bresenham.nimble +1 -1 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | version = "0.1.0" |
| 2 | 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 | 4 | license = "MIT" |
| 5 | 5 | srcDir = "src" |
| 6 | 6 | 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 differ | Binary files a/docs/screenshot.png and b/docs/screenshot.png differ |
modified
src/bresenham.nim +44 -27 | @@ -1,14 +1,16 @@ | ||
| 1 | 1 | ## Two ways to put a circle on a grid, side by side. |
| 2 | 2 | ## |
| 3 | 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 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. | |
| 8 | 10 | ## |
| 9 | 11 | ## 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 | 15 | const |
| 14 | 16 | InitW = 1400 |
| @@ -23,7 +25,7 @@ const | ||
| 23 | 25 | TrueArc = Color(r: 100, g: 100, b: 128, a: 255) |
| 24 | 26 | Octant = Color(r: 88, g: 166, b: 255, a: 255) |
| 25 | 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 | 29 | Cursor = Color(r: 255, g: 176, b: 64, a: 255) |
| 28 | 30 | Candidate = Color(r: 255, g: 176, b: 64, a: 60) |
| 29 | 31 | Mid = Color(r: 255, g: 92, b: 92, a: 255) |
| @@ -45,9 +47,9 @@ type | ||
| 45 | 47 | branch: Branch |
| 46 | 48 | lastUpdate: string |
| 47 | 49 | done: bool |
| 48 | - # -- Poisson sprinkle | |
| 50 | + # -- Golden angle | |
| 49 | 51 | over: int ## samples per Bresenham pixel |
| 50 | - samples: seq[(int, int)] ## pre-rolled, revealed progressively | |
| 52 | + samples: seq[(int, int)] ## precomputed, revealed progressively | |
| 51 | 53 | revealed: int |
| 52 | 54 | scells: Cells |
| 53 | 55 | perStep: int |
| @@ -71,13 +73,15 @@ proc reset(s: var State, r: int, over = -1) = | ||
| 71 | 73 | s.lastUpdate = "" |
| 72 | 74 | s.done = false |
| 73 | 75 | |
| 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 | |
| 77 | 81 | let n = int(s.over.float * 4.0 * sqrt(2.0) * r.float) |
| 78 | 82 | 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 | |
| 81 | 85 | s.samples.add (int(round(r.float * cos(t))), int(round(r.float * sin(t)))) |
| 82 | 86 | s.revealed = 0 |
| 83 | 87 | s.scells = initHashSet[(int, int)]() |
| @@ -90,8 +94,8 @@ proc reveal(s: var State, k: int) = | ||
| 90 | 94 | inc s.revealed |
| 91 | 95 | |
| 92 | 96 | 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. | |
| 95 | 99 | if s.done: return |
| 96 | 100 | if s.x > s.y: |
| 97 | 101 | s.done = true |
| @@ -120,9 +124,20 @@ proc step(s: var State) = | ||
| 120 | 124 | s.done = true |
| 121 | 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 | 138 | proc symmetries(c: Cells): int = |
| 124 | 139 | ## 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 | 141 | if c.len == 0: return 0 |
| 127 | 142 | for k in 0 ..< 8: |
| 128 | 143 | var ok = true |
| @@ -175,7 +190,7 @@ proc backdrop(v: View, r: int) = | ||
| 175 | 190 | |
| 176 | 191 | proc main = |
| 177 | 192 | setConfigFlags(flags(WindowResizable)) |
| 178 | - initWindow(InitW, InitH, "Bresenham vs Poisson sprinkle") | |
| 193 | + initWindow(InitW, InitH, "Bresenham vs golden angle") | |
| 179 | 194 | defer: closeWindow() |
| 180 | 195 | setWindowMinSize(900, 640) |
| 181 | 196 | setTargetFPS(60) |
| @@ -258,10 +273,10 @@ proc main = | ||
| 258 | 273 | drawCircle(int32(left.cx(s.x + 1) + left.half), int32(left.cy(s.y) + cell), |
| 259 | 274 | float32(max(3, cell div 5)), Mid) |
| 260 | 275 | |
| 261 | - # ================= right: Poisson sprinkle ================= | |
| 276 | + # ================= right: golden angle ================= | |
| 262 | 277 | backdrop(right, s.r) |
| 263 | 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 | 281 | # ---- titles and per-pane stats |
| 267 | 282 | let |
| @@ -271,14 +286,16 @@ proc main = | ||
| 271 | 286 | rx = halfW + 24 |
| 272 | 287 | text("BRESENHAM", lx, 16, 20, Octant) |
| 273 | 288 | 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) | |
| 276 | 291 | |
| 277 | 292 | let by = sh - 66 |
| 278 | 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) | |
| 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) | |
| 282 | 299 | |
| 283 | 300 | drawLn(halfW, 0, halfW, sh, GridLine) |
| 284 | 301 | |
| @@ -322,18 +339,18 @@ proc main = | ||
| 322 | 339 | text(fmt"testing midpoint ({s.x + 1}, {s.y}-1/2)", px, sy, 15, Mid) |
| 323 | 340 | |
| 324 | 341 | 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 | |
| 326 | 343 | if s.done and s.scells.len > 0: |
| 327 | 344 | 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 | |
| 329 | 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 += 30 | |
| 347 | + text(fmt"{gap:+.0f}% cells vs bresenham", px, ly, 15, Golden); ly += 30 | |
| 331 | 348 | else: |
| 332 | 349 | ly += 52 |
| 333 | 350 | |
| 334 | 351 | text("SPACE step A auto R reset", px, ly, 15, (if auto: Cursor else: Dim)); ly += 20 |
| 335 | 352 | 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 | 355 | if shot and frames == shotFrame: |
| 339 | 356 | 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 the | 6 | +## 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 ESC | 11 | ## 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 | const | 15 | const |
| 14 | InitW = 1400 | 16 | 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: Branch | 47 | branch: Branch |
| 46 | lastUpdate: string | 48 | lastUpdate: string |
| 47 | done: bool | 49 | done: bool |
| 48 | - # -- Poisson sprinkle | 50 | + # -- Golden angle |
| 49 | over: int ## samples per Bresenham pixel | 51 | over: int ## samples per Bresenham pixel |
| 50 | - samples: seq[(int, int)] ## pre-rolled, revealed progressively | 52 | + samples: seq[(int, int)] ## precomputed, revealed progressively |
| 51 | revealed: int | 53 | revealed: int |
| 52 | scells: Cells | 54 | scells: Cells |
| 53 | perStep: int | 55 | perStep: int |
| @@ -71,13 +73,15 @@ proc reset(s: var State, r: int, over = -1) = | |||
| 71 | s.lastUpdate = "" | 73 | s.lastUpdate = "" |
| 72 | s.done = false | 74 | s.done = false |
| 73 | 75 | ||
| 74 | - # Sprinkle: uniform in angle, so isotropic in distribution but with no | 76 | + # 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 = 0 | 86 | 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.revealed | 94 | 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's | 97 | + ## 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: return | 99 | if s.done: return |
| 96 | if s.x > s.y: | 100 | if s.x > s.y: |
| 97 | s.done = true | 101 | s.done = true |
| @@ -120,9 +124,20 @@ proc step(s: var State) = | |||
| 120 | s.done = true | 124 | 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 to | 139 | ## 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 0 | 141 | if c.len == 0: return 0 |
| 127 | for k in 0 ..< 8: | 142 | for k in 0 ..< 8: |
| 128 | var ok = true | 143 | 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 stats | 281 | # ---- titles and per-pane stats |
| 267 | let | 282 | let |
| @@ -271,14 +286,16 @@ proc main = | |||
| 271 | rx = halfW + 24 | 286 | 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 - 66 | 292 | 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 - 170 | 341 | 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 |
| 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.float | 344 | 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 |
| 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 += 30 | 347 | + text(fmt"{gap:+.0f}% cells vs bresenham", px, ly, 15, Golden); ly += 30 |
| 331 | else: | 348 | else: |
| 332 | ly += 52 | 349 | ly += 52 |
| 333 | 350 | ||
| 334 | text("SPACE step A auto R reset", px, ly, 15, (if auto: Cursor else: Dim)); ly += 20 | 351 | 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 += 20 | 352 | 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") |