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
//! Spark / star sprite generator.
//!
//! An N-pointed streak burst: a bright core with radial arms that fade
//! toward their tips. Reads as embers, glints, impact sparks, or magic
//! sparkles depending on palette and arm count. Per-variant cells jitter
//! the rotation phase and individual arm lengths.
//!
//! 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, lerp_color},
};
/// Hard cap on the arm count; also the size of the per-cell arm-length
/// table. Twelve arms at typical sprite resolutions is already past the
/// point where individual streaks resolve.
const MAX_POINTS: usize = 12;
/// Configures the appearance of a [`SparkGenerator`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SparkConfig {
/// 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,
/// Number of streak arms `[2, 12]`.
pub points: usize,
/// Colour of the central glow in linear RGB \[0, 1\].
pub color_core: [f32; 3],
/// Colour toward the arm tips in linear RGB \[0, 1\].
pub color_tip: [f32; 3],
/// Radius of the solid central glow as a fraction of the cell
/// half-extent `[0.02, 0.5]`.
pub core_radius: f64,
/// Angular tightness of each arm `[0.5, 10]`. Higher → needle-thin
/// streaks; lower → fat lobes that merge into a star polygon.
pub arm_sharpness: f64,
/// Radial fade exponent along each arm `[0.5, 6]`.
pub falloff: f64,
/// Per-arm length jitter `[0, 0.8]`: each arm is shortened by a random
/// fraction up to this value, per variant cell.
pub length_jitter: f64,
/// Normal map strength.
pub normal_strength: f32,
}
impl Default for SparkConfig {
fn default() -> Self {
Self {
seed: 0,
variant_rows: 2,
variant_cols: 2,
points: 4,
color_core: [1.0, 0.95, 0.8],
color_tip: [1.0, 0.45, 0.1],
core_radius: 0.12,
arm_sharpness: 3.0,
falloff: 1.8,
length_jitter: 0.3,
normal_strength: 1.0,
}
}
}
/// One baked spark variant: rotation phase plus a per-arm length table.
struct SparkCell {
config: SparkConfig,
points: usize,
phase: f64,
arm_len: [f64; MAX_POINTS],
}
impl SparkCell {
fn new(config: &SparkConfig, cell: usize) -> Self {
let mut rng = CellRng::new(config.seed, cell);
let points = config.points.clamp(2, MAX_POINTS);
let jitter = config.length_jitter.clamp(0.0, 0.8);
let mut arm_len = [0.0; MAX_POINTS];
for slot in arm_len.iter_mut().take(points) {
// 0.96: same clip-safety margin as the soft disc.
*slot = 0.96 * (1.0 - rng.next_f64() * jitter);
}
Self {
config: config.clone(),
points,
phase: rng.range(0.0, TAU),
arm_len,
}
}
}
impl SpriteCell for SparkCell {
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();
let theta = dy.atan2(dx);
// Position within the arm fan: integer part selects the nearest
// arm, fractional distance (0 at an arm centre, 1 halfway between
// arms) drives the angular profile.
let fan = (theta - self.phase) / TAU * self.points as f64;
let nearest = fan.round();
let angular = (fan - nearest).abs() * 2.0; // [0, 1]
let arm = (nearest.rem_euclid(self.points as f64)) as usize % self.points;
let core = c.core_radius.clamp(0.02, 0.5);
let sharpness = c.arm_sharpness.clamp(0.5, 10.0);
let falloff = c.falloff.clamp(0.5, 6.0);
// Envelope radius at this angle: core everywhere, stretching to the
// arm length at the arm centreline.
let profile = (1.0 - angular).powf(sharpness);
let envelope = core + (self.arm_len[arm] - core) * profile;
// Radial fade inside the envelope plus a soft solid core so the
// centre never shows the angular structure.
let arm_alpha = if r < envelope {
(1.0 - r / envelope).powf(falloff)
} else {
0.0
};
let core_alpha = (1.0 - r / core).clamp(0.0, 1.0);
let alpha = arm_alpha.max(core_alpha * core_alpha);
let core_blend = (alpha * alpha) as f32;
SpriteSample {
color: lerp_color(c.color_tip, c.color_core, core_blend),
alpha,
height: alpha,
roughness: 0.9,
}
}
}
/// Procedural spark / star sprite generator.
///
/// See the [module documentation](self) for the visual model.
pub struct SparkGenerator {
config: SparkConfig,
}
impl SparkGenerator {
/// Create a new generator with the given configuration.
pub fn new(config: SparkConfig) -> Self {
Self { config }
}
}
impl TextureGenerator for SparkGenerator {
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| SparkCell::new(c, cell),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn single_cell(points: usize) -> SparkConfig {
SparkConfig {
variant_rows: 1,
variant_cols: 1,
points,
length_jitter: 0.0,
..SparkConfig::default()
}
}
#[test]
fn generator_produces_correct_buffer_sizes() {
let map = SparkGenerator::new(SparkConfig::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 centre_is_opaque_with_transparent_surround() {
let map = SparkGenerator::new(single_cell(4))
.generate(64, 64)
.expect("generate failed");
let centre = (32 * 64 + 32) * 4;
assert!(map.albedo[centre + 3] > 200, "core should be near-opaque");
assert!(
map.albedo.chunks(4).any(|px| px[3] == 0),
"spark must have fully transparent texels"
);
}
#[test]
fn arms_extend_past_the_core() {
// With phase jitter disabled per-arm coverage is hard to probe at a
// fixed texel, so measure instead: an opaque-ish texel must exist
// outside the core radius.
let map = SparkGenerator::new(single_cell(4))
.generate(128, 128)
.expect("generate failed");
let core_px = (0.12 * 64.0) as i32; // core_radius × half-size
let found = map.albedo.chunks(4).enumerate().any(|(i, px)| {
let x = (i % 128) as i32 - 64;
let y = (i / 128) as i32 - 64;
px[3] > 100 && (x * x + y * y) > (core_px * 3) * (core_px * 3)
});
assert!(found, "streaks should reach well beyond the core");
}
#[test]
fn point_count_is_clamped() {
// 100 points clamps to MAX_POINTS without panicking on the arm table.
let map = SparkGenerator::new(single_cell(100))
.generate(32, 32)
.expect("generate failed");
assert_eq!(map.albedo.len(), 32 * 32 * 4);
}
#[test]
fn deterministic_for_same_seed() {
let a = SparkGenerator::new(SparkConfig::default())
.generate(32, 32)
.expect("generate failed");
let b = SparkGenerator::new(SparkConfig::default())
.generate(32, 32)
.expect("generate failed");
assert_eq!(a.albedo, b.albedo);
}
}