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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
//! Metal texture generator — brushed finish or standing-seam roof panels,
//! with optional rust weathering.
//!
//! The algorithm:
//! 1. **Brushed**: anisotropic FBM — high frequency in U (many scratches),
//! very low frequency in V (scratches run nearly horizontally).
//! 2. **StandingSeam**: sinusoidal ridge profile across V, with micro-detail
//! FBM overlay.
//! 3. A separate low-frequency FBM drives rust-patch blending: rust areas
//! receive a warm colour, raised roughness, and reduced metallic value.
use std::f64::consts::TAU;
use noise::{Fbm, MultiFractal, NoiseFn, Perlin};
use crate::{
generator::{TextureError, TextureGenerator, TextureMap, Workspace, validate_dimensions},
noise::{ToroidalNoise, normalize, sample_grid_into},
surface::{SurfaceCell, SurfaceSample, generate_surface, lerp},
};
/// Visual style of the metal surface.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum MetalStyle {
/// Fine horizontal scratches (brushed / satin finish).
Brushed,
/// Parallel raised ridges running across the tile (standing-seam roof).
StandingSeam,
/// Peened surface of round overlapping dimple depressions (hand-hammered
/// sheet). `scale` sets the dimple count across the tile.
Hammered,
/// Classic tread plate: a diamond lattice of raised oblong studs with
/// alternating orientation. `scale` sets the stud count across the tile.
DiamondPlate,
}
/// Configures the appearance of a [`MetalGenerator`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct MetalConfig {
/// PRNG seed for the deterministic noise pattern; different seeds give
/// statistically-different textures from otherwise-identical configs.
pub seed: u32,
/// Surface finish style.
pub style: MetalStyle,
/// Base noise scale.
pub scale: f64,
/// For `StandingSeam`: number of ridges across the tile.
pub seam_count: f64,
/// Ridge sharpness for `StandingSeam` \[0.5 = sinusoidal, 4.0 = sharp\].
pub seam_sharpness: f64,
/// Anisotropy factor for `Brushed` — higher = longer horizontal scratches.
pub brush_stretch: f64,
/// Micro-roughness amplitude \[0, 1\].
pub roughness: f64,
/// Metallic value for clean (rust-free) areas \[0, 1\].
pub metallic: f32,
/// Rust-patch coverage \[0 = none, 1 = heavy\].
pub rust_level: f64,
/// Base metal colour in linear RGB \[0, 1\].
pub color_metal: [f32; 3],
/// Rust colour in linear RGB \[0, 1\].
pub color_rust: [f32; 3],
/// Normal-map strength.
pub normal_strength: f32,
}
impl Default for MetalConfig {
fn default() -> Self {
Self {
seed: 31,
style: MetalStyle::Brushed,
scale: 6.0,
seam_count: 6.0,
seam_sharpness: 2.5,
brush_stretch: 8.0,
roughness: 0.25,
metallic: 0.85,
rust_level: 0.15,
color_metal: [0.42, 0.44, 0.47],
color_rust: [0.42, 0.24, 0.12],
normal_strength: 3.0,
}
}
}
/// Procedural metal texture generator.
///
/// Drives [`TextureGenerator::generate`] using a [`MetalConfig`]. Construct
/// via [`MetalGenerator::new`] and call `generate` directly, or spawn a
/// [`crate::async_gen::PendingTexture::metal`] task for non-blocking generation.
///
/// Noise objects are built in the constructor so that calling `generate`
/// multiple times (e.g. producing size variants of the same material)
/// does not repeat the initialisation cost.
pub struct MetalGenerator {
config: MetalConfig,
fbm_scratch: Fbm<Perlin>,
rust_noise: ToroidalNoise<Fbm<Perlin>>,
}
impl MetalGenerator {
/// Create a new generator with the given configuration.
///
/// Builds the noise objects up front so that repeated
/// calls to [`generate`](TextureGenerator::generate) skip initialisation.
pub fn new(config: MetalConfig) -> Self {
let fbm_scratch: Fbm<Perlin> = Fbm::new(config.seed).set_octaves(5);
let fbm_rust: Fbm<Perlin> = Fbm::new(config.seed.wrapping_add(41)).set_octaves(4);
let rust_noise = ToroidalNoise::new(fbm_rust, config.scale * 0.4);
Self {
config,
fbm_scratch,
rust_noise,
}
}
}
/// Per-generation sampler: precomputed rust grid + per-pixel anisotropic
/// scratch noise (sampled analytically — the brushed style's stretched torus
/// coordinates have no grid equivalent).
struct MetalCell<'a> {
config: &'a MetalConfig,
fbm_scratch: &'a Fbm<Perlin>,
rust_grid: &'a [f64],
width: usize,
}
impl SurfaceCell for MetalCell<'_> {
fn sample(&self, x: u32, y: u32, u: f64, v: f64) -> SurfaceSample {
let c = self.config;
// Standing-seam ridge profile (sinusoidal bumps in V).
// seam_count must be an integer for the pattern to tile; round to nearest.
let seam_count = c.seam_count.round();
let seam_h = if c.style == MetalStyle::StandingSeam {
let phase = (v * seam_count * TAU).sin();
// Raise to power to sharpen; clamp to [0,1].
phase.abs().powf(c.seam_sharpness.max(0.1)) * phase.signum() * 0.5 + 0.5
} else {
0.0
};
// Sample scratch noise.
// Brushed: large radius in U (fast oscillations → many horizontal
// scratches), small radius in V (slow → scratches run lengthwise).
// Other styles: uniform toroidal sampling for micro-detail.
let scratch = match c.style {
MetalStyle::Brushed => {
let nx = (TAU * u).cos() * c.scale * c.brush_stretch;
let ny = (TAU * u).sin() * c.scale * c.brush_stretch;
let nz = (TAU * v).cos() * c.scale * 0.12;
let nw = (TAU * v).sin() * c.scale * 0.12;
self.fbm_scratch.get([nx, ny, nz, nw]) * 0.5 + 0.5
}
MetalStyle::StandingSeam | MetalStyle::Hammered | MetalStyle::DiamondPlate => {
let nx = (TAU * u).cos() * c.scale;
let ny = (TAU * u).sin() * c.scale;
let nz = (TAU * v).cos() * c.scale;
let nw = (TAU * v).sin() * c.scale;
self.fbm_scratch.get([nx, ny, nz, nw]) * 0.5 + 0.5
}
};
let idx = y as usize * self.width + x as usize;
let rust_t = normalize(self.rust_grid[idx]);
// Soft threshold → rust coverage.
let rust_blend = ((rust_t - (1.0 - c.rust_level)).clamp(0.0, c.rust_level)
/ c.rust_level.max(1e-9))
.clamp(0.0, 1.0);
let h_scratch = scratch * c.roughness * 0.3;
let h_val = match c.style {
MetalStyle::Brushed => h_scratch,
MetalStyle::StandingSeam => seam_h * 0.7 + h_scratch * 0.3,
MetalStyle::Hammered => {
// Plateau between dimples at 1, dimple centres at 0.
let dimple = dimple_height(u, v, c.scale, c.seed);
(dimple * 0.8 + h_scratch * 0.2).clamp(0.0, 1.0)
}
MetalStyle::DiamondPlate => {
(0.35 + diamond_stud(u, v, c.scale) * 0.5 + h_scratch * 0.3).clamp(0.0, 1.0)
}
};
// Colour: lerp metal → rust.
let color = [
lerp(c.color_metal[0], c.color_rust[0], rust_blend as f32),
lerp(c.color_metal[1], c.color_rust[1], rust_blend as f32),
lerp(c.color_metal[2], c.color_rust[2], rust_blend as f32),
];
// ORM: rust raises roughness and kills metallic.
let rough = (c.roughness as f32 + rust_blend as f32 * 0.65).clamp(0.0, 1.0);
let met = (c.metallic - rust_blend as f32 * 0.80).clamp(0.0, 1.0);
SurfaceSample {
height: h_val,
color,
roughness: rough,
metallic: met,
occlusion: 1.0,
emissive: [0.0, 0.0, 0.0],
}
}
}
impl MetalGenerator {
fn generate_inner(
&self,
width: u32,
height: u32,
mut ws: Option<&mut Workspace>,
) -> Result<TextureMap, TextureError> {
validate_dimensions(width, height)?;
// Rust patches — separate seed, low frequency for large blotches.
let mut rust_grid = ws.as_deref_mut().map_or_else(Vec::new, |w| w.take_grid());
sample_grid_into(&self.rust_noise, width, height, &mut rust_grid);
let cell = MetalCell {
config: &self.config,
fbm_scratch: &self.fbm_scratch,
rust_grid: &rust_grid,
width: width as usize,
};
let result = generate_surface(
width,
height,
self.config.normal_strength,
ws.as_deref_mut(),
&cell,
);
if let Some(ws) = ws {
ws.return_grid(rust_grid);
}
result
}
}
impl TextureGenerator for MetalGenerator {
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))
}
}
// --- style helpers ------------------------------------------------------------
/// Hammered dimple field: `1` on the plateau between dimples, falling to `0`
/// at each dimple centre. A jittered toroidal point grid (one dimple per
/// cell, 3×3 neighbourhood search) keeps the pattern seamless.
fn dimple_height(u: f64, v: f64, count: f64, seed: u32) -> f64 {
let n = count.round().max(1.0);
let gi = (u * n).floor() as i64;
let gj = (v * n).floor() as i64;
let mut best = f64::MAX;
for di in -1i64..=1 {
for dj in -1i64..=1 {
let ni = (gi + di).rem_euclid(n as i64);
let nj = (gj + dj).rem_euclid(n as i64);
// Keep centres away from cell borders so neighbouring dimples
// overlap without leaving flat seams.
let jx = 0.2 + 0.6 * cell_hash(ni, nj, seed);
let jy = 0.2 + 0.6 * cell_hash(nj, ni, seed.wrapping_add(13));
let cx = (ni as f64 + jx) / n;
let cy = (nj as f64 + jy) / n;
// Toroidal distance in cell units.
let mut dx = (u - cx).abs();
if dx > 0.5 {
dx = 1.0 - dx;
}
let mut dy = (v - cy).abs();
if dy > 0.5 {
dy = 1.0 - dy;
}
best = best.min((dx * dx + dy * dy).sqrt() * n);
}
}
// Spherical cap profile: depth 1 at the centre, flat beyond ~0.75 cells.
let t = (best / 0.75).min(1.0);
1.0 - (1.0 - t * t).max(0.0)
}
/// Diamond-plate stud field: `1` on a raised stud, `0` on the base plate.
///
/// The UV plane is sheared into diagonal lattice coordinates; each lattice
/// cell carries one rounded-bar stud whose orientation alternates with cell
/// parity. Integer `scale` keeps both diagonal families tiling.
fn diamond_stud(u: f64, v: f64, scale: f64) -> f64 {
let k = scale.round().max(1.0);
let s = (u + v) * k;
let t = (u - v) * k;
let cell_s = s.floor();
let cell_t = t.floor();
let fs = s - cell_s - 0.5; // [-0.5, 0.5] within the lattice cell
let ft = t - cell_t - 0.5;
// Alternate stud orientation per cell.
let (along, across) = if ((cell_s + cell_t) as i64).rem_euclid(2) == 0 {
(fs, ft)
} else {
(ft, fs)
};
// Rounded-bar SDF: half-length 0.28, half-width via the 0.16 falloff.
let dx = (along.abs() - 0.28).max(0.0);
let dy = across.abs();
let d = (dx * dx + dy * dy).sqrt();
let stud = (1.0 - d / 0.16).clamp(0.0, 1.0);
stud * stud * (3.0 - 2.0 * stud) // smoothstep shoulder
}
/// Deterministic integer hash → \[0, 1\] for the hammered dimple jitter.
fn cell_hash(bx: i64, by: i64, seed: u32) -> f64 {
let mut h = seed as u64;
h ^= (bx as u64).wrapping_mul(6_364_136_223_846_793_005);
h ^= (by as u64).wrapping_mul(1_442_695_040_888_963_407);
h ^= h >> 33;
h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
h ^= h >> 33;
(h as f64) * (1.0 / u64::MAX as f64)
}
#[cfg(test)]
mod tests {
use super::*;
fn styled(style: MetalStyle) -> MetalConfig {
MetalConfig {
style,
..MetalConfig::default()
}
}
#[test]
fn all_styles_generate() {
for style in [
MetalStyle::Brushed,
MetalStyle::StandingSeam,
MetalStyle::Hammered,
MetalStyle::DiamondPlate,
] {
let map = MetalGenerator::new(styled(style))
.generate(32, 32)
.expect("generate failed");
assert_eq!(map.albedo.len(), 32 * 32 * 4);
}
}
#[test]
fn new_styles_differ_from_brushed() {
let brushed = MetalGenerator::new(styled(MetalStyle::Brushed))
.generate(64, 64)
.expect("generate failed");
for style in [MetalStyle::Hammered, MetalStyle::DiamondPlate] {
let other = MetalGenerator::new(styled(style.clone()))
.generate(64, 64)
.expect("generate failed");
assert_ne!(
brushed.normal, other.normal,
"{style:?} must shape the surface differently from Brushed"
);
}
}
#[test]
fn dimple_and_stud_fields_tile() {
for x in [0.0, 0.25, 0.75] {
assert!((dimple_height(0.0, x, 6.0, 7) - dimple_height(1.0, x, 6.0, 7)).abs() < 1e-12);
assert!((dimple_height(x, 0.0, 6.0, 7) - dimple_height(x, 1.0, 6.0, 7)).abs() < 1e-12);
assert!((diamond_stud(0.0, x, 6.0) - diamond_stud(1.0, x, 6.0)).abs() < 1e-12);
assert!((diamond_stud(x, 0.0, 6.0) - diamond_stud(x, 1.0, 6.0)).abs() < 1e-12);
}
}
}