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
//! Window texture generator using 2-D signed distance functions (SDF).
//!
//! The algorithm:
//! 1. Compute a plain rectangular outer silhouette (full card) and a
//! rounded-box SDF for the inner glass opening.
//! 2. Classify each pixel as frame, mullion, or glass.
//! 3. Subdivide the glass region into `panes_x × panes_y` panes separated by
//! mullions using fractional UV within the inner glass area.
//! 4. Add FBM grime noise to the glass surface and roughness map.
//! 5. Produce an alpha-masked card (clamp-to-edge sampler via `map_to_images_card`).
use noise::{Fbm, MultiFractal, NoiseFn, Perlin};
use crate::{
generator::{TextureError, TextureGenerator, TextureMap, linear_to_srgb, validate_dimensions},
normal::{BoundaryMode, dilate_heights, height_to_normal},
};
/// Configures the appearance of a [`WindowGenerator`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct WindowConfig {
/// PRNG seed for the deterministic noise pattern; different seeds give
/// statistically-different textures from otherwise-identical configs.
pub seed: u32,
/// Frame width as a fraction of the card \[0, 0.4\].
pub frame_width: f64,
/// Number of panes in the horizontal direction.
pub panes_x: usize,
/// Number of panes in the vertical direction.
pub panes_y: usize,
/// Mullion/muntin thickness as a fraction of the glass area \[0, 0.2\].
pub mullion_thickness: f64,
/// Inner (glass-opening) corner-rounding radius as a fraction of the card \[0, 0.4\].
/// The outer silhouette is always a plain rectangle.
pub corner_radius: f64,
/// Glass opacity \[0 = clear, 1 = frosted/opaque\].
pub glass_opacity: f64,
/// Grime/dirt noise intensity on glass \[0, 1\].
pub grime_level: f64,
/// Frame and mullion colour in linear RGB \[0, 1\].
pub color_frame: [f32; 3],
/// Normal-map strength.
pub normal_strength: f32,
}
impl Default for WindowConfig {
fn default() -> Self {
Self {
seed: 42,
frame_width: 0.08,
panes_x: 2,
panes_y: 3,
mullion_thickness: 0.025,
corner_radius: 0.02,
glass_opacity: 0.30,
grime_level: 0.15,
color_frame: [0.85, 0.82, 0.78],
normal_strength: 3.0,
}
}
}
/// Procedural window / glazing texture generator (foliage-card type).
///
/// Drives [`TextureGenerator::generate`] using a [`WindowConfig`]. Construct
/// via [`WindowGenerator::new`] and call `generate` directly, or spawn a
/// [`crate::async_gen::PendingTexture::window`] task for non-blocking generation.
///
/// The result has per-pixel alpha: transparent outside the frame, semi-transparent
/// glass, and opaque frame/mullions.
///
/// 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 WindowGenerator {
config: WindowConfig,
grime_fbm: Fbm<Perlin>,
}
impl WindowGenerator {
/// 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: WindowConfig) -> Self {
let grime_fbm: Fbm<Perlin> = Fbm::new(config.seed).set_octaves(6);
Self { config, grime_fbm }
}
}
impl TextureGenerator for WindowGenerator {
fn generate(&self, width: u32, height: u32) -> Result<TextureMap, TextureError> {
validate_dimensions(width, height)?;
let c = &self.config;
let w = width as usize;
let h = height as usize;
let n = w * h;
// Inner half-extent = outer half-extent (0.5) minus frame.
let inner_half = 0.5 - c.frame_width;
// Inner corner radius: keep at least a small value so the SDF is well-formed.
// The outer silhouette is a plain rectangle — only the inner opening is rounded.
let inner_r = c.corner_radius.min(inner_half * 0.9).max(0.005);
// Glass area extent in UV for pane subdivision.
let glass_span = inner_half * 2.0; // UV span of glass region (centered)
let glass_origin = 0.5 - inner_half; // UV origin of glass region
let mullion_half = (c.mullion_thickness * 0.5).min(0.49);
let panes_x = c.panes_x.max(1);
let panes_y = c.panes_y.max(1);
let mut heights = vec![0.0f64; n];
let mut albedo = vec![0u8; n * 4];
let mut roughness_buf = vec![0u8; n * 4];
for y in 0..h {
let v = y as f64 / h as f64;
let py = v - 0.5; // centered in [-0.5, 0.5]
for x in 0..w {
let u = x as f64 / w as f64;
let px = u - 0.5;
// The outer silhouette (r=0, half-extents 0.5) covers every pixel
// of the card: px,py ∈ [-0.5,0.5] so outer_sdf ≤ 0 always.
let inner_sdf =
sdf_rounded_box(px, py, inner_half - inner_r, inner_half - inner_r, inner_r);
let idx = y * w + x;
let ai = idx * 4;
if inner_sdf > 0.0 {
// Frame band between outer and inner SDF.
// Height ramps from 0 at the outer edge inward to 1 deep in the frame.
// Distance from the nearest outer edge: min(0.5-|px|, 0.5-|py|).
let edge_dist = (0.5 - px.abs()).min(0.5 - py.abs());
let edge_t = (edge_dist / (c.frame_width + 0.005)).clamp(0.0, 1.0);
heights[idx] = edge_t;
albedo[ai] = linear_to_srgb(c.color_frame[0]);
albedo[ai + 1] = linear_to_srgb(c.color_frame[1]);
albedo[ai + 2] = linear_to_srgb(c.color_frame[2]);
albedo[ai + 3] = 255;
roughness_buf[ai] = 255;
roughness_buf[ai + 1] = (0.75 * 255.0) as u8; // rough wood/paint
roughness_buf[ai + 2] = 0;
roughness_buf[ai + 3] = 255;
} else {
// Inside glass area. Check for mullions.
// Map pixel to glass-local UV in [0, 1].
let gu = ((u - glass_origin) / glass_span).clamp(0.0, 1.0);
let gv = ((v - glass_origin) / glass_span).clamp(0.0, 1.0);
// Fractional position within each pane.
let pu = (gu * panes_x as f64).fract();
let pv = (gv * panes_y as f64).fract();
// Mullion half-width scaled to pane-local coordinates so that
// internal mullions have the same physical thickness regardless
// of how many panes there are.
let mhx = mullion_half * panes_x as f64;
let mhy = mullion_half * panes_y as f64;
// Check if we're on an internal mullion line.
// The outer boundary lines coincide with the frame so panes_x=1 never
// produces spurious mullions inside the glass.
let is_mullion_x = panes_x > 1 && (pu < mhx || pu > 1.0 - mhx);
let is_mullion_y = panes_y > 1 && (pv < mhy || pv > 1.0 - mhy);
// For single-pane axis, still suppress the outer boundary band
// using the same thickness as internal mullions (in glass-UV).
let at_x_border = gu < mullion_half || gu > 1.0 - mullion_half;
let at_y_border = gv < mullion_half || gv > 1.0 - mullion_half;
if is_mullion_x || is_mullion_y || at_x_border || at_y_border {
// Mullion — treat like frame.
heights[idx] = 1.0;
albedo[ai] = linear_to_srgb(c.color_frame[0]);
albedo[ai + 1] = linear_to_srgb(c.color_frame[1]);
albedo[ai + 2] = linear_to_srgb(c.color_frame[2]);
albedo[ai + 3] = 255;
roughness_buf[ai] = 255;
roughness_buf[ai + 1] = (0.75 * 255.0) as u8;
roughness_buf[ai + 2] = 0;
roughness_buf[ai + 3] = 255;
} else {
// Glass pane.
let grime_raw = self.grime_fbm.get([u * 8.0, v * 8.0]) * 0.5 + 0.5;
let grime = grime_raw * c.grime_level;
heights[idx] = grime * 0.08;
// Light blue-grey glass tint, darkened slightly by grime.
let gr = (0.82 - grime as f32 * 0.3).clamp(0.0, 1.0);
let gg = (0.88 - grime as f32 * 0.2).clamp(0.0, 1.0);
let gb = (0.93 - grime as f32 * 0.1).clamp(0.0, 1.0);
let alpha = (c.glass_opacity * 255.0).round() as u8;
albedo[ai] = linear_to_srgb(gr);
albedo[ai + 1] = linear_to_srgb(gg);
albedo[ai + 2] = linear_to_srgb(gb);
albedo[ai + 3] = alpha;
// Glass: low roughness, high metallic (simulates reflection).
let glass_rough = (0.05 + grime as f32 * 0.3).clamp(0.0, 1.0);
roughness_buf[ai] = 255;
roughness_buf[ai + 1] = (glass_rough * 255.0).round() as u8;
roughness_buf[ai + 2] = (0.85 * 255.0) as u8; // metallic
roughness_buf[ai + 3] = 255;
}
}
}
}
// Fill transparent pixels' heights from opaque neighbours so the normal
// map doesn't produce hard silhouette cliffs.
dilate_heights(&mut heights, &albedo, w, h);
let normal = height_to_normal(
&heights,
width,
height,
c.normal_strength,
BoundaryMode::Clamp,
);
Ok(TextureMap {
albedo,
normal,
roughness: roughness_buf,
width,
height,
mip_level_count: 1,
emissive: None,
})
}
}
// --- helpers ----------------------------------------------------------------
/// Signed distance to a rounded rectangle centred at the origin.
/// Negative inside, positive outside.
/// `bx`, `by` are inner half-extents (before rounding); `r` is the corner radius.
#[inline]
fn sdf_rounded_box(px: f64, py: f64, bx: f64, by: f64, r: f64) -> f64 {
let dx = px.abs() - bx;
let dy = py.abs() - by;
let outside = (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt();
let inside = dx.max(dy).min(0.0);
outside + inside - r
}