1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
//! Snowflake sprite generator.
//!
//! A dendritic flake with N-fold symmetry: a small central plate, one main
//! arm per sector, and paired side branches along each arm. The angle is
//! folded into a single mirrored sector so the maths only ever draws one
//! half-arm — symmetry comes for free. Per-variant cells jitter arm
//! length, branch stations, branch lengths, and branch angle, which is
//! where the "no two snowflakes alike" character comes from.
//!
//! Upload with [`map_to_images_card`](crate::generator::map_to_images_card);
//! see [`crate::sprite`] for the shared atlas conventions.
use std::f64::consts::TAU;
use crate::{
generator::{TextureError, TextureGenerator, TextureMap},
sprite::{CellRng, SpriteCell, SpriteSample, generate_atlas},
};
/// Hard cap on side-branch pairs; also sizes the per-cell branch tables.
const MAX_BRANCH_PAIRS: usize = 5;
/// Configures the appearance of a [`SnowflakeGenerator`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SnowflakeConfig {
/// PRNG seed for the per-cell variant jitter.
pub seed: u32,
/// Atlas rows; each cell bakes an independent variant (clamped to
/// `1..=16`).
pub variant_rows: usize,
/// Atlas columns; see `variant_rows`.
pub variant_cols: usize,
/// Symmetry order `[3, 8]`. Real snow is hexagonal (`6`); other
/// orders read as stylised frost stars.
pub arms: usize,
/// Flake colour in linear RGB \[0, 1\].
pub color: [f32; 3],
/// Radius of the central plate as a fraction of the cell half-extent
/// `[0, 0.4]`.
pub core_radius: f64,
/// Half-width of the main arm at the centre `[0.01, 0.12]`; arms taper
/// toward the tip.
pub arm_width: f64,
/// Number of side-branch pairs per arm `[0, 5]`.
pub branch_pairs: usize,
/// Angle between a side branch and the main arm, radians
/// `[0.3, 1.4]`. Real dendrites branch at ~60° (`1.05`).
pub branch_angle: f64,
/// Side-branch length relative to the remaining arm length at the
/// branch station `[0.1, 1]`.
pub branch_scale: f64,
/// Anti-aliasing edge width in cell units `[0.005, 0.08]`.
pub softness: f64,
/// Normal map strength.
pub normal_strength: f32,
}
impl Default for SnowflakeConfig {
fn default() -> Self {
Self {
seed: 0,
variant_rows: 2,
variant_cols: 2,
arms: 6,
color: [0.92, 0.96, 1.0],
core_radius: 0.12,
arm_width: 0.045,
branch_pairs: 3,
branch_angle: 1.05,
branch_scale: 0.45,
softness: 0.02,
normal_strength: 1.5,
}
}
}
/// One side branch baked for a variant cell: where it leaves the main arm
/// and how far it reaches.
#[derive(Clone, Copy, Default)]
struct Branch {
/// Station along the main arm (cell half-extent units).
station: f64,
/// Branch length (cell half-extent units).
length: f64,
}
/// One baked snowflake variant.
struct SnowflakeCell {
config: SnowflakeConfig,
arms: usize,
/// Main-arm length in cell half-extent units.
arm_len: f64,
/// Per-cell jittered branch angle.
branch_angle: f64,
branch_count: usize,
branches: [Branch; MAX_BRANCH_PAIRS],
}
impl SnowflakeCell {
fn new(config: &SnowflakeConfig, cell: usize) -> Self {
let mut rng = CellRng::new(config.seed, cell);
let arms = config.arms.clamp(3, 8);
let core = config.core_radius.clamp(0.0, 0.4);
let arm_len = 0.94 * rng.range(0.85, 1.0);
let branch_count = config.branch_pairs.min(MAX_BRANCH_PAIRS);
let branch_scale = config.branch_scale.clamp(0.1, 1.0);
let mut branches = [Branch::default(); MAX_BRANCH_PAIRS];
for (k, b) in branches.iter_mut().enumerate().take(branch_count) {
// Evenly spaced stations between the plate and the tip, each
// jittered within its slot so variants differ structurally.
let t = (k as f64 + rng.range(0.55, 1.0)) / (branch_count as f64 + 1.0);
let station = core + t * (arm_len - core);
b.station = station;
b.length = branch_scale * (arm_len - station) * rng.range(0.7, 1.1);
}
Self {
config: config.clone(),
arms,
arm_len,
branch_angle: config.branch_angle.clamp(0.3, 1.4) * rng.range(0.9, 1.1),
branch_count,
branches,
}
}
}
/// Distance from point `p` to the segment `a → b` (all in 2-D).
fn dist_to_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
let ab = (b.0 - a.0, b.1 - a.1);
let ap = (p.0 - a.0, p.1 - a.1);
let len_sq = ab.0 * ab.0 + ab.1 * ab.1;
let t = if len_sq > 1e-12 {
((ap.0 * ab.0 + ap.1 * ab.1) / len_sq).clamp(0.0, 1.0)
} else {
0.0
};
let cx = a.0 + ab.0 * t - p.0;
let cy = a.1 + ab.1 * t - p.1;
(cx * cx + cy * cy).sqrt()
}
impl SpriteCell for SnowflakeCell {
fn sample(&self, u: f64, v: f64) -> SpriteSample {
let c = &self.config;
let dx = (u - 0.5) * 2.0;
let dy = (v - 0.5) * 2.0;
let r = (dx * dx + dy * dy).sqrt();
// Fold the angle into one mirrored sector: the arm axis is the
// sector's x-axis, so every arm and its mirror image are handled by
// drawing a single half-arm.
let sector = TAU / self.arms as f64;
let theta = dy.atan2(dx).rem_euclid(sector);
let phi = theta.min(sector - theta);
let p = (r * phi.cos(), r * phi.sin());
let core = c.core_radius.clamp(0.0, 0.4);
let width = c.arm_width.clamp(0.01, 0.12);
let softness = c.softness.clamp(0.005, 0.08);
// Signed distance to the flake: negative inside. Start from the
// central plate (gently scalloped by the fold so it reads as a
// plate rather than a dot) …
let plate = r - core * (0.88 + 0.12 * (phi / (sector * 0.5)).cos());
let mut d = plate;
// … the tapered main arm …
let taper = 1.0 - 0.85 * (p.0 / self.arm_len).clamp(0.0, 1.0);
let d_arm = dist_to_segment(p, (0.0, 0.0), (self.arm_len, 0.0)) - width * taper;
d = d.min(d_arm);
// … and the side branches (mirror side covered by the fold).
let (sin_b, cos_b) = self.branch_angle.sin_cos();
for b in &self.branches[..self.branch_count] {
let root = (b.station, 0.0);
let tip = (b.station + b.length * cos_b, b.length * sin_b);
let branch_taper = taper * 0.7;
let d_branch = dist_to_segment(p, root, tip) - width * branch_taper;
d = d.min(d_branch);
}
let alpha = (1.0 - d / softness).clamp(0.0, 1.0);
// Crystal reads best with a slightly raised spine: height peaks at
// the centreline of whatever feature we are inside.
let height = alpha * (0.55 + 0.45 * (1.0 - r).clamp(0.0, 1.0));
SpriteSample {
color: c.color,
alpha,
height,
roughness: 0.35,
}
}
}
/// Procedural snowflake sprite generator.
///
/// See the [module documentation](self) for the visual model.
pub struct SnowflakeGenerator {
config: SnowflakeConfig,
}
impl SnowflakeGenerator {
/// Create a new generator with the given configuration.
pub fn new(config: SnowflakeConfig) -> Self {
Self { config }
}
}
impl TextureGenerator for SnowflakeGenerator {
fn generate(&self, width: u32, height: u32) -> Result<TextureMap, TextureError> {
let c = &self.config;
generate_atlas(
width,
height,
c.variant_rows,
c.variant_cols,
c.normal_strength,
|cell| SnowflakeCell::new(c, cell),
)
}
}
#[cfg(test)]
mod tests {
use std::f64::consts::PI;
use super::*;
fn single_cell() -> SnowflakeConfig {
SnowflakeConfig {
variant_rows: 1,
variant_cols: 1,
..SnowflakeConfig::default()
}
}
#[test]
fn generator_produces_correct_buffer_sizes() {
let map = SnowflakeGenerator::new(SnowflakeConfig::default())
.generate(64, 64)
.expect("generate failed");
assert_eq!(map.albedo.len(), 64 * 64 * 4);
assert_eq!(map.normal.len(), 64 * 64 * 4);
assert_eq!(map.roughness.len(), 64 * 64 * 4);
}
#[test]
fn has_opaque_centre_and_transparent_background() {
let map = SnowflakeGenerator::new(single_cell())
.generate(128, 128)
.expect("generate failed");
let centre = (64 * 128 + 64) * 4;
assert_eq!(map.albedo[centre + 3], 255, "plate centre must be opaque");
// Most of a snowflake cell is air.
let transparent = map.albedo.chunks(4).filter(|px| px[3] == 0).count();
assert!(
transparent > 128 * 128 / 2,
"snowflake should be mostly transparent (got {transparent} clear texels)"
);
}
#[test]
fn flake_is_n_fold_symmetric() {
// With 6 arms, rotating a high-alpha texel by 60° must land on
// another high-alpha texel. Probe along the +X arm axis... but the
// arm axis depends only on the fold, which starts at angle 0, so
// (r, 0) and (r·cos60°, r·sin60°) are both on arm centrelines.
let map = SnowflakeGenerator::new(single_cell())
.generate(256, 256)
.expect("generate failed");
let alpha_at = |x: f64, y: f64| -> u8 {
let px = ((x + 1.0) / 2.0 * 256.0) as usize;
let py = ((y + 1.0) / 2.0 * 256.0) as usize;
map.albedo[(py.min(255) * 256 + px.min(255)) * 4 + 3]
};
let r = 0.5;
let a0 = alpha_at(r, 0.0);
let (s, c) = (PI / 3.0).sin_cos();
let a60 = alpha_at(r * c, r * s);
assert!(a0 > 128, "arm centreline should be opaque at r=0.5");
assert!(
a0.abs_diff(a60) < 64,
"6-fold symmetry violated: alpha {a0} vs {a60}"
);
}
#[test]
fn branch_pairs_zero_is_a_bare_star() {
let config = SnowflakeConfig {
branch_pairs: 0,
..single_cell()
};
let map = SnowflakeGenerator::new(config)
.generate(64, 64)
.expect("generate failed");
assert!(map.albedo.chunks(4).any(|px| px[3] == 255));
}
#[test]
fn deterministic_for_same_seed() {
let a = SnowflakeGenerator::new(SnowflakeConfig::default())
.generate(32, 32)
.expect("generate failed");
let b = SnowflakeGenerator::new(SnowflakeConfig::default())
.generate(32, 32)
.expect("generate failed");
assert_eq!(a.albedo, b.albedo);
}
}