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
//! Lava texture generator — basalt crust with emissive cracks.
//!
//! Cooling lava: dark basalt plates from a toroidal Voronoi decomposition
//! (domed `F1` plates, like cobblestone) separated by molten cracks where
//! the `F2 − F1` boundary field collapses. The crack field drives the
//! crate's **emissive channel**: the glow colour ramps with crack depth and
//! is written into [`TextureMap::emissive`], which the polling systems
//! assign to `StandardMaterial::emissive_texture`. Bevy multiplies that
//! texture by the material's emissive colour factor, which the material flow
//! auto-defaults to white when you leave `MaterialSettings::emission_color` /
//! `emission_strength` unset — so the glow shows out of the box.
//!
//! The albedo carries a faint heat tint near the cracks so the material
//! still reads as hot without bloom.
//!
//! [`TextureMap::emissive`]: crate::generator::TextureMap::emissive
use noise::{Fbm, MultiFractal, Perlin};
use crate::{
generator::{TextureError, TextureGenerator, TextureMap, Workspace, validate_dimensions},
noise::{ToroidalNoise, cell_hash, normalize, sample_grid_into, toroidal_voronoi},
surface::{SurfaceCell, SurfaceSample, generate_surface_emissive, lerp},
};
/// Configures the appearance of a [`LavaGenerator`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct LavaConfig {
/// PRNG seed for the deterministic noise pattern; different seeds give
/// statistically-different textures from otherwise-identical configs.
pub seed: u32,
/// Approximate number of crust plates across the tile `[3, 12]`.
pub plate_scale: f64,
/// Molten-crack width as a fraction of plate spacing `[0.02, 0.3]`.
pub crack_width: f64,
/// Glow falloff exponent `[0.5, 4]` — higher concentrates the glow into
/// the crack centres.
pub glow_falloff: f64,
/// Cooled basalt crust colour in linear RGB \[0, 1\].
pub color_crust: [f32; 3],
/// Molten glow colour in linear RGB \[0, 1\] — written to the emissive
/// map at full crack depth.
pub color_glow: [f32; 3],
/// Emissive intensity multiplier `[0, 4]` applied to the glow colour
/// before it is encoded into the emissive map.
pub emissive_intensity: f32,
/// Normal-map strength.
pub normal_strength: f32,
}
impl Default for LavaConfig {
fn default() -> Self {
Self {
seed: 666,
plate_scale: 6.0,
crack_width: 0.14,
glow_falloff: 1.6,
color_crust: [0.08, 0.07, 0.07],
color_glow: [1.0, 0.45, 0.06],
emissive_intensity: 1.0,
normal_strength: 4.0,
}
}
}
/// Procedural lava texture generator.
///
/// Drives [`TextureGenerator::generate`] using a [`LavaConfig`]. Construct
/// via [`LavaGenerator::new`] and call `generate` directly, or spawn a
/// [`crate::async_gen::PendingTexture::lava`] task for non-blocking generation.
pub struct LavaGenerator {
config: LavaConfig,
surf_noise: ToroidalNoise<Fbm<Perlin>>,
}
impl LavaGenerator {
/// Create a new generator with the given configuration.
pub fn new(config: LavaConfig) -> Self {
let fbm_surf: Fbm<Perlin> = Fbm::new(config.seed.wrapping_add(50)).set_octaves(4);
let surf_noise = ToroidalNoise::new(fbm_surf, config.plate_scale * 2.0);
Self { config, surf_noise }
}
fn generate_inner(
&self,
width: u32,
height: u32,
mut ws: Option<&mut Workspace>,
) -> Result<TextureMap, TextureError> {
validate_dimensions(width, height)?;
let c = &self.config;
let mut surf_grid = ws.as_deref_mut().map_or_else(Vec::new, |w| w.take_grid());
sample_grid_into(&self.surf_noise, width, height, &mut surf_grid);
let scale = c.plate_scale.round().clamp(2.0, 16.0);
let cell = LavaCell {
config: c,
surf_grid: &surf_grid,
scale,
crack_threshold: c.crack_width.clamp(0.02, 0.3) / scale,
width: width as usize,
};
let result =
generate_surface_emissive(width, height, c.normal_strength, ws.as_deref_mut(), &cell);
if let Some(ws) = ws {
ws.return_grid(surf_grid);
}
result
}
}
impl TextureGenerator for LavaGenerator {
fn generate(&self, width: u32, height: u32) -> Result<TextureMap, TextureError> {
self.generate_inner(width, height, None)
}
fn generate_with_workspace(
&self,
width: u32,
height: u32,
workspace: &mut Workspace,
) -> Result<TextureMap, TextureError> {
self.generate_inner(width, height, Some(workspace))
}
}
/// Per-generation sampler: crust micro-detail grid + Voronoi constants.
struct LavaCell<'a> {
config: &'a LavaConfig,
surf_grid: &'a [f64],
/// `plate_scale` rounded so the Voronoi lattice tiles.
scale: f64,
/// `F2 − F1` below this is molten crack.
crack_threshold: f64,
width: usize,
}
impl SurfaceCell for LavaCell<'_> {
fn sample(&self, x: u32, y: u32, u: f64, v: f64) -> SurfaceSample {
let c = self.config;
let idx = y as usize * self.width + x as usize;
let micro = normalize(self.surf_grid[idx]);
let (f1, f2, ci, cj) = toroidal_voronoi(u, v, self.scale, c.seed);
let gap = f2 - f1;
// Glow: 1 at the crack centre, fading into the plates.
let glow = ((1.0 - gap / (self.crack_threshold * 2.0)).clamp(0.0, 1.0))
.powf(c.glow_falloff.clamp(0.5, 4.0));
let molten = gap < self.crack_threshold;
// Plates dome gently; cracks sink.
let dome = (1.0 - (f1 * self.scale).powf(1.3)).clamp(0.0, 1.0);
let height = if molten {
0.12 + micro * 0.04
} else {
(0.30 + dome * 0.55 + micro * 0.10).clamp(0.0, 1.0)
};
// Albedo: dark basalt with per-plate variance and a faint heat tint
// toward the cracks; the molten channel itself shows the glow
// colour dimmed (the emissive map carries the actual light).
let plate_jitter = ((cell_hash(ci, cj, c.seed.wrapping_add(99)) - 0.5) * 0.10) as f32;
let heat = (glow * 0.45) as f32;
let color = [
(lerp(
c.color_crust[0] + plate_jitter,
c.color_glow[0] * 0.55,
heat,
))
.clamp(0.0, 1.0),
(lerp(
c.color_crust[1] + plate_jitter,
c.color_glow[1] * 0.55,
heat,
))
.clamp(0.0, 1.0),
(lerp(
c.color_crust[2] + plate_jitter,
c.color_glow[2] * 0.55,
heat,
))
.clamp(0.0, 1.0),
];
// Molten rock is glassy; crust is rough and dusty.
let rough = if molten {
(0.18 - glow as f32 * 0.08).clamp(0.05, 1.0)
} else {
(0.85 + (micro as f32 - 0.5) * 0.1).clamp(0.0, 1.0)
};
// Emissive: glow colour scaled by intensity, strongest in cracks
// and flickering slightly with the micro noise.
let intensity = c.emissive_intensity.clamp(0.0, 4.0);
let e = (glow as f32) * intensity * (0.85 + micro as f32 * 0.3);
let emissive = [
(c.color_glow[0] * e).clamp(0.0, 1.0),
(c.color_glow[1] * e).clamp(0.0, 1.0),
(c.color_glow[2] * e).clamp(0.0, 1.0),
];
SurfaceSample {
height,
color,
roughness: rough,
metallic: 0.0,
occlusion: 1.0,
emissive,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generator_produces_emissive_map() {
let map = LavaGenerator::new(LavaConfig::default())
.generate(64, 64)
.expect("generate failed");
let emissive = map.emissive.as_ref().expect("lava must emit");
assert_eq!(emissive.len(), 64 * 64 * 4);
// Cracks glow, plate interiors stay dark.
let lit = emissive.chunks(4).filter(|px| px[0] > 120).count();
let dark = emissive.chunks(4).filter(|px| px[0] < 20).count();
assert!(lit > 0, "cracks should glow in the emissive map");
assert!(dark > lit, "plate interiors should dominate and stay dark");
}
#[test]
fn molten_cracks_are_glassy_against_rough_crust() {
let map = LavaGenerator::new(LavaConfig::default())
.generate(128, 128)
.expect("generate failed");
let glassy = map.roughness.chunks(4).filter(|px| px[1] < 60).count();
let crusty = map.roughness.chunks(4).filter(|px| px[1] > 180).count();
assert!(glassy > 0, "molten channels should be glassy");
assert!(crusty > glassy, "basalt crust should dominate");
}
#[test]
fn deterministic_for_same_seed() {
let a = LavaGenerator::new(LavaConfig::default())
.generate(32, 32)
.expect("generate failed");
let b = LavaGenerator::new(LavaConfig::default())
.generate(32, 32)
.expect("generate failed");
assert_eq!(a.albedo, b.albedo);
assert_eq!(a.emissive, b.emissive);
}
}