authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 12:43:04-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 19:59:06-07:00
loga29d2f35e9138142b1ac422ca2e99f5e78599c28
treef9d2896975edc20208869bd2fbe841bcfdb4fa21
parent407dfe55dbe42cb807256f899650e1042bc2c8e4
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: a snowflake toy grown by Reiter's automaton, painted through draw

Assisted-by: claude-opus-5.5

2 files changed, 364 insertions(+), 0 deletions(-)

crates/draw/Cargo.toml+4
...@@ -64,3 +64,7 @@ web-sys = { version = "0.3", optional = true, features = [...@@ -64,3 +64,7 @@ web-sys = { version = "0.3", optional = true, features = [
64[dev-dependencies]64[dev-dependencies]
65pollster = "0.4"65pollster = "0.4"
66png = "0.18"66png = "0.18"
67
68[[example]]
69name = "snowflakes"
70required-features = ["wgpu"]
crates/draw/examples/snowflakes.rs created+360
...@@ -0,0 +1,360 @@
1//! Snowflakes grown by Reiter's hexagonal automaton ("A local cellular model for snow
2//! crystal growth", 2005), painted through Snowbound's draw crate.
3//!
4//! `cargo run --release -p draw --example snowflakes -- [first seed] [out dir]` writes
5//! `gallery.png`, twelve flakes from consecutive seeds, and `growing.gif`, the first one freezing.
6
7use std::{f32::consts::PI, fmt::Write as _, path::PathBuf, time::Duration};
8
9use draw::{Layer, PathStyle, Primitive, Renderer, srgb};
10
11/// Hex cells from the centre to the edge of the grid.
12const RADIUS: i32 = 110;
13const SIDE: usize = 2 * RADIUS as usize + 1;
14const NEIGHBOURS: [(i32, i32); 6] = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, -1), (-1, 1)];
15const TILE: u32 = 512;
16const BACKGROUND: [u8; 3] = [9, 16, 36];
17
18struct Weather {
19 /// How quickly vapour spreads.
20 alpha: f32,
21 /// Vapour everywhere at the start, and at the grid's edge always.
22 beta: f32,
23 /// Vapour condensing onto the crystal each step.
24 gamma: f32,
25}
26
27impl Weather {
28 fn from_seed(seed: u64) -> Self {
29 let mut state = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ 0x5eed;
30 let mut next = || {
31 state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
32 let mut z = state;
33 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
34 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
35 ((z ^ (z >> 31)) >> 40) as f32 / (1u64 << 24) as f32
36 };
37 Self {
38 alpha: 0.7 + 1.6 * next(),
39 beta: 0.3 + 0.6 * next(),
40 gamma: if next() < 0.2 {
41 0.0
42 } else {
43 10f32.powf(-4.0 + 1.6 * next())
44 },
45 }
46 }
47}
48
49/// Axial cell `(q, r)` with every `|q|`, `|r|`, `|q + r|` within `RADIUS`.
50fn index(q: i32, r: i32) -> Option<usize> {
51 (q.abs() <= RADIUS && r.abs() <= RADIUS && (q + r).abs() <= RADIUS)
52 .then(|| (q + RADIUS) as usize * SIDE + (r + RADIUS) as usize)
53}
54
55fn cells() -> impl Iterator<Item = (i32, i32, usize)> {
56 (-RADIUS..=RADIUS)
57 .flat_map(|q| (-RADIUS..=RADIUS).map(move |r| (q, r)))
58 .filter_map(|(q, r)| index(q, r).map(|i| (q, r, i)))
59}
60
61/// Water at each cell; a cell holding 1 or more is ice.
62type Field = Vec<f32>;
63
64/// Grows a flake until its arms near the grid's edge, offering `frame` each step.
65fn grow(weather: &Weather, mut frame: impl FnMut(&Field)) -> Field {
66 let Weather { alpha, beta, gamma } = *weather;
67 let mut water = vec![beta; SIDE * SIDE];
68 water[index(0, 0).unwrap()] = 1.0;
69 let mut diffusing = vec![0.0; SIDE * SIDE];
70 let mut spread = diffusing.clone();
71 let mut held = diffusing.clone();
72 let neighbours = |q: i32, r: i32| NEIGHBOURS.map(|(dq, dr)| index(q + dq, r + dr));
73 for _ in 0..60_000 {
74 for (q, r, i) in cells() {
75 let receptive =
76 water[i] >= 1.0 || neighbours(q, r).iter().flatten().any(|&n| water[n] >= 1.0);
77 (diffusing[i], held[i]) = if receptive {
78 (0.0, water[i] + gamma)
79 } else {
80 (water[i], 0.0)
81 };
82 }
83 let mut reach = 0;
84 for (q, r, i) in cells() {
85 let around: f32 = neighbours(q, r)
86 .iter()
87 .map(|n| n.map_or(beta, |n| diffusing[n]))
88 .sum();
89 spread[i] = diffusing[i] + alpha / 12.0 * (around - 6.0 * diffusing[i]);
90 water[i] = spread[i] + held[i];
91 if water[i] >= 1.0 {
92 reach = reach.max(q.abs().max(r.abs()).max((q + r).abs()));
93 }
94 }
95 frame(&water);
96 if reach >= RADIUS - 6 {
97 break;
98 }
99 }
100 water
101}
102
103/// The farthest ice from the centre, in cells across.
104fn extent(water: &Field) -> f32 {
105 cells()
106 .filter(|&(_, _, i)| water[i] >= 1.0)
107 .map(|(q, r, _)| {
108 3f32.sqrt() * ((q as f32 + r as f32 / 2.0).powi(2) + (0.75 * (r * r) as f32)).sqrt()
109 })
110 .fold(1.0, f32::max)
111}
112
113/// The flake as stacked translucent layers: a glow, then ice thickening toward white.
114fn paint(
115 water: &Field,
116 reach: f32,
117 centre: [f32; 2],
118 size: f32,
119) -> Vec<(String, PathStyle, [[f32; 4]; 2])> {
120 let hexagon = |q: i32, r: i32, path: &mut String| {
121 let x = centre[0] + size * 3f32.sqrt() * (q as f32 + r as f32 / 2.0);
122 let y = centre[1] + size * 1.5 * r as f32;
123 for corner in 0..6 {
124 let angle = PI / 3.0 * corner as f32 + PI / 6.0;
125 let command = if corner == 0 { 'M' } else { 'L' };
126 let _ = write!(
127 path,
128 "{command}{:.2} {:.2}",
129 x + size * angle.cos(),
130 y + size * angle.sin()
131 );
132 }
133 path.push('Z');
134 };
135 let ice: Vec<_> = cells().filter(|&(_, _, i)| water[i] >= 1.0).collect();
136 let heaviest = ice.iter().map(|&(_, _, i)| water[i]).fold(1.0, f32::max);
137 let thickness = |i: usize| ((water[i] - 1.0) / (heaviest - 1.0).max(1e-6)).sqrt();
138 const BANDS: usize = 7;
139 let mut layers = Vec::new();
140 let mut all = String::new();
141 ice.iter().for_each(|&(q, r, _)| hexagon(q, r, &mut all));
142 let glow = [0.25, 0.55, 1.0, 0.45];
143 layers.push((
144 all.clone(),
145 PathStyle::Shadow(size * reach * 0.04 + 6.0),
146 [glow, glow],
147 ));
148 layers.push((
149 all,
150 PathStyle::Fill,
151 [[0.32, 0.55, 0.95, 0.55], [0.18, 0.35, 0.80, 0.55]],
152 ));
153 for band in 1..BANDS {
154 let mut path = String::new();
155 for &(q, r, i) in &ice {
156 if thickness(i) * BANDS as f32 >= band as f32 {
157 hexagon(q, r, &mut path);
158 }
159 }
160 if !path.is_empty() {
161 layers.push((
162 path,
163 PathStyle::Fill,
164 [[0.95, 0.98, 1.0, 0.24], [0.80, 0.90, 1.0, 0.20]],
165 ));
166 }
167 }
168 layers
169}
170
171struct Gpu {
172 renderer: Renderer,
173 texture: wgpu::Texture,
174 readback: wgpu::Buffer,
175}
176
177impl Gpu {
178 fn new() -> Self {
179 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
180 let adapter =
181 pollster::block_on(instance.request_adapter(&Default::default())).expect("a GPU");
182 let (device, queue) =
183 pollster::block_on(adapter.request_device(&Default::default())).expect("a device");
184 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
185 let texture = device.create_texture(&wgpu::TextureDescriptor {
186 label: Some("Flake"),
187 size: wgpu::Extent3d {
188 width: TILE,
189 height: TILE,
190 depth_or_array_layers: 1,
191 },
192 mip_level_count: 1,
193 sample_count: 1,
194 dimension: wgpu::TextureDimension::D2,
195 format,
196 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
197 view_formats: &[],
198 });
199 let readback = device.create_buffer(&wgpu::BufferDescriptor {
200 label: Some("Flake readback"),
201 size: u64::from(TILE * TILE * 4),
202 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
203 mapped_at_creation: false,
204 });
205 Self {
206 renderer: Renderer::new(device, queue, format),
207 texture,
208 readback,
209 }
210 }
211
212 /// One tile of sRGB RGBA rows.
213 fn render(&mut self, water: &Field, reach: f32) -> image::RgbaImage {
214 let size = TILE as f32 * 0.46 / reach;
215 let centre = [TILE as f32 / 2.0; 2];
216 let layers = paint(water, reach, centre, size);
217 let primitives: Vec<_> = layers
218 .iter()
219 .map(|(data, style, colors)| Primitive::Path {
220 data,
221 origin: [0.0; 2],
222 style: *style,
223 colors: *colors,
224 })
225 .collect();
226 let [red, green, blue] = BACKGROUND;
227 self.renderer.clear_glyph_cache();
228 self.renderer
229 .draw(
230 &draw::Target::from(self.texture.create_view(&Default::default())),
231 [TILE; 2],
232 srgb(red, green, blue),
233 &[Layer {
234 scale: 1.0,
235 origin: [0.0; 2],
236 clip: None,
237 backdrop: None,
238 round: None,
239 motion: None,
240 primitives: &primitives,
241 }],
242 )
243 .expect("a frame");
244 let device = self.renderer.device();
245 let mut encoder = device.create_command_encoder(&Default::default());
246 encoder.copy_texture_to_buffer(
247 self.texture.as_image_copy(),
248 wgpu::TexelCopyBufferInfo {
249 buffer: &self.readback,
250 layout: wgpu::TexelCopyBufferLayout {
251 offset: 0,
252 bytes_per_row: Some(TILE * 4),
253 rows_per_image: Some(TILE),
254 },
255 },
256 wgpu::Extent3d {
257 width: TILE,
258 height: TILE,
259 depth_or_array_layers: 1,
260 },
261 );
262 self.renderer.queue().submit([encoder.finish()]);
263 let (sender, receiver) = std::sync::mpsc::channel();
264 self.readback
265 .map_async(wgpu::MapMode::Read, .., move |result| {
266 sender.send(result).unwrap()
267 });
268 device
269 .poll(wgpu::PollType::Wait {
270 submission_index: None,
271 timeout: Some(Duration::from_secs(10)),
272 })
273 .unwrap();
274 receiver
275 .recv_timeout(Duration::from_secs(10))
276 .unwrap()
277 .unwrap();
278 let pixels = self.readback.get_mapped_range(..).unwrap().to_vec();
279 self.readback.unmap();
280 image::RgbaImage::from_raw(TILE, TILE, pixels).unwrap()
281 }
282}
283
284fn main() {
285 let mut args = std::env::args().skip(1);
286 let first: u64 = args
287 .next()
288 .map_or(1, |seed| seed.parse().expect("a numeric seed"));
289 let out = PathBuf::from(args.next().unwrap_or_else(|| ".".into()));
290 std::fs::create_dir_all(&out).unwrap();
291
292 let seeds: Vec<u64> = (first..first + 12).collect();
293 let mut growing = Vec::new();
294 let flakes: Vec<Field> = std::thread::scope(|scope| {
295 let handles: Vec<_> = seeds
296 .iter()
297 .map(|&seed| scope.spawn(move || grow(&Weather::from_seed(seed), |_| {})))
298 .collect();
299 // Snapshots at a widening interval, halved whenever they pass 400.
300 let (mut step, mut every) = (0, 1);
301 let shown = grow(&Weather::from_seed(first), |water| {
302 if step % every == 0 {
303 growing.push(water.clone());
304 if growing.len() > 400 {
305 growing = growing.iter().step_by(2).cloned().collect();
306 every *= 2;
307 }
308 }
309 step += 1;
310 });
311 growing.push(shown.clone());
312 let mut flakes: Vec<_> = handles
313 .into_iter()
314 .map(|handle| handle.join().unwrap())
315 .collect();
316 flakes[0] = shown;
317 flakes
318 });
319
320 let mut gpu = Gpu::new();
321 let mut gallery = image::RgbaImage::new(TILE * 4, TILE * 3);
322 for (n, (water, seed)) in flakes.iter().zip(&seeds).enumerate() {
323 let Weather { alpha, beta, gamma } = Weather::from_seed(*seed);
324 println!("seed {seed}: alpha {alpha:.2} beta {beta:.3} gamma {gamma:.5}");
325 let tile = gpu.render(water, extent(water));
326 image::imageops::overlay(
327 &mut gallery,
328 &tile,
329 i64::from(n as u32 % 4 * TILE),
330 i64::from(n as u32 / 4 * TILE),
331 );
332 }
333 gallery.save(out.join("gallery.png")).unwrap();
334
335 // Ninety frames spread over the growth, the finished flake held at the end.
336 let reach = extent(growing.last().unwrap());
337 let picks: Vec<usize> = (0..90).map(|f| f * (growing.len() - 1) / 89).collect();
338 let file = std::fs::File::create(out.join("growing.gif")).unwrap();
339 let mut encoder = image::codecs::gif::GifEncoder::new_with_speed(file, 10);
340 encoder
341 .set_repeat(image::codecs::gif::Repeat::Infinite)
342 .unwrap();
343 for (n, &pick) in picks.iter().enumerate() {
344 let tile = image::imageops::resize(
345 &gpu.render(&growing[pick], reach),
346 320,
347 320,
348 image::imageops::FilterType::Triangle,
349 );
350 let delay = if n + 1 == picks.len() { 2500 } else { 50 };
351 encoder
352 .encode_frame(image::Frame::from_parts(
353 tile,
354 0,
355 0,
356 image::Delay::from_numer_denom_ms(delay, 1),
357 ))
358 .unwrap();
359 }
360}