gizmo-renderer 0.9.1

A custom ECS and physics engine aimed for realistic simulations.
Documentation
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
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
use bytemuck::{Pod, Zeroable};

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct Vertex {
    pub position: [f32; 3],
    /// Vertex colour, **RGBA**.
    ///
    /// The alpha is a real channel, not padding: a decal or skid-mark layer authored with a
    /// soft edge carries that edge here, and `baked_lit.wgsl` multiplies it into the fragment
    /// alpha (see [`crate::components::MaterialType::BakedLit`]). Shaders that have no use
    /// for it declare `@location(1) color: vec3<f32>` and read only the RGB — a vertex format
    /// may supply more components than the shader consumes.
    ///
    /// The attribute is ALWAYS present, because this is the engine's one vertex layout. A
    /// source file that carries no colours is normalised to opaque white **at import** (see
    /// [`Default`] and the glTF loader), which is the only place that knows the attribute was
    /// absent. Downstream code must therefore take this value at face value: `[0,0,0,1]`
    /// means "authored black", not "missing".
    pub color: [f32; 4],
    pub normal: [f32; 3],
    pub tex_coords: [f32; 2],
    pub joint_indices: [u32; 4],
    pub joint_weights: [f32; 4],
    pub tangent: [f32; 4],
}

impl Default for Vertex {
    fn default() -> Self {
        Self {
            position: [0.0; 3],
            // Opaque white: the neutral element of every multiply the colour feeds, and the
            // value an importer must leave behind when the source has no colour attribute.
            color: [1.0; 4],
            normal: [0.0, 1.0, 0.0],
            tex_coords: [0.0; 2],
            joint_indices: [0; 4],
            joint_weights: [0.0; 4],
            tangent: [1.0, 0.0, 0.0, 1.0],
        }
    }
}

impl Vertex {
    pub fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
        wgpu::VertexBufferLayout {
            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
            step_mode: wgpu::VertexStepMode::Vertex,
            attributes: &[
                wgpu::VertexAttribute {
                    offset: 0,
                    shader_location: 0,
                    format: wgpu::VertexFormat::Float32x3,
                },
                // Vertex colour is RGBA (Float32x4). A shader that only wants the RGB may
                // still declare `vec3<f32>` here — wgpu/WebGPU allow a shader input with
                // FEWER components than the attribute format supplies, so widening this
                // attribute did not require touching the eight shaders that ignore alpha.
                // Offsets come from `offset_of!` rather than a running sum of the preceding
                // field sizes: widening `color` from RGB to RGBA moved five of them by four
                // bytes, and a hand-maintained sum is one arithmetic slip away from a
                // pipeline that reads normals out of the tex-coords.
                wgpu::VertexAttribute {
                    offset: std::mem::offset_of!(Vertex, color) as wgpu::BufferAddress,
                    shader_location: 1,
                    format: wgpu::VertexFormat::Float32x4,
                },
                wgpu::VertexAttribute {
                    offset: std::mem::offset_of!(Vertex, normal) as wgpu::BufferAddress,
                    shader_location: 2,
                    format: wgpu::VertexFormat::Float32x3,
                },
                wgpu::VertexAttribute {
                    offset: std::mem::offset_of!(Vertex, tex_coords) as wgpu::BufferAddress,
                    shader_location: 3,
                    format: wgpu::VertexFormat::Float32x2,
                },
                wgpu::VertexAttribute {
                    offset: std::mem::offset_of!(Vertex, joint_indices) as wgpu::BufferAddress,
                    shader_location: 4,
                    format: wgpu::VertexFormat::Uint32x4,
                },
                wgpu::VertexAttribute {
                    offset: std::mem::offset_of!(Vertex, joint_weights) as wgpu::BufferAddress,
                    shader_location: 5,
                    format: wgpu::VertexFormat::Float32x4,
                },
                wgpu::VertexAttribute {
                    offset: std::mem::offset_of!(Vertex, tangent) as wgpu::BufferAddress,
                    shader_location: 6,
                    format: wgpu::VertexFormat::Float32x4,
                },
            ],
        }
    }
}

#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
pub struct LightData {
    pub position: [f32; 4],  // xyz=pos, w=intensity
    pub color: [f32; 4],     // rgb=color, a=radius
    pub direction: [f32; 4], // xyz=direction (spot), w=inner_cutoff_cos
    pub params: [f32; 4], // x=outer_cutoff_cos, y=light_type (0=point,1=spot,2=directional), zw=unused
}

/// Pack the anisotropy/clear-coat/subsurface triple into the one spare `InstanceRaw` slot.
///
/// Private on purpose: [`InstanceRaw::new`] is the only producer, because `gbuffer.wgsl`'s decoder
/// is the only consumer and the two must agree digit for digit. When this was a helper each render
/// path called for itself, they stopped agreeing — see the constructor's docs.
///
/// Two decimal digits per field, not three, and that is a correctness bound rather than taste. An
/// `f32` holds integers exactly only up to 2^24 = 16 777 216 — eight digits, and not even all of
/// those. Three fields of three digits needs nine, and past the limit the step exceeds 1, so the
/// low field rounds *into* its neighbour: with the old layout, anisotropy 1.0 (clamped to 999) and
/// any subsurface ≥ 0.16 rounded 999 up to 1000 and carried, reading back as anisotropy **0.0**
/// with a phantom clear_coat. Which is precisely the overflow the `.min()` clamps were added to
/// stop — f32 rounding simply reintroduced it further up the range, where the endpoint test was
/// not looking.
///
/// Six digits caps the packed value at 999 999, seventeen times under the limit, and the round
/// trip is exact for every combination at the 1 % resolution the subsurface field always had.
fn pack_pbr_params(anisotropy: f32, clear_coat: f32, subsurface: f32) -> f32 {
    (anisotropy * 100.0).floor().min(99.0)
        + 100.0 * (clear_coat * 100.0).floor().min(99.0)
        + 10_000.0 * (subsurface * 100.0).floor().min(99.0)
}

impl Default for LightData {
    /// An unlit slot: zero intensity, zero radius, pointing down.
    ///
    /// The array is fixed-length and `SceneUniforms::num_lights` says how much of it is live, so
    /// the tail is never read — but it is uploaded, and a light array padded with garbage is one
    /// shader bug away from being visible.
    fn default() -> Self {
        Self {
            position: [0.0; 4],
            color: [0.0; 4],
            direction: [0.0, -1.0, 0.0, 0.0],
            params: [0.0; 4],
        }
    }
}

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct PostProcessUniforms {
    pub bloom_intensity: f32,
    pub bloom_threshold: f32,
    pub exposure: f32,
    pub chromatic_aberration: f32,
    pub vignette_intensity: f32,
    pub film_grain_intensity: f32,
    pub dof_focus_dist: f32,
    pub dof_focus_range: f32,
    pub dof_blur_size: f32,
    // Active camera near/far, so DoF depth linearization matches the real projection
    // instead of hardcoded 0.1/1000 (miscalibrated CoC for any other far plane).
    pub cam_near: f32,
    pub cam_far: f32,
    // ── Su-altı atmosferi (kamera bir fluid zone içindeyken) ──
    /// 0 = kamera havada (etki yok), 1 = kamera su altında → derinlik-bazlı sis uygulanır.
    pub underwater: f32,
    /// Su-altı sis rengi (deniz mavisi-yeşili) + yoğunluk. WGSL'de tek `fog: vec4` (rgb+a=density)
    /// olarak hizalanır (offset 48, 16-bayt hizalı).
    pub fog_r: f32,
    pub fog_g: f32,
    pub fog_b: f32,
    pub fog_density: f32,
}

/// Uniform block for the shadow pass vertex shader only (one cascade matrix per draw).
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct ShadowVsUniform {
    pub light_view_proj: [[f32; 4]; 4],
}

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct SceneUniforms {
    pub view_proj: [[f32; 4]; 4],
    pub camera_pos: [f32; 4],
    pub sun_direction: [f32; 4],
    pub sun_color: [f32; 4],
    pub lights: [LightData; 10],
    /// Directional CSM: world → light clip space per cascade (same order as shadow array layers).
    pub light_view_proj: [[[f32; 4]; 4]; 4],
    /// Far distance (along `camera_forward`) for cascades 0..3. `w` is therefore the far edge
    /// of the LAST cascade — the whole range the shadow maps cover, `min(camera far,
    /// csm::SHADOW_DISTANCE)` — which is what the shaders fade the shadow term out over. (It
    /// was documented as "always camera far plane"; it never was, and the shadow fade depends
    /// on it not being.)
    pub cascade_splits: [f32; 4],
    /// xyz = normalized camera forward in world space (for view-depth cascade selection).
    pub camera_forward: [f32; 4],
    /// x = camera z_near, y = 1 / shadow map resolution (PCF texel size),
    /// z = elapsed time in seconds (fluid caustics/wave animation), w unused.
    pub cascade_params: [f32; 4],
    pub num_lights: u32,
    pub exposure: f32,
    pub _pre_align_pad: [u32; 2], // offset 1064-1071
    pub _align_pad: [u32; 3],     // offset 1072-1083
    pub environment_blend_t: f32, // offset 1084-1087
    pub environment_preset: u32,  // offset 1088-1091
    pub point_shadows_enabled: u32, // offset 1092-1095
    pub environment_preset_2: u32, // offset 1096-1099
    pub shading_mode: u32,        // offset 1100-1103
    /// inverse(view_proj), computed once per frame on the CPU so fullscreen passes that
    /// unproject NDC→world (volumetric, particle soft-depth) read it instead of recomputing a
    /// full 4×4 inverse per fragment. Appended at the 16-byte-aligned tail (1104) so every
    /// existing field offset — and the partial SceneUniforms copies in other shaders — is
    /// unaffected. offset 1104-1167.
    pub inv_view_proj: [[f32; 4]; 4],
                           // Total: 1168 bytes
}

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct InstanceRaw {
    pub model: [[f32; 4]; 4],
    pub albedo_color: [f32; 4],
    pub roughness: f32,
    pub metallic: f32,
    pub unlit: f32,
    /// The anisotropy/clear-coat/subsurface triple, packed into one f32 — the `.w` of the
    /// shader's `pbr` vec4. Called `_padding` until 2026-08-15, which is part of how two render
    /// paths came to pack it two different ways: a slot named "padding" reads as a slot nobody
    /// has to get right. Written only by [`InstanceRaw::new`].
    pub packed_pbr_params: f32,
    /// xyz = ambient light reaching this surface regardless of the sun (linear, added to the
    /// baked/vertex term BEFORE the albedo multiply, so it tints with the surface); w unused.
    ///
    /// Zero by default — the shading is then bit-for-bit what it was before this field
    /// existed. Only `baked_lit.wgsl` reads it today; it rides `InstanceRaw` rather than the
    /// per-material uniform because it comes from the `Material` COMPONENT, and two entities
    /// can share one material bind group while carrying different components.
    pub ambient: [f32; 4],
    /// xyz = self-emission (linear), added AFTER the albedo/texture multiply so a black
    /// surface can still glow — the same relationship glTF's `emissiveFactor` has to base
    /// colour. w unused. Zero by default.
    pub emissive: [f32; 4],
}

impl InstanceRaw {
    /// Builds the per-instance record from the values a
    /// [`Material`](crate::components::Material) contributes to a draw.
    ///
    /// This is the single place those values become GPU bytes. It takes the values rather
    /// than the `Material` itself for two reasons: the game (`gizmo`) and studio
    /// (`gizmo-studio`) render paths both build instances and have drifted before, and a
    /// `Material` owns a `wgpu::BindGroup`, so taking one would put this mapping out of reach
    /// of any test that does not open a GPU adapter.
    ///
    /// The anisotropy/clear-coat/subsurface triple is packed into one f32 slot **here**, by
    /// [`pack_pbr_params`], rather than by the caller. It was the caller's job until the two
    /// paths were compared: the engine packed two decimal digits per field and studio still
    /// packed three, which is the layout the engine abandoned because it overflows `f32`'s
    /// exact-integer range. `gbuffer.wgsl` decodes the two-digit layout, so studio's instances
    /// would have decoded as a different material entirely — inert only because the editor's
    /// forward pipeline never reaches that shader. A value with one producer and one consumer
    /// should not be assembled at the call site.
    ///
    /// `unlit` is the shading-route flag (0 = deferred PBR, 1 = unlit/baked-lit, 2 = skybox).
    ///
    /// `ambient` and `emissive` are floored at zero here, not only in the `Material` builders:
    /// the fields on `Material` are `pub`, so a builder-side clamp is not an enforcement
    /// point. Light that subtracts is always a mistake, and it would drive the shader's
    /// `lit + ambient` negative. (`f32::max` also drops NaN, which would otherwise poison the
    /// whole fragment.)
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        model: [[f32; 4]; 4],
        albedo_color: [f32; 4],
        roughness: f32,
        metallic: f32,
        unlit: f32,
        anisotropy: f32,
        clear_coat: f32,
        subsurface: f32,
        ambient: [f32; 3],
        emissive: [f32; 3],
    ) -> Self {
        Self {
            model,
            albedo_color,
            roughness,
            metallic,
            unlit,
            packed_pbr_params: pack_pbr_params(anisotropy, clear_coat, subsurface),
            ambient: [ambient[0].max(0.0), ambient[1].max(0.0), ambient[2].max(0.0), 0.0],
            emissive: [emissive[0].max(0.0), emissive[1].max(0.0), emissive[2].max(0.0), 0.0],
        }
    }
}

/// Per-material scalar parameters that accompany the textured-PBR bind group
/// (group 1, binding 6).  These carry the glTF factors that modulate the
/// sampled auxiliary maps so that an absent map falls back to the scalar value:
///
/// * `emissive` = emissiveFactor (× KHR_materials_emissive_strength) — multiplied
///   by the (white-default) emissive map, so absent map + zero factor = no emission.
/// * `normal_scale` = glTF normalTexture.scale — scales the tangent-space XY of the
///   (flat-default) normal map, so absent map = unperturbed geometric normal.
/// * `occlusion_strength` = glTF occlusionTexture.strength — lerps the (white-default)
///   AO map toward 1.0, so absent map = no occlusion.
/// * `uv` = KHR_texture_transform (offset / rotation / scale) applied to the UV
///   before every map is sampled; identity when the extension is absent.
///
/// std140 layout: three 16-byte vec4 slots → 48 bytes total.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct MaterialParams {
    /// xyz = emissive factor (linear), w = normal-map scale.
    pub emissive_and_normal_scale: [f32; 4],
    /// x = occlusion (AO) strength; y = UV rotation (radians); zw = UV offset.
    pub occlusion_uv_rot_offset: [f32; 4],
    /// xy = UV scale; z = alpha cutoff (glTF `AlphaMode::Mask`; 0.0 = no cutout,
    /// the g-buffer hard-`discard`s texels with `alpha < cutoff`); w reserved (0.0).
    pub uv_scale: [f32; 4],
}

impl Default for MaterialParams {
    fn default() -> Self {
        // Neutral material: no emission, unit normal scale, unit AO strength,
        // identity UV transform (zero offset, zero rotation, unit scale).
        Self {
            emissive_and_normal_scale: [0.0, 0.0, 0.0, 1.0],
            occlusion_uv_rot_offset: [1.0, 0.0, 0.0, 0.0],
            uv_scale: [1.0, 1.0, 0.0, 0.0],
        }
    }
}

impl MaterialParams {
    pub fn new(
        emissive: [f32; 3],
        normal_scale: f32,
        occlusion_strength: f32,
        uv: UvTransform,
        alpha_cutoff: f32,
    ) -> Self {
        Self {
            emissive_and_normal_scale: [emissive[0], emissive[1], emissive[2], normal_scale],
            occlusion_uv_rot_offset: [occlusion_strength, uv.rotation, uv.offset[0], uv.offset[1]],
            uv_scale: [uv.scale[0], uv.scale[1], alpha_cutoff, 0.0],
        }
    }
}

/// A UV-coordinate transform from `KHR_texture_transform` (offset, rotation in
/// radians, scale). The renderer applies one transform per material (derived
/// from the base-colour texture) to every map's sampled UV — see
/// `asset::loaders`. Its [`Default`] is the identity (no transform).
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct UvTransform {
    pub offset: [f32; 2],
    pub rotation: f32,
    pub scale: [f32; 2],
}

impl Default for UvTransform {
    fn default() -> Self {
        Self { offset: [0.0, 0.0], rotation: 0.0, scale: [1.0, 1.0] }
    }
}

impl UvTransform {
    /// True when this transform leaves UVs unchanged (identity).
    pub fn is_identity(&self) -> bool {
        self.offset == [0.0, 0.0] && self.rotation == 0.0 && self.scale == [1.0, 1.0]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // These sizes are the Rust half of the GPU layout: bytemuck::Pod requires no interior or
    // trailing padding, and a size that moves means every shader indexing the buffer reads
    // shifted memory. It is only half — this test never opens a `.wgsl` file, and used to claim
    // it was "a contract with the WGSL side" while checking nothing on that side. The other half
    // is `crate::shader_contract`, which parses the shaders and compares their field offsets to
    // `offset_of!` on these structs. Keep both: this one fails first and reads clearly, that one
    // says which shader disagreed.
    #[test]
    fn gpu_struct_sizes_match_the_shader_layout() {
        // 96 (was 92) since the vertex colour became RGBA.
        assert_eq!(std::mem::size_of::<Vertex>(), 96, "Vertex stride");
        assert_eq!(std::mem::size_of::<LightData>(), 64, "LightData = 4×vec4");
        // 128 (was 96) since `ambient` + `emissive` joined it. EVERY shader that indexes
        // `array<InstanceRaw>` must declare the same 8 vec4s or `instances[i]` reads the
        // wrong offsets for i > 0 — see `every_instance_shader_declares_the_full_struct`.
        assert_eq!(std::mem::size_of::<InstanceRaw>(), 128, "InstanceRaw");
        assert_eq!(std::mem::size_of::<MaterialParams>(), 48, "MaterialParams = 3×vec4");
        // The struct's own comment pins the total at 1168 bytes with inv_view_proj
        // appended at the 16-byte-aligned tail (offset 1104).
        assert_eq!(std::mem::size_of::<SceneUniforms>(), 1168, "SceneUniforms total");
    }

    #[test]
    fn vertex_desc_offsets_are_packed_and_in_bounds() {
        let d = Vertex::desc();
        assert_eq!(d.array_stride as usize, std::mem::size_of::<Vertex>());

        // Offsets strictly increase, shader locations run 0..=6, everything fits.
        let mut prev: Option<u64> = None;
        for (i, a) in d.attributes.iter().enumerate() {
            assert_eq!(a.shader_location, i as u32, "attribute {i} shader_location");
            assert!(a.offset < d.array_stride, "attribute {i} offset past stride");
            if let Some(p) = prev {
                assert!(a.offset > p, "attribute {i} offset must increase");
            }
            prev = Some(a.offset);
        }
        // Last attribute is the Float32x4 tangent (16 bytes) and must end exactly
        // at the stride — no hidden gap that would corrupt the next vertex.
        let last = d.attributes.last().unwrap();
        assert_eq!(last.format, wgpu::VertexFormat::Float32x4);
        assert_eq!(last.offset + 16, d.array_stride);
    }

    // The Material lighting knobs are useless if they land in the wrong slot, and the wrong
    // slot is invisible: the shader would read a neighbouring field and quietly shade with it.
    #[test]
    fn instance_new_puts_the_material_knobs_in_their_own_slots() {
        let model = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [7.0, 8.0, 9.0, 1.0]];
        let i = InstanceRaw::new(
            model,
            [0.1, 0.2, 0.3, 0.4],
            0.5,
            0.6,
            1.0,
            // Distinct per field so a swapped pair cannot pass the packed round trip below.
            0.77,
            0.055,
            0.33,
            [0.01, 0.02, 0.03],
            [4.0, 5.0, 6.0],
        );
        assert_eq!(i.model, model);
        assert_eq!(i.albedo_color, [0.1, 0.2, 0.3, 0.4]);
        assert_eq!(i.roughness, 0.5);
        assert_eq!(i.metallic, 0.6);
        assert_eq!(i.unlit, 1.0);
        assert_eq!(
            i.packed_pbr_params,
            pack_pbr_params(0.77, 0.055, 0.33),
            "the constructor packs the triple; no call site may assemble this slot"
        );
        // Distinct values on both sides so a swapped pair cannot pass.
        assert_eq!(i.ambient, [0.01, 0.02, 0.03, 0.0], "ambient must not pick up emissive");
        assert_eq!(i.emissive, [4.0, 5.0, 6.0, 0.0], "emissive must not pick up ambient");
    }

    #[test]
    fn instance_with_zero_knobs_is_byte_identical_to_the_pre_knob_record() {
        // The bit-identical-defaults promise, checked rather than hoped: a material that sets
        // neither knob produces the same first 96 bytes it always did, and the 32 new ones are
        // all zero — which is what makes the shader's `(lit + ambient) * base + emissive`
        // collapse back to `lit * base`.
        let i = InstanceRaw::new(
            [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]],
            [1.0, 1.0, 1.0, 1.0],
            0.5,
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
            [0.0; 3],
            [0.0; 3],
        );
        let bytes = bytemuck::bytes_of(&i);
        assert_eq!(&bytes[96..], &[0u8; 32], "unset knobs must be all-zero bytes");
        assert_eq!(std::mem::offset_of!(InstanceRaw, ambient), 96);
        assert_eq!(std::mem::offset_of!(InstanceRaw, emissive), 112);
    }

    #[test]
    fn instance_new_floors_negative_and_nan_light() {
        let i = InstanceRaw::new(
            [[0.0; 4]; 4],
            [1.0; 4],
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
            [-1.0, f32::NAN, 0.25],
            [f32::NEG_INFINITY, 2.0, f32::NAN],
        );
        assert_eq!(i.ambient, [0.0, 0.0, 0.25, 0.0], "negative/NaN ambient must be floored");
        assert_eq!(i.emissive, [0.0, 2.0, 0.0, 0.0], "negative/NaN emissive must be floored");
    }

    #[test]
    fn vertex_colour_attribute_carries_alpha() {
        // Vertex alpha reaches the GPU or it does not exist. A Float32x3 here is exactly the
        // bug that made decal/skid-mark layers draw as opaque geometry: the alpha was in the
        // Rust struct's neighbourhood but never described to the pipeline.
        let d = Vertex::desc();
        let colour = d.attributes.iter().find(|a| a.shader_location == 1).expect("colour attribute");
        assert_eq!(
            colour.format,
            wgpu::VertexFormat::Float32x4,
            "vertex colour must be RGBA — a Float32x3 silently drops the alpha channel"
        );
        // The attribute must cover the whole `color` field and nothing else.
        assert_eq!(colour.offset as usize, std::mem::offset_of!(Vertex, color));
        assert_eq!(colour.offset as usize + 16, std::mem::offset_of!(Vertex, normal));
    }

    #[test]
    fn a_default_vertex_is_opaque_white() {
        // The invariant that lets `baked_lit.wgsl` trust `in.color` instead of guessing:
        // "no colour in the source" is normalised to opaque white at import, so a black
        // vertex colour can only ever mean the author asked for black.
        assert_eq!(Vertex::default().color, [1.0, 1.0, 1.0, 1.0]);
    }

    // The instance buffer's element stride used to be policed here, by a hand-written list of ten
    // shader files and a count of `vec4<f32>` occurrences. It now lives in
    // `crate::shader_contract::every_instance_declaration_matches_the_bytes_rust_uploads`, which
    // takes its subjects from the shader directory and its answer from the offsets naga computes —
    // an eleventh shader is covered the day it is added, which a list cannot do.

    #[test]
    fn material_params_new_packs_fields_into_documented_slots() {
        let uv = UvTransform { offset: [0.1, 0.2], rotation: 0.5, scale: [2.0, 3.0] };
        let p = MaterialParams::new([1.0, 2.0, 3.0], 0.7, 0.4, uv, 0.25);
        // emissive.xyz + normal_scale
        assert_eq!(p.emissive_and_normal_scale, [1.0, 2.0, 3.0, 0.7]);
        // occlusion, uv.rotation, uv.offset.x, uv.offset.y
        assert_eq!(p.occlusion_uv_rot_offset, [0.4, 0.5, 0.1, 0.2]);
        // uv.scale.xy, alpha_cutoff, reserved 0
        assert_eq!(p.uv_scale, [2.0, 3.0, 0.25, 0.0]);
    }

    #[test]
    fn material_params_default_is_neutral() {
        let d = MaterialParams::default();
        assert_eq!(d.emissive_and_normal_scale, [0.0, 0.0, 0.0, 1.0]);
        assert_eq!(d.occlusion_uv_rot_offset, [1.0, 0.0, 0.0, 0.0]);
        assert_eq!(d.uv_scale, [1.0, 1.0, 0.0, 0.0]);
        // Default UvTransform folded into MaterialParams::new must reproduce Default.
        let via_new = MaterialParams::new([0.0; 3], 1.0, 1.0, UvTransform::default(), 0.0);
        assert_eq!(via_new.emissive_and_normal_scale, d.emissive_and_normal_scale);
        assert_eq!(via_new.occlusion_uv_rot_offset, d.occlusion_uv_rot_offset);
        assert_eq!(via_new.uv_scale, d.uv_scale);
    }

    #[test]
    fn uv_transform_is_identity_only_for_the_identity() {
        assert!(UvTransform::default().is_identity());
        assert!(!UvTransform { offset: [0.1, 0.0], ..Default::default() }.is_identity());
        assert!(!UvTransform { rotation: 0.001, ..Default::default() }.is_identity());
        assert!(!UvTransform { scale: [1.0, 2.0], ..Default::default() }.is_identity());
        // A non-unit scale of exactly 0 is still "not identity".
        assert!(!UvTransform { scale: [0.0, 0.0], ..Default::default() }.is_identity());
    }
}

#[cfg(test)]
mod pbr_pack_tests {
    use super::pack_pbr_params;
    use super::InstanceRaw;

    // Mirror gbuffer.wgsl fs_main's unpack of packed_params (in.inst_pbr.w) exactly.
    fn unpack(w: f32) -> (f32, f32, f32) {
        let subsurface = (w / 10_000.0).floor() / 100.0;
        let rem = w - (w / 10_000.0).floor() * 10_000.0;
        let clear_coat = (rem / 100.0).floor() / 100.0;
        let anisotropy = (rem - (rem / 100.0).floor() * 100.0) / 100.0;
        (anisotropy, clear_coat, subsurface)
    }

    /// The packer is private and the constructor is its only caller, so the round trip that
    /// actually ships is the one through `InstanceRaw::new` — including that the three values
    /// arrive in the right order. A swapped pair here is a material that shades as a different
    /// material, which is what the editor's own copy of this packing was doing.
    #[test]
    fn the_constructor_packs_what_the_shader_decodes() {
        let i = InstanceRaw::new(
            [[0.0; 4]; 4],
            [1.0; 4],
            0.5,
            0.0,
            0.0,
            0.3,
            0.7,
            0.05,
            [0.0; 3],
            [0.0; 3],
        );
        let (aniso, cc, ss) = unpack(i.packed_pbr_params);
        assert!((aniso - 0.3).abs() <= 0.011, "anisotropy {aniso}");
        assert!((cc - 0.7).abs() <= 0.011, "clear_coat {cc}");
        assert!((ss - 0.05).abs() <= 0.011, "subsurface {ss}");
    }

    // Regression: the legal clamped endpoint 1.0 must NOT overflow its 3-digit field into
    // the neighbour. Before the clamp, clear_coat=1.0 packed as floor(1000)*1000
    // which carried into the subsurface field (three-digit layout, since abandoned) → clear_coat read back as 0 and a phantom
    // subsurface≈0.01 appeared. Symmetric for anisotropy=1.0.
    #[test]
    fn endpoint_one_does_not_overflow_into_neighbours() {
        // clear_coat = 1.0, others 0 → clear_coat must survive (~0.999), no phantom subsurface.
        let (aniso, cc, ss) = unpack(pack_pbr_params(0.0, 1.0, 0.0));
        assert!(cc >= 0.99, "clear_coat=1.0 lost (got {cc})");
        assert_eq!(ss, 0.0, "clear_coat=1.0 leaked a phantom subsurface ({ss})");
        assert_eq!(aniso, 0.0, "clear_coat=1.0 leaked into anisotropy ({aniso})");

        // anisotropy = 1.0 → survives, no leak into clear_coat.
        let (aniso, cc, ss) = unpack(pack_pbr_params(1.0, 0.0, 0.0));
        assert!(aniso >= 0.99, "anisotropy=1.0 lost (got {aniso})");
        assert_eq!(cc, 0.0, "anisotropy=1.0 leaked into clear_coat ({cc})");
        assert_eq!(ss, 0.0, "anisotropy=1.0 leaked into subsurface ({ss})");
    }

    // Ordinary mid-range values round-trip within the decimal-packing resolution.
    #[test]
    fn mid_range_values_round_trip() {
        let (aniso, cc, ss) = unpack(pack_pbr_params(0.3, 0.7, 0.05));
        assert!((aniso - 0.3).abs() <= 0.011, "aniso {aniso}");
        assert!((cc - 0.7).abs() <= 0.011, "clear_coat {cc}");
        assert!((ss - 0.05).abs() <= 0.011, "subsurface {ss}");
    }

    /// The endpoint test above only ever tried subsurface 0, and that is where this hid.
    ///
    /// Every field must survive every combination of the other two, which the old three-digit
    /// layout did not: `anisotropy = 1.0` with `subsurface ≥ 0.16` pushed the packed value past
    /// 2^24, where an f32's step exceeds 1, so the clamped 999 rounded to 1000 and carried into
    /// the clear-coat field. Anisotropy read back as **0.0** — full anisotropy rendering as none.
    /// Swept rather than spot-checked, because a spot check is exactly what missed it.
    #[test]
    fn every_combination_round_trips() {
        for i in 0..=100 {
            for j in (0..=100).step_by(5) {
                for k in (0..=100).step_by(5) {
                    let (a, c, s) = (i as f32 / 100.0, j as f32 / 100.0, k as f32 / 100.0);
                    let (da, dc, ds) = unpack(pack_pbr_params(a, c, s));
                    for (got, want, name) in
                        [(da, a, "anisotropy"), (dc, c, "clear_coat"), (ds, s, "subsurface")]
                    {
                        assert!(
                            (got - want).abs() <= 0.011,
                            "{name} {want} came back as {got} (a={a} c={c} s={s}) — a field is \
                             carrying into its neighbour"
                        );
                    }
                }
            }
        }
    }
}