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
390
391
392
393
394
use std::sync::Arc;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub enum MaterialType {
Pbr,
Unlit,
/// Lighting already baked into the vertex colour, plus the sun's shadow — a static level, a
/// lightmapped world, anything authored lit. Skips the G-buffer and the point lights: one
/// forward draw per batch instead of eleven, and it still casts into and receives from the
/// directional cascades.
BakedLit,
Skybox,
/// A painted backdrop — the scene's OWN sky/panorama geometry, drawn from its own texture
/// and vertex colour behind everything else.
///
/// Three properties travel together and cannot be set apart: drawn before the world,
/// locked to the camera (translation removed, rotation kept), and never writing depth. See
/// [`crate::backdrop`] for where each is enforced and why this is a material type rather
/// than a set of knobs on [`Material`].
///
/// The difference from [`Skybox`](Self::Skybox) is what reaches the screen: `Skybox`
/// ignores the mesh entirely and generates an atmospheric gradient from the sun colour,
/// which is right for "there is no sky asset" and wrong for "here is the sky asset".
Backdrop,
/// A painted backdrop that stays **where it was authored**.
///
/// Everything [`Backdrop`](Self::Backdrop) is — drawn before the world, never writing depth,
/// its own texture and vertex colour — minus the camera lock. It exists because the lock is
/// right for exactly one authoring convention and wrong for the other, and the two are not
/// distinguishable from inside the renderer:
///
/// - A backdrop authored as a **shell around the origin** (a sky dome, a cube) is meant to
/// follow the viewer. That is [`Backdrop`](Self::Backdrop).
/// - A backdrop authored as **distant geometry placed in the level** — a panorama ring at the
/// edge of the map, silhouette panels on the horizon, a matte painting hung behind a set —
/// is meant to stay put and to be approached, parallaxed and passed. Locking it to the
/// camera drags a kilometre-wide panel onto the lens. That is this.
///
/// The depth treatment is the same and is what makes both safe: pinned to the far plane, so
/// whatever is in front wins, however near the panel physically is.
BackdropPlaced,
Water,
Grid,
}
#[derive(Clone)]
pub struct Material {
pub bind_group: Arc<wgpu::BindGroup>,
pub albedo: gizmo_math::Vec4,
pub roughness: f32,
pub metallic: f32,
pub anisotropy: f32,
pub clear_coat: f32,
pub subsurface: f32,
/// Light that reaches the surface no matter where the sun is (linear RGB).
///
/// Read by [`MaterialType::BakedLit`], which is otherwise a bare multiply chain — vertex
/// colour × instance albedo × texture — with nothing that can lift content authored dark.
/// It is added to the baked term BEFORE the albedo/texture multiply, so a lifted surface
/// still shows its own colour instead of washing toward grey:
///
/// ```text
/// rgb = (baked · shadow + ambient) · albedo · texture + emissive
/// ```
///
/// The units are the same linear HDR the rest of the pipeline works in, ahead of exposure
/// and the ACES curve — and that curve has a steep toe (`aces(x) ≈ 0.214·x` as `x → 0`),
/// so lifting a near-black scene takes more here than the arithmetic suggests. Defaults to
/// zero, which reproduces the previous shading bit-for-bit.
///
/// Not read by the PBR path, which gets its ambient from the environment/IBL instead.
pub ambient: gizmo_math::Vec3,
/// Light the surface emits itself (linear RGB), added AFTER the albedo/texture multiply so
/// a black surface can still glow — the same relationship glTF's `emissiveFactor` has to
/// base colour.
///
/// Read by [`MaterialType::BakedLit`]. Defaults to zero. (The textured-PBR path has its
/// own emissive, from the glTF material's `emissiveFactor` + emissive map; this knob does
/// not touch it.)
pub emissive: gizmo_math::Vec3,
pub texture_source: Option<String>,
pub material_type: MaterialType,
pub is_transparent: bool,
pub is_double_sided: bool,
}
impl Material {
pub fn new(bind_group: Arc<wgpu::BindGroup>) -> Self {
Self {
bind_group,
albedo: gizmo_math::Vec4::new(1.0, 1.0, 1.0, 1.0),
roughness: 0.5,
metallic: 0.0,
anisotropy: 0.0,
clear_coat: 0.0,
subsurface: 0.0,
// Zero: the neutral element of both terms, so a material built today shades
// exactly as it did before these fields existed.
ambient: gizmo_math::Vec3::ZERO,
emissive: gizmo_math::Vec3::ZERO,
texture_source: None,
material_type: MaterialType::Pbr,
is_transparent: false,
is_double_sided: false,
}
}
/// PBR materyali olarak yapılandırır.
/// Not: Eğer `albedo.w < 1.0` verilirse `is_transparent` otomatik olarak `true` yapılır.
/// `roughness` ve `metallic` değerleri [0.0, 1.0] aralığına sınırlandırılır.
pub fn with_pbr(mut self, albedo: gizmo_math::Vec4, roughness: f32, metallic: f32) -> Self {
self.albedo = albedo;
self.roughness = roughness.clamp(0.0, 1.0);
self.metallic = metallic.clamp(0.0, 1.0);
self.material_type = MaterialType::Pbr;
if albedo.w < 1.0 {
self.is_transparent = true;
}
self
}
pub fn with_anisotropy(mut self, anisotropy: f32) -> Self {
self.anisotropy = anisotropy.clamp(0.0, 1.0);
self
}
pub fn with_clear_coat(mut self, clear_coat: f32) -> Self {
self.clear_coat = clear_coat.clamp(0.0, 1.0);
self
}
pub fn with_subsurface(mut self, subsurface: f32) -> Self {
self.subsurface = subsurface.clamp(0.0, 1.0);
self
}
/// Saydamlığı manuel olarak belirler.
/// Uyarı: `with_pbr`, `with_unlit` veya `with_water` metodları albedo'nun alpha değerine (w) bakarak
/// saydamlığı otomatik değiştirebilir. Kesin bir saydamlık istiyorsanız, bu metodu builder zincirinin en sonunda çağırın.
pub fn with_transparent(mut self, transparent: bool) -> Self {
self.is_transparent = transparent;
self
}
pub fn with_double_sided(mut self, double_sided: bool) -> Self {
self.is_double_sided = double_sided;
self
}
/// Vertex renginde pişmiş ışığı kullanan, güneşin gölgesini alan materyal.
///
/// `with_unlit`'ten farkı tek şey: gölge. Işık zaten vertex renginde olduğu için PBR'a gerek
/// yok, ama dünyanın önünde duran dinamik nesnelerin gölgesi dosyada olamaz.
///
/// Karanlık sahneleri açmak için [`with_ambient`](Self::with_ambient) ve
/// [`with_emissive`](Self::with_emissive); ikisi de varsayılan olarak sıfırdır.
pub fn with_baked_lit(mut self, albedo: gizmo_math::Vec4) -> Self {
self.albedo = albedo;
self.material_type = MaterialType::BakedLit;
if albedo.w < 1.0 {
self.is_transparent = true;
}
self
}
/// Sets [`Material::ambient`] — the light that reaches the surface with the sun out of the
/// picture. Negative components are clamped to zero (an ambient that subtracts light is
/// always a bug, and it would make the shader's `lit + ambient` go negative).
///
/// Only [`MaterialType::BakedLit`] reads it; see the field for the exact expression.
///
/// Yüzeye güneşten bağımsız ulaşan ışık. Karanlık pişmiş sahneleri açmanın yolu.
pub fn with_ambient(mut self, ambient: gizmo_math::Vec3) -> Self {
self.ambient = ambient.max(gizmo_math::Vec3::ZERO);
self
}
/// Sets [`Material::emissive`] — light the surface emits itself, independent of its albedo
/// and of the sun's shadow. Negative components are clamped to zero.
///
/// Only [`MaterialType::BakedLit`] reads it; see the field for the exact expression.
///
/// Yüzeyin kendi yaydığı ışık; albedo'dan ve gölgeden bağımsızdır.
pub fn with_emissive(mut self, emissive: gizmo_math::Vec3) -> Self {
self.emissive = emissive.max(gizmo_math::Vec3::ZERO);
self
}
/// Işıklandırmadan etkilenmeyen (Unlit) materyal olarak yapılandırır.
/// Not: Eğer `albedo.w < 1.0` verilirse `is_transparent` otomatik olarak `true` yapılır.
pub fn with_unlit(mut self, albedo: gizmo_math::Vec4) -> Self {
self.albedo = albedo;
self.material_type = MaterialType::Unlit;
if albedo.w < 1.0 {
self.is_transparent = true;
}
self
}
pub fn with_skybox(mut self) -> Self {
self.material_type = MaterialType::Skybox;
self
}
/// Configures this material as a painted backdrop: the mesh's own texture and vertex
/// colour, drawn before the world, locked to the camera and writing no depth. See
/// [`MaterialType::Backdrop`].
///
/// `albedo` is a tint multiplied into every pixel; pass [`Vec4::ONE`](gizmo_math::Vec4::ONE)
/// to get the artwork unmodified. Unlike the other builders this does NOT flip
/// `is_transparent` on a sub-1.0 alpha, because it does not need to: the backdrop pipeline
/// always alpha-blends, and `is_transparent` would additionally move the draw into the
/// transparent bucket — which is exactly where a backdrop must not be.
///
/// Boyalı arka plan (gökyüzü/panorama): mesh'in kendi dokusu ve vertex rengi, dünyadan
/// önce, kameraya kilitli, derinlik yazmadan çizilir.
pub fn with_backdrop(mut self, albedo: gizmo_math::Vec4) -> Self {
self.albedo = albedo;
self.material_type = MaterialType::Backdrop;
self
}
/// Configures this material as a painted backdrop that stays where it was authored — see
/// [`MaterialType::BackdropPlaced`].
///
/// Same three-line contract as [`with_backdrop`](Self::with_backdrop) — drawn before the
/// world, its own pixels, no depth write — except that the geometry keeps its place instead
/// of following the camera. Reach for it when the backdrop is *in* the level rather than
/// *around* the viewer.
///
/// Kameraya kilitlenmeyen boyalı arka plan: dünyada durduğu yerde çizilir.
pub fn with_backdrop_placed(mut self, albedo: gizmo_math::Vec4) -> Self {
self.albedo = albedo;
self.material_type = MaterialType::BackdropPlaced;
self
}
/// Su materyali olarak yapılandırır.
/// `roughness` 0.05, `metallic` 0.0 olarak varsayılan su değerlerine ayarlanır.
/// Not: Eğer `base_albedo.w < 1.0` verilirse `is_transparent` otomatik olarak `true` yapılır.
pub fn with_water(mut self, base_albedo: gizmo_math::Vec4) -> Self {
self.albedo = base_albedo;
self.roughness = 0.05;
self.metallic = 0.0;
self.material_type = MaterialType::Water;
if base_albedo.w < 1.0 {
self.is_transparent = true;
}
self
}
pub fn with_texture_source(mut self, path: String) -> Self {
self.texture_source = Some(path);
self
}
}
#[cfg(test)]
mod baked_lit_shading_tests {
//! What `BakedLit` computes, checked as arithmetic.
//!
//! The effect of these knobs is a picture, and this crate can neither open a surface nor
//! read a pixel — so nothing here claims the scene looks right. What IS checked is the
//! expression the fragment shader evaluates, mirrored below: that the knobs default to
//! inert (the bit-identical promise), and that when set they can lift content the bare
//! multiply chain has no way to lift. `Material` itself owns a `wgpu::BindGroup` and so
//! cannot be built without a device; these take the field values directly.
/// Mirror of the tail of `fs_main` in `shaders/baked_lit.wgsl`:
///
/// ```text
/// lit = baked · (1 − sun_share + sun_share · vis)
/// colour = (lit + ambient) · albedo · texture + emissive
/// ```
fn baked_lit_rgb(
vcol: [f32; 3],
albedo: [f32; 3],
tex: [f32; 3],
vis: f32,
ambient: [f32; 3],
emissive: [f32; 3],
) -> [f32; 3] {
const SUN_SHARE: f32 = 0.45;
let shade = 1.0 - SUN_SHARE + SUN_SHARE * vis;
std::array::from_fn(|c| (vcol[c] * shade + ambient[c]) * (albedo[c] * tex[c]) + emissive[c])
}
/// The expression as it stood before the knobs existed: `baked · shade · albedo · texture`.
fn baked_lit_rgb_before_knobs(vcol: [f32; 3], albedo: [f32; 3], tex: [f32; 3], vis: f32) -> [f32; 3] {
const SUN_SHARE: f32 = 0.45;
let shade = 1.0 - SUN_SHARE + SUN_SHARE * vis;
std::array::from_fn(|c| vcol[c] * shade * (albedo[c] * tex[c]))
}
// The property the brief asks to be stated as checked, not hoped: with both knobs at their
// defaults, every existing scene shades to the same bits it did before.
#[test]
fn zero_knobs_reproduce_the_old_expression_exactly() {
let cases = [
([1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0], 1.0),
([0.5, 0.5, 0.5], [1.0, 0.8, 0.6], [0.25, 0.5, 0.75], 0.0),
([0.502, 0.376, 0.251], [0.9, 0.9, 0.9], [0.502, 0.502, 0.502], 0.5),
([0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0], 1.0),
([1.0, 0.0, 0.25], [0.3, 1.0, 0.7], [1.0, 0.1, 0.9], 0.375),
];
for (vcol, albedo, tex, vis) in cases {
let now = baked_lit_rgb(vcol, albedo, tex, vis, [0.0; 3], [0.0; 3]);
let before = baked_lit_rgb_before_knobs(vcol, albedo, tex, vis);
// Bit equality, not an epsilon: adding a literal 0.0 and multiplying by the same
// factors in the same order must not perturb a single mantissa bit.
assert_eq!(
now.map(f32::to_bits),
before.map(f32::to_bits),
"default knobs changed the shading for vcol={vcol:?} albedo={albedo:?} \
tex={tex:?} vis={vis}"
);
}
}
// The reported failure mode: a mid-dark surface (vertex colour 128/255 over a 128/255
// texture) lands at ~0.25 linear before exposure and the ACES toe, and a darker one
// (64/255 over 64/255) at ~0.06 — from which nothing downstream can recover it, because
// `BakedLit` had no term that adds.
#[test]
fn ambient_lifts_a_scene_the_multiply_chain_cannot() {
let dark = 64.0 / 255.0;
let unlifted = baked_lit_rgb([dark; 3], [1.0; 3], [dark; 3], 1.0, [0.0; 3], [0.0; 3]);
assert!(unlifted[0] < 0.07, "premise: the bare chain really is this dark ({unlifted:?})");
let lifted = baked_lit_rgb([dark; 3], [1.0; 3], [dark; 3], 1.0, [0.35; 3], [0.0; 3]);
assert!(
lifted[0] > unlifted[0] * 2.0,
"ambient must be able to more than double a dark surface: {unlifted:?} → {lifted:?}"
);
// Ambient is incident light, so it goes through the surface: it must scale with albedo,
// not flood every material to the same grey.
let dim_surface = baked_lit_rgb([dark; 3], [0.25; 3], [dark; 3], 1.0, [0.35; 3], [0.0; 3]);
assert!(
dim_surface[0] < lifted[0],
"a dark-albedo surface must stay darker than a white one under the same ambient"
);
// …and it must keep the surface's hue rather than washing it toward neutral.
let red = baked_lit_rgb([dark; 3], [1.0, 0.2, 0.2], [1.0; 3], 1.0, [0.4; 3], [0.0; 3]);
assert!(red[0] > red[1] * 2.0, "white ambient greyed out a red surface: {red:?}");
}
#[test]
fn emissive_glows_through_a_black_surface_and_through_shadow() {
// Emissive is the surface emitting, so albedo and texture must not gate it — a lit
// window in a black wall is the whole use case.
let black_wall = baked_lit_rgb([0.0; 3], [0.0; 3], [0.0; 3], 1.0, [0.0; 3], [1.5, 1.2, 0.6]);
assert_eq!(black_wall, [1.5, 1.2, 0.6]);
// Nor may the sun's shadow dim it: a fully shadowed emitter emits the same.
let shadowed = baked_lit_rgb([0.5; 3], [1.0; 3], [1.0; 3], 0.0, [0.0; 3], [1.5, 1.2, 0.6]);
let lit = baked_lit_rgb([0.5; 3], [1.0; 3], [1.0; 3], 1.0, [0.0; 3], [1.5, 1.2, 0.6]);
for c in 0..3 {
assert!(
(shadowed[c] - lit[c] - (0.5 * 0.55 - 0.5)).abs() < 1e-5,
"shadow must move only the baked term, not the emissive one"
);
}
}
// The shader is text; this mirror is Rust. Nothing else notices if they drift apart.
#[test]
fn the_shader_still_computes_the_mirrored_expression() {
let src = include_str!("../shaders/baked_lit.wgsl");
assert!(
src.contains("let colour = (lit + in.inst_ambient) * base + in.inst_emissive;"),
"baked_lit.wgsl no longer matches `baked_lit_rgb` — update both or neither"
);
assert!(
src.contains("let lit = baked * (1.0 - sun_share + sun_share * vis);"),
"baked_lit.wgsl's shadow term no longer matches the mirror"
);
// ITEM 5: the vertex colour must be taken as authored. A length test that rewrites
// near-black to white cannot tell "no attribute" from "painted black".
assert!(
!src.contains("length(baked)"),
"the near-black vertex colour is being second-guessed again"
);
// ITEM 6: vertex alpha has to survive to the fragment output or nothing downstream
// can blend with it.
assert!(
src.contains("@location(1) color: vec4<f32>"),
"baked_lit.wgsl dropped the vertex colour's alpha channel"
);
assert!(
src.contains("in.color.a * in.inst_albedo.a * tex.a"),
"vertex alpha no longer reaches the fragment's output alpha"
);
}
}