Skip to main content

gizmo_engine/systems/render/
mod.rs

1use super::physics::*;
2use crate::core::World;
3use crate::math::{Mat4, Vec3};
4use crate::renderer::{
5    components::{Camera, Material, Mesh, MeshRenderer},
6    Renderer,
7};
8use bytemuck;
9use wgpu;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub struct WireframeConfig {
13    pub global: bool,
14}
15
16#[derive(Default)]
17pub struct RenderCache {
18    pub(crate) batches: std::collections::HashMap<BatchKey, BatchData>,
19    pub instances: Vec<crate::renderer::gpu_types::InstanceRaw>,
20    pub draw_items: Vec<DrawItem>,
21}
22
23thread_local! {
24    static RENDER_CACHE: std::cell::RefCell<RenderCache> = std::cell::RefCell::new(RenderCache::default());
25}
26
27pub fn clear_render_cache() {
28    RENDER_CACHE.with(|rc| {
29        let mut cache = rc.borrow_mut();
30        cache.batches.clear();
31        cache.instances.clear();
32        cache.draw_items.clear();
33    });
34}
35
36#[derive(Debug, Clone)]
37pub struct DrawItem {
38    vbuf: std::sync::Arc<wgpu::Buffer>,
39    vertex_count: u32,
40    bind_group: std::sync::Arc<wgpu::BindGroup>,
41    unlit: bool,
42    is_skybox: bool,
43    skeleton_bind_group: Option<std::sync::Arc<wgpu::BindGroup>>,
44    is_transparent: bool,
45    first_instance: u32,
46    /// Total instances in this batch's contiguous range: camera-visible ones FIRST,
47    /// then shadow-only casters (outside the camera frustum but inside a cascade's light
48    /// frustum). Shadow passes draw the whole range; main passes draw only `camera_count`.
49    /// (Yalnız shadow geçitleri okur — web'de gölge yok, alan orada ölü.)
50    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
51    instance_count: u32,
52    /// Number of leading instances visible to the CAMERA (== the old camera-culled set).
53    camera_count: u32,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Hash)]
57pub(crate) struct BatchKey {
58    vbuf_id: usize,
59    mat_id: usize,
60    skeleton_id: Option<usize>,
61    // Pass-routing flags MUST be part of the key. `mat_id` is the material's
62    // *texture* bind-group pointer, which the asset manager caches and shares
63    // across distinct materials (e.g. the default white texture, or the same
64    // file). Two materials that differ only in transparency / material type would
65    // otherwise collide into one batch, and the batch would inherit whichever
66    // entity the (unordered) ECS iteration hit first — so a transparent object
67    // could render opaque, or a PBR object route through the unlit path, and
68    // *which* one corrupts flips between frames. Keying on the routing flags keeps
69    // same-routing instances batched while separating ones that render differently.
70    is_transparent: bool,
71    unlit: bool,
72    is_skybox: bool,
73}
74
75pub(crate) struct BatchData {
76    vbuf: std::sync::Arc<wgpu::Buffer>,
77    bind_group: std::sync::Arc<wgpu::BindGroup>,
78    vertex_count: u32,
79    unlit: bool,
80    is_skybox: bool,
81    skeleton_bind_group: Option<std::sync::Arc<wgpu::BindGroup>>,
82    is_transparent: bool,
83    instances: Vec<crate::renderer::gpu_types::InstanceRaw>,
84    /// Casters outside the camera frustum but inside a shadow cascade's light frustum —
85    /// must be drawn into the shadow maps so off-screen objects still cast visible shadows.
86    shadow_instances: Vec<crate::renderer::gpu_types::InstanceRaw>,
87}
88
89/// Guarantee every renderable mesh has a current `GlobalTransform` before the
90/// draw query runs.
91///
92/// The draw query below requires `(&Mesh, &GlobalTransform, &Material)` and reads
93/// the world matrix from `GlobalTransform`, but physics/gameplay write only the
94/// local `Transform`. Without this step a plain `spawn((Transform, Mesh, Material))`
95/// renders nothing (the "empty screen" footgun) and callers had to hand-run the
96/// transform systems each frame. Here we (1) backfill a `GlobalTransform` onto any
97/// mesh that lacks one, then (2) refresh local matrices and propagate them to
98/// `GlobalTransform` — the "update transforms right before the pass" TODO.
99fn ensure_global_transforms(world: &mut World) {
100    use crate::core::query::Without;
101    use crate::core::system::System;
102    use gizmo_physics_core::components::{GlobalTransform, Transform};
103
104    // Collect first: `add_component` is a structural change and can't run while a
105    // query borrow is live.
106    let mut missing = Vec::new();
107    if let Some(q) = world.query::<(&Mesh, &Transform, Without<GlobalTransform>)>() {
108        for (id, _) in q.iter() {
109            missing.push(id);
110        }
111    }
112    for id in missing {
113        if let Some(e) = world.get_entity(id) {
114            world.add_component(e, GlobalTransform::default());
115        }
116    }
117
118    let mut sync = crate::systems::transform::TransformSyncSystem;
119    let mut propagate = crate::systems::transform::TransformPropagateSystem;
120    sync.run(world, 0.0);
121    propagate.run(world, 0.0);
122}
123
124/// Manuel App (`set_setup`/`set_update`/`set_ui`) için TEK-SATIR sahne render kurulumu.
125///
126/// KÖK-TUZAK ÇÖZÜMÜ: manuel App, `set_render` verilmezse 3B sahneyi ÇİZMEZ (egui HUD
127/// görünür ama sahne SİYAH kalır — sessizce). `with_simple_scene` bunu kendi yapar;
128/// manuel App için bu uzantı aynısını tek satırda sağlar (ağır/opsiyonel pass'leri —
129/// SSR/SSGI/volumetric/TAA + GPU sıvı/fizik — kapatarak; GPU parçacık açık kalır).
130///
131/// ```ignore
132/// use gizmo::systems::AppSceneRenderExt;
133/// App::<S>::new(..).add_plugin(TransformPlugin).set_setup(..).set_update(..)
134///     .with_scene_render()   // <- bu olmadan ekran siyah
135///     .run()
136/// ```
137pub trait AppSceneRenderExt {
138    /// Sahneyi [`default_render_pass`] ile çizecek şekilde `set_render`'ı kurar.
139    fn with_scene_render(self) -> Self;
140}
141
142impl<State: 'static> AppSceneRenderExt for gizmo_app::App<State> {
143    fn with_scene_render(self) -> Self {
144        self.set_render(|world, _state, encoder, view, renderer, _light_time| {
145            renderer.gpu_fluid = None;
146            renderer.gpu_physics = None;
147            renderer.ssr = None;
148            renderer.ssgi = None;
149            renderer.volumetric = None;
150            renderer.taa = None;
151            default_render_pass(world, encoder, view, renderer);
152        })
153    }
154}
155
156/// Bevy'nin DefaultPlugins davranisini taklit eden, sadece modelleri
157/// isiklandirip hizlica ekrana basmaya yarayan kutudan cikmis Render Motoru.
158/// Yeni acilan `tut` gibi bos projelerde yuzlerce satir kod yazmamak icin kullanilir.
159#[tracing::instrument(skip_all, name = "render_system")]
160pub fn default_render_pass(
161    world: &mut World,
162    encoder: &mut wgpu::CommandEncoder,
163    view: &wgpu::TextureView,
164    renderer: &mut Renderer,
165) {
166    // Every renderable object needs an up-to-date `GlobalTransform` (the draw query
167    // below requires it, and physics/gameplay only write the local `Transform`).
168    // Realize the long-standing "update_transforms right before the pass" TODO here
169    // so a caller that just spawned `Transform + Mesh + Material` is not silently
170    // culled (the classic "empty screen" footgun) and doesn't have to hand-run the
171    // transform systems every frame.
172    ensure_global_transforms(world);
173
174    // Post-process params are written AFTER the active camera is resolved (below), so the
175    // single exposure knob can be the camera's exposure — see the update_post_process call
176    // after camera selection. Exposure is applied ONCE here, over the whole composited HDR
177    // (deferred geometry + sky + unlit), instead of being baked per-geometry in the
178    // deferred pass and multiplied again by a separate global knob.
179
180    let aspect = if renderer.size.height > 0 {
181        renderer.size.width as f32 / renderer.size.height as f32
182    } else {
183        1.0
184    };
185    let mut proj = Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, aspect, 0.1, 2000.0);
186    let mut view_mat = Mat4::from_translation(Vec3::ZERO);
187    let mut cam_pos = Vec3::ZERO;
188    let mut cam_forward = Vec3::new(0.0, 0.0, -1.0);
189
190    // TODO: Bütün nesnelerin (özellikle kamera ve çizilecek objelerin) global matrix'leri
191    // bu pass çağrılmadan hemen önce bir `update_transforms(world)` sistemiyle güncellenmiş olmalıdır.
192
193    // ECS veri GPU'ya basılır ve GPU verisi ECS'ye alınır
194    gpu_physics_submit_system(world, renderer);
195    gpu_physics_readback_system(world, renderer);
196
197    let mut cam_exposure = 1.0;
198    // Shadow cascades must follow the ACTIVE camera's near/far/fov, not hardcoded values
199    // (otherwise splits/cascade matrices are wrong for any non-default camera).
200    let mut cam_near = 0.1f32;
201    let mut cam_far = 2000.0f32;
202    let mut cam_fov = std::f32::consts::FRAC_PI_4;
203
204    // KAMERALARI BUL VE MATRIX YARAT
205    let cameras = world.borrow::<Camera>();
206    let global_transforms = world.borrow::<gizmo_physics_core::components::GlobalTransform>();
207    let local_transforms = world.borrow::<gizmo_physics_core::components::Transform>();
208    {
209        // Pick the camera flagged `primary` — the convention maintained by
210        // `spawn_camera`/`CameraBundle` (which keep a single primary) and used by
211        // the audio listener. Fall back to the first camera if none is marked.
212        // This makes selection deterministic instead of depending on the
213        // (unstable) ECS iteration order.
214        let active_cam = cameras
215            .iter()
216            .find(|(_, c)| c.primary)
217            .or_else(|| cameras.iter().next())
218            .map(|(id, _)| id);
219        if let Some(active_cam) = active_cam {
220            if let Some(cam) = cameras.get(active_cam) {
221                // Camera world position: prefer a synced GlobalTransform (needed when the
222                // camera is parented), but fall back to the camera's own Transform.position
223                // when it has none. Without the fallback a hand-built camera that only got
224                // a Transform + Camera (no GlobalTransform) was silently skipped and the
225                // view stuck at the origin — nothing read the Transform that gameplay/WASD
226                // moved. The transform-propagate system runs in the fixed-step schedule
227                // BEFORE the user update, and a custom App may not register it at all, so
228                // a camera's GlobalTransform is easily missing or a frame stale; the
229                // Transform is written right before render and is always current.
230                let pos = global_transforms
231                    .get(active_cam)
232                    .map(|g| g.matrix.to_scale_rotation_translation().2)
233                    .or_else(|| local_transforms.get(active_cam).map(|t| t.position))
234                    .unwrap_or(Vec3::ZERO);
235                proj = cam.get_projection(aspect);
236                view_mat = cam.get_view(pos);
237                cam_pos = pos;
238                cam_forward = cam.get_front();
239                cam_exposure = cam.exposure;
240                cam_near = cam.near;
241                cam_far = cam.far;
242                cam_fov = cam.fov;
243            }
244        }
245    }
246
247    // Update post-process params now that the active camera (hence its exposure) is known.
248    // `exposure` is the SINGLE exposure knob: the camera's exposure, applied once in the
249    // post composite over the entire HDR. (Previously the deferred pass baked cam.exposure
250    // into geometry AND post multiplied by a separate 1.15, which compounded and skipped
251    // sky/unlit; folding both into one post-stage exposure fixes that.)
252    // ── Su-altı atmosferi: kamera bir fluid zone içindeyse derinlik-bazlı sis uygula (W3+W4).
253    // W1 `water_at` sorgusu tekrar kullanılır (aynı su hacimleri hem buoyancy hem yüzme hem bu
254    // sisi sürer). Sis rengi/yoğunluğu deniz için makul sabitler — demolarda tunable yapılabilir.
255    // Sis rengi/yoğunluğu artık kameranın içinde bulunduğu FluidZone'dan gelir (her su hacmi
256    // kendi su-altı görünümünü tanımlar) — eskiden burada sabitti.
257    let water_sample = world
258        .get_resource::<crate::physics::world::PhysicsWorld>()
259        .and_then(|pw| pw.water_at(cam_pos));
260    let (uw, fog_r, fog_g, fog_b, fog_density) = match water_sample {
261        Some(s) => (1.0, s.fog_color[0], s.fog_color[1], s.fog_color[2], s.fog_density),
262        None => (0.0, 0.0, 0.0, 0.0, 0.0),
263    };
264
265    renderer.update_post_process(
266        &renderer.queue,
267        crate::renderer::gpu_types::PostProcessUniforms {
268            bloom_intensity: renderer.bloom_intensity,
269            bloom_threshold: renderer.bloom_threshold,
270            exposure: cam_exposure,
271            chromatic_aberration: renderer.chromatic_aberration,
272            vignette_intensity: 0.25,
273            film_grain_intensity: renderer.film_grain_intensity,
274            dof_focus_dist: renderer.dof_focus_dist,
275            dof_focus_range: renderer.dof_focus_range,
276            dof_blur_size: if renderer.dof_enabled { renderer.dof_blur_size } else { 0.0 },
277            cam_near,
278            cam_far,
279            underwater: uw,
280            fog_r,
281            fog_g,
282            fog_b,
283            fog_density,
284        },
285    );
286
287    // Save unjittered projection before applying TAA offset (needed for reprojection next frame).
288    let unjittered_proj = proj;
289
290    // ── TAA Halton jitter: subpixel offset applied via z-column of projection ──
291    if let Some(ref taa) = renderer.taa {
292        if taa.enabled {
293            let jp = crate::renderer::taa::TaaState::get_jitter(taa.frame_index);
294            // Convert pixel jitter [−0.5, 0.5] to NDC offset (2 / viewport_size per axis)
295            let jx = jp[0] * 2.0 / renderer.size.width as f32;
296            let jy = jp[1] * 2.0 / renderer.size.height as f32;
297            // Adding jitter to NDC.x requires: new_clip.x = clip.x - jx*vz
298            // ↔ subtract jx from proj.z_axis.x (the M[0][2] element, row0·col2)
299            proj.z_axis.x -= jx;
300            proj.z_axis.y -= jy;
301        }
302    }
303
304    let view_proj = proj * view_mat; // jittered — used for SceneUniforms
305    let unjittered_view_proj = unjittered_proj * view_mat; // clean    — stored in TaaState for next frame
306
307    // Lights (point + spot + sun) — collected via the shared setup helper so the
308    // game and studio renderers can never drift apart on light handling again.
309    let scene_lights = collect_scene_lights(world);
310    let sun_dir = scene_lights.sun_dir;
311    let sun_col = scene_lights.sun_col;
312
313    // Directional shadow cascades via the shared orchestration helper (SHADOW_DISTANCE
314    // cap + CASCADE_LAMBDA + cascade math), so the game and studio paths can't drift on
315    // shadow setup. The game always casts from the sun; the studio has its own fallback.
316    let cascades =
317        crate::renderer::compute_directional_cascades(cam_pos, cam_forward, aspect, cam_fov, cam_near, cam_far, sun_dir);
318    let cascade_splits = cascades.splits;
319    let cascade_vp = cascades.view_projs;
320    let light_view_projs: [[[f32; 4]; 4]; 4] = cascade_vp.map(|m| m.to_cols_array_2d());
321
322    // Dinamik ışıklar (point + spot) shared helper'dan geldi.
323    let lights_data = scene_lights.lights;
324    let num_lights = scene_lights.num_lights;
325
326    #[allow(unused_assignments)]
327    let mut point_light_view_projs = [gizmo_math::Mat4::IDENTITY; 6];
328    // Build the point-shadow cube for the ONE designated caster (shared.rs picks the
329    // first point light). Take its position/radius from the collected light array so the
330    // CPU and the shader agree on which light owns the cube, and so a light with only a
331    // Transform (no GlobalTransform) still casts — matching how it is lit.
332    if renderer.point_shadows_enabled && scene_lights.shadow_point_index >= 0 {
333        let idx = scene_lights.shadow_point_index as usize;
334        let lp = lights_data[idx].position;
335        let pos = gizmo_math::Vec3::new(lp[0], lp[1], lp[2]);
336        // Far plane tracks the light radius (the shader decodes depth with the same far).
337        let radius = lights_data[idx].color[3].max(1.0);
338        let proj = gizmo_math::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, radius);
339        point_light_view_projs = [
340            proj * gizmo_math::Mat4::look_to_rh(pos, gizmo_math::Vec3::X, -gizmo_math::Vec3::Y),
341            proj * gizmo_math::Mat4::look_to_rh(pos, gizmo_math::Vec3::NEG_X, -gizmo_math::Vec3::Y),
342            proj * gizmo_math::Mat4::look_to_rh(pos, gizmo_math::Vec3::Y, gizmo_math::Vec3::Z),
343            proj * gizmo_math::Mat4::look_to_rh(pos, gizmo_math::Vec3::NEG_Y, gizmo_math::Vec3::NEG_Z),
344            proj * gizmo_math::Mat4::look_to_rh(pos, gizmo_math::Vec3::Z, -gizmo_math::Vec3::Y),
345            proj * gizmo_math::Mat4::look_to_rh(pos, gizmo_math::Vec3::NEG_Z, -gizmo_math::Vec3::Y),
346        ];
347
348        for (i, view_proj) in point_light_view_projs.iter().enumerate() {
349            renderer.queue.write_buffer(
350                &renderer.scene.point_shadow_uniform_buffers[i],
351                0,
352                bytemuck::bytes_of(&crate::renderer::gpu_types::ShadowVsUniform {
353                    light_view_proj: view_proj.to_cols_array_2d(),
354                }),
355            );
356        }
357    }
358
359
360    // Elapsed time drives fluid caustics/wave animation in fluid_composite.wgsl
361    // (it reads cascade_params.z); this slot was hardcoded to 0.0 → frozen water.
362    let elapsed_time = world
363        .get_resource::<gizmo_core::time::Time>()
364        .map(|t| t.elapsed() as f32)
365        .unwrap_or(0.0);
366    let scene_uniform_data = crate::renderer::gpu_types::SceneUniforms {
367        view_proj: view_proj.to_cols_array_2d(),
368        camera_pos: [cam_pos.x, cam_pos.y, cam_pos.z, 1.0],
369        // w = "sun present" flag (1.0 / 0.0). Was hardcoded 1.0, which left the deferred
370        // shader evaluating the sun branch + a full CSM shadow lookup (against cascades
371        // built for a bogus down-vector) even in a scene with no sun. Gate it on has_sun,
372        // exactly like the studio path already does.
373        sun_direction: [sun_dir.x, sun_dir.y, sun_dir.z, if scene_lights.has_sun { 1.0 } else { 0.0 }],
374        sun_color: [sun_col.x, sun_col.y, sun_col.z, sun_col.w],
375        lights: lights_data,
376        light_view_proj: light_view_projs,
377        cascade_splits,
378        camera_forward: [cam_forward.x, cam_forward.y, cam_forward.z, 0.0],
379        // w = point-shadow caster index + 1 (0 = none); the deferred shader samples the
380        // single point-shadow cube only for this light.
381        cascade_params: [
382            0.1,
383            1.0 / crate::renderer::SHADOW_MAP_RES as f32,
384            elapsed_time,
385            (scene_lights.shadow_point_index + 1).max(0) as f32,
386        ],
387        num_lights,
388        exposure: cam_exposure,
389        _pre_align_pad: [0; 2],
390        _align_pad: [0; 3],
391        environment_blend_t: renderer.environment_blend_t,
392        environment_preset: renderer.environment_preset,
393        point_shadows_enabled: renderer.point_shadows_enabled as u32,
394        environment_preset_2: renderer.environment_preset_2,
395        shading_mode: renderer.shading_mode,
396    };
397    renderer.queue.write_buffer(
398        &renderer.scene.global_uniform_buffer,
399        0,
400        bytemuck::cast_slice(&[scene_uniform_data]),
401    );
402    for (i, light_view_proj) in light_view_projs.iter().enumerate() {
403        renderer.queue.write_buffer(
404            &renderer.scene.shadow_cascade_uniform_buffers[i],
405            0,
406            bytemuck::bytes_of(&crate::renderer::gpu_types::ShadowVsUniform {
407                light_view_proj: *light_view_proj,
408            }),
409        );
410    }
411
412    // Upload TAA params (prev_vp from last frame, current jitter, blend alpha)
413    if let Some(ref mut taa) = renderer.taa {
414        if taa.enabled {
415            let jp = crate::renderer::taa::TaaState::get_jitter(taa.frame_index);
416            let jx = jp[0] * 2.0 / renderer.size.width as f32;
417            let jy = jp[1] * 2.0 / renderer.size.height as f32;
418            let alpha = if taa.frame_index == 0 { 1.0f32 } else { 0.1f32 };
419            taa.update_params(&renderer.queue, [jx, jy], alpha);
420            taa.store_prev_vp(unjittered_view_proj.to_cols_array_2d());
421        }
422    }
423
424    // Upload SSGI temporal-accumulation params (mirrors TAA: previous-frame unjittered
425    // view-proj for reprojection + blend alpha). alpha=1.0 on the first frame / after a
426    // reset so there is no stale history to reproject. Denoises the 1-spp raymarch grain.
427    if let Some(ref mut ssgi) = renderer.ssgi {
428        let alpha = if ssgi.frame_index == 0 { 1.0f32 } else { 0.1f32 };
429        ssgi.update_params(&renderer.queue, alpha);
430        ssgi.store_prev_vp(unjittered_view_proj.to_cols_array_2d());
431    }
432
433    // ... inside default_render_pass ...
434    // ... before line 205 ...
435    let renderers = world.borrow::<MeshRenderer>();
436
437    // Get or create RenderCache
438    let frustum = crate::math::Frustum::from_matrix(&unjittered_view_proj);
439    // Per-cascade LIGHT frusta — shadow casters are culled against these, NOT the camera
440    // frustum, so objects outside the view that cast shadows INTO it aren't dropped.
441    let cascade_frusta: [crate::math::Frustum; 4] =
442        cascade_vp.map(|m| crate::math::Frustum::from_matrix(&m));
443
444    let (draw_items, uploaded_instances) = RENDER_CACHE.with(|rc| {
445        let mut cache = rc.borrow_mut();
446        
447        // Clear instances but keep allocations.
448        // `shadow_instances` MUST be cleared too: it is appended to every frame for
449        // off-screen shadow casters (line ~444) but the batches HashMap persists across
450        // frames, so leaving it uncleared made it grow without bound. Once the total
451        // instance count crossed `instance_capacity` (8192) the buffer upload truncated
452        // the tail, so batches past the cap silently stopped drawing — meshes vanished
453        // one by one as more frames accumulated ("araç giderek kayboluyor"). Which mesh
454        // dropped first depended on nondeterministic HashMap batch order.
455        for batch in cache.batches.values_mut() {
456            batch.instances.clear();
457            batch.shadow_instances.clear();
458        }
459        cache.instances.clear();
460        cache.draw_items.clear();
461
462        let pooled_storage = world.borrow::<gizmo_core::pool::Pooled>();
463        
464        macro_rules! process_mesh {
465            ($e:expr, $mesh:expr, $trans:expr, $mat:expr, $skeleton:expr) => {
466                if renderers.get($e).is_none() {
467                    continue;
468                }
469                
470                // Pooled (havuzda pasif) nesneleri render etme
471                if pooled_storage.get($e).is_some() {
472                    continue;
473                }
474
475                let center_mat = Mat4::from_translation($mesh.center_offset);
476                let model = $trans.matrix * center_mat;
477
478                // CPU Frustum Culling
479                let local_cx = ($mesh.bounds.min.x + $mesh.bounds.max.x) * 0.5;
480                let local_cy = ($mesh.bounds.min.y + $mesh.bounds.max.y) * 0.5;
481                let local_cz = ($mesh.bounds.min.z + $mesh.bounds.max.z) * 0.5;
482                let world_c = model.transform_point3(Vec3::new(local_cx, local_cy, local_cz));
483                let hx = ($mesh.bounds.max.x - $mesh.bounds.min.x) * 0.5;
484                let hy = ($mesh.bounds.max.y - $mesh.bounds.min.y) * 0.5;
485                let hz = ($mesh.bounds.max.z - $mesh.bounds.min.z) * 0.5;
486                let local_r = (hx * hx + hy * hy + hz * hz).sqrt();
487                let sx = model.x_axis.truncate().length();
488                let sy = model.y_axis.truncate().length();
489                let sz = model.z_axis.truncate().length();
490                let world_r = local_r * sx.max(sy).max(sz);
491
492                // Camera-visible → main passes; an off-screen shadow caster inside a
493                // cascade's light frustum → shadow maps only (main passes use
494                // `camera_count`, shadow passes the full range); otherwise skip. Shared
495                // with the studio path so the cull test + caster predicate can't drift —
496                // now the tighter AABB test (was a bounding sphere here).
497                let camera_visible = match crate::renderer::classify_visibility(
498                    &frustum,
499                    &cascade_frusta,
500                    &model,
501                    $mesh.bounds,
502                    $mat.material_type,
503                    $mat.is_transparent,
504                    $mat.albedo.w,
505                ) {
506                    crate::renderer::Visibility::Culled => continue,
507                    crate::renderer::Visibility::Camera => true,
508                    crate::renderer::Visibility::ShadowOnly => false,
509                };
510
511                // Auto-LOD (Level of Detail) Seçimi
512                let dist_to_cam = (world_c - cam_pos).length();
513                let use_lod1 = if !$mesh.lod_vbufs.is_empty() {
514                    dist_to_cam > world_r * 15.0 // Nesne boyutuna göre uzaklaştıkça LOD1'e geç (örneğin 2m çapında bir nesne 30m uzaktayken geç)
515                } else {
516                    false
517                };
518
519                let active_vbuf = if use_lod1 {
520                    $mesh.lod_vbufs[0].clone()
521                } else {
522                    $mesh.vbuf.clone()
523                };
524                let active_vertex_count = if use_lod1 {
525                    $mesh.lod_vertex_counts[0]
526                } else {
527                    $mesh.vertex_count
528                };
529
530                let packed_params = (($mat.anisotropy * 1000.0).floor() + 1000.0 * ($mat.clear_coat * 1000.0).floor() + 1000000.0 * ($mat.subsurface * 100.0).floor()) as f32;
531
532                let instance_data = crate::renderer::gpu_types::InstanceRaw {
533                    model: model.to_cols_array_2d(),
534                    albedo_color: [$mat.albedo.x, $mat.albedo.y, $mat.albedo.z, $mat.albedo.w],
535                    roughness: $mat.roughness,
536                    metallic: $mat.metallic,
537                    unlit: match $mat.material_type {
538                        crate::renderer::components::MaterialType::Skybox => 2.0,
539                        crate::renderer::components::MaterialType::Unlit => 1.0,
540                        _ => 0.0,
541                    },
542                    _padding: packed_params,
543                };
544                let skel_bg = $skeleton.map(|s: &crate::renderer::components::Skeleton| s.bind_group.clone());
545
546                // Compute the pass-routing flags up front so they can be part of the
547                // batch key (see BatchKey docs) — not just read from the first material.
548                let is_skybox = $mat.material_type == crate::renderer::components::MaterialType::Skybox;
549                let unlit = is_skybox
550                    || $mat.material_type == crate::renderer::components::MaterialType::Unlit;
551                let is_transparent = $mat.is_transparent || $mat.albedo.w < 0.99;
552
553                let key = BatchKey {
554                    vbuf_id: std::sync::Arc::as_ptr(&active_vbuf) as usize,
555                    mat_id: std::sync::Arc::as_ptr(&$mat.bind_group) as usize,
556                    skeleton_id: skel_bg.as_ref().map(|bg| std::sync::Arc::as_ptr(bg) as usize),
557                    is_transparent,
558                    unlit,
559                    is_skybox,
560                };
561
562                let batch = cache.batches.entry(key).or_insert_with(|| BatchData {
563                    vbuf: active_vbuf.clone(),
564                    bind_group: $mat.bind_group.clone(),
565                    vertex_count: active_vertex_count,
566                    unlit,
567                    is_skybox,
568                    skeleton_bind_group: skel_bg,
569                    is_transparent,
570                    instances: Vec::new(),
571                    shadow_instances: Vec::new(),
572                });
573                if camera_visible {
574                    batch.instances.push(instance_data);
575                } else {
576                    // Off-screen caster kept above for shadow maps only.
577                    batch.shadow_instances.push(instance_data);
578                }
579            };
580        }
581
582        let skeletons = world.borrow::<crate::renderer::components::Skeleton>();
583
584        if let Some(mut q) = world.query::<(&Mesh, &gizmo_physics_core::components::GlobalTransform, &Material)>() {
585            for (e, (mesh, trans, mat)) in q.iter_mut() {
586                process_mesh!(e, mesh, trans, mat, skeletons.get(e));
587            }
588        }
589        
590        let meshes = world.try_get_resource::<gizmo_core::asset::Assets<Mesh>>().ok();
591        let materials = world.try_get_resource::<gizmo_core::asset::Assets<Material>>().ok();
592        
593        if let (Some(meshes), Some(materials)) = (meshes, materials) {
594            if let Some(mut q) = world.query::<(&gizmo_core::asset::Handle<Mesh>, &gizmo_physics_core::components::GlobalTransform, &gizmo_core::asset::Handle<Material>)>() {
595                for (e, (h_mesh, trans, h_mat)) in q.iter_mut() {
596                    if let (Some(mesh), Some(mat)) = (meshes.get(h_mesh), materials.get(h_mat)) {
597                        process_mesh!(e, mesh, trans, mat, skeletons.get(e));
598                    }
599                }
600            }
601        }
602        
603        let mut local_instances: Vec<crate::renderer::gpu_types::InstanceRaw> = std::mem::take(&mut cache.instances);
604        let mut local_draw_items: Vec<DrawItem> = std::mem::take(&mut cache.draw_items);
605
606        for batch in cache.batches.values() {
607            if batch.instances.is_empty() && batch.shadow_instances.is_empty() {
608                continue;
609            }
610            let first_instance = local_instances.len() as u32;
611            // Camera-visible instances FIRST (so `camera_count` == the old culled set),
612            // then shadow-only casters — both contiguous under one DrawItem range.
613            let camera_count = batch.instances.len() as u32;
614            local_instances.extend(&batch.instances);
615            local_instances.extend(&batch.shadow_instances);
616            let instance_count = camera_count + batch.shadow_instances.len() as u32;
617
618            local_draw_items.push(DrawItem {
619                vbuf: batch.vbuf.clone(),
620                vertex_count: batch.vertex_count,
621                bind_group: batch.bind_group.clone(),
622                unlit: batch.unlit,
623                is_skybox: batch.is_skybox,
624                skeleton_bind_group: batch.skeleton_bind_group.clone(),
625                is_transparent: batch.is_transparent,
626                first_instance,
627                instance_count,
628                camera_count,
629            });
630        }
631        
632        cache.instances = local_instances;
633        cache.draw_items = local_draw_items;
634
635        // Instance limiti kontrolü (Taşmaları önlemek için capaciteyi zorla)
636        let max_instances = renderer.scene.instance_capacity;
637        let instances_slice = if cache.instances.len() > max_instances {
638            &cache.instances[..max_instances]
639        } else {
640            &cache.instances
641        };
642
643        if !instances_slice.is_empty() {
644            renderer.queue.write_buffer(
645                &renderer.scene.instance_buffer,
646                0,
647                bytemuck::cast_slice(instances_slice),
648            );
649        }
650        
651        // Pass draw_items to rendering logic by cloning the small struct (Arc clones are cheap).
652        // Also return how many instances actually made it into the GPU buffer so draw ranges
653        // can be clamped (shadow casters increase the count → guard against capacity truncation).
654        (cache.draw_items.clone(), instances_slice.len() as u32)
655    });
656    // CPU Batched Instancing replaces GPU cull for draw_items
657
658    if let Some(physics) = &renderer.gpu_physics {
659        // Her frame başında sıradaki state'i çekmek için WGPU CommandEncoder'a asenkron mapping iste.
660        physics.request_readback(encoder);
661
662        physics.compute_pass(encoder);
663        physics.debug_compute_pass(encoder);
664        physics.cull_pass(encoder, &renderer.scene.global_bind_group);
665    }
666
667    // Compute LOD (Level of Detail) Scaling.
668    // `fluid_lod == 0` disables the fluid entirely (both `compute_pass` and
669    // `render_ssfr` early-return on a zero active count), so a scene that hasn't
670    // opted into fluid never simulates or composites the default 100k-particle
671    // ocean — previously its SSFR water surface rendered over every scene as a
672    // mottled overlay that read like broken shadows.
673    let fluid_pos = Vec3::new(0.0, 5.0, 0.0);
674    let dist_to_fluid = (cam_pos - fluid_pos).length();
675    let fluid_lod = if !renderer.fluid_enabled {
676        0.0
677    } else if dist_to_fluid < 40.0 {
678        1.0
679    } else if dist_to_fluid < 80.0 {
680        0.5
681    } else if dist_to_fluid < 150.0 {
682        0.1
683    } else {
684        0.0
685    };
686
687    let dist_to_origin = cam_pos.length();
688    let particle_lod = if dist_to_origin < 50.0 {
689        1.0
690    } else if dist_to_origin < 100.0 {
691        0.5
692    } else if dist_to_origin < 200.0 {
693        0.1
694    } else {
695        0.0
696    };
697
698    // Gpu Fluid Processing
699    if let Some(fluid) = &renderer.gpu_fluid {
700        let active_fluid = (fluid.num_particles as f32 * fluid_lod) as u32;
701        fluid.compute_pass(encoder, &renderer.queue, true, active_fluid);
702    }
703
704    // Gpu Particles Processing
705    if let Some(particles) = &renderer.gpu_particles {
706        let active_parts = (particles.max_particles as f32 * particle_lod) as u32;
707        let (dt, time) = world
708            .get_resource::<gizmo_core::time::Time>()
709            .map(|t| (t.dt(), t.elapsed() as f32))
710            .unwrap_or((0.016, 0.0));
711        particles.update_params(&renderer.queue, dt, time); // time → curl-noise evrimi
712        particles.compute_pass(encoder, active_parts);
713    }
714
715    // GPU cull pass removed since we use CPU instancing
716
717    // Resize deferred G-buffers if window changed; resize SSAO + TAA to match
718    if let Some(ref mut def) = renderer.deferred {
719        def.resize(&renderer.device, renderer.size.width, renderer.size.height);
720    }
721    {
722        let w = renderer.size.width;
723        let h = renderer.size.height;
724        if let (Some(ssao), Some(def)) = (&mut renderer.ssao, &renderer.deferred) {
725            if ssao.width != w || ssao.height != h {
726                ssao.resize(&renderer.device, def, w, h);
727            }
728        }
729        if let (Some(ssr), Some(def)) = (&mut renderer.ssr, &renderer.deferred) {
730            if ssr.width != w || ssr.height != h {
731                ssr.resize(&renderer.device, def, &renderer.post.hdr_texture_view, w, h);
732            }
733        }
734        if let (Some(volumetric), Some(def)) = (&mut renderer.volumetric, &renderer.deferred) {
735            if volumetric.width != w || volumetric.height != h {
736                volumetric.resize(&renderer.device, def, w, h);
737            }
738        }
739    }
740    {
741        let w = renderer.size.width;
742        let h = renderer.size.height;
743        if let (Some(taa), Some(def)) = (&mut renderer.taa, &renderer.deferred) {
744            if taa.width != w || taa.height != h {
745                taa.resize(
746                    &renderer.device,
747                    &renderer.post.hdr_texture_view,
748                    &def.world_position_view,
749                    w,
750                    h,
751                );
752            }
753        }
754    }
755
756    // Web şemasında gölge yok (4-grup limiti, forward shader'dan shadow örneklemesi
757    // `load_shader_web` ile sökülür) — depth-only CSM/point geçitleri boşa GPU olur.
758    #[cfg(not(target_arch = "wasm32"))]
759    passes::record_shadow_passes(encoder, renderer, &draw_items, uploaded_instances);
760    passes::record_deferred_geometry(encoder, renderer, world, &draw_items, uploaded_instances);
761    passes::record_ssao(encoder, renderer);
762    passes::record_forward_and_fluid(
763        encoder, renderer, world, &draw_items, uploaded_instances, particle_lod, fluid_lod,
764    );
765    passes::record_screen_space_effects(encoder, renderer);
766    // Advance SSGI temporal ping-pong / frame counter after its passes have run.
767    if let Some(ref mut ssgi) = renderer.ssgi {
768        ssgi.advance_frame();
769    }
770    passes::record_taa_and_overlays(encoder, renderer, world);
771
772    renderer.run_post_processing(encoder, view);
773}
774
775// ============================================================
776//  RenderContext Kolaylık Metodu
777//  `ctx.default_render(world)` ile varsayılan pipeline çalışır.
778// ============================================================
779
780/// `RenderContext` üzerine eklenen kolaylık metodları.
781/// `use gizmo::prelude::*;` ile otomatik olarak dahil edilir.
782pub trait RenderContextExt {
783    /// Motorun varsayılan render pipeline'ını çalıştırır.
784    /// Deferred rendering, gölgeler, SSAO, SSR, TAA ve post-processing dahildir.
785    ///
786    /// ```ignore
787    /// fn render(world: &mut World, _state: &GameState, ctx: &mut RenderContext) {
788    ///     ctx.disable_gpu_compute();
789    ///     ctx.default_render(world);
790    /// }
791    /// ```
792    fn default_render(&mut self, world: &mut crate::core::World);
793}
794
795impl<'a> RenderContextExt for crate::renderer::RenderContext<'a> {
796    fn default_render(&mut self, world: &mut crate::core::World) {
797        let (encoder, view, renderer) = self.parts_mut();
798        default_render_pass(world, encoder, view, renderer);
799    }
800}
801
802mod passes;
803
804mod shared;
805pub use shared::{collect_scene_lights, SceneLights};
806
807/// Golden render test: drive the REAL [`default_render_pass`] over a minimal scene
808/// (one lit cube + a camera + a sun) into an offscreen target and assert that geometry
809/// actually reaches the framebuffer — a sizeable central region must differ from the
810/// background. Unlike the renderer's clear-colour readback test, this exercises the full
811/// pipeline (cull → batch → shadow/deferred/forward → post), so a regression in the
812/// pass-recording split (or any pass) that drops geometry fails here instead of slipping
813/// past CI. Needs a GPU adapter; runs in GPU-backed CI/dev.
814#[cfg(test)]
815mod batch_key_tests {
816    use super::BatchKey;
817
818    // Regression: two materials that share a cached texture bind group (same
819    // `mat_id`) and mesh (same `vbuf_id`) but route differently must NOT collide
820    // into one batch — otherwise the batch inherits the first-iterated material's
821    // transparency / lighting classification (a transparent object rendering
822    // opaque, or a PBR object routed through the unlit path). The routing flags
823    // are part of the key precisely to keep these apart while still batching
824    // identical materials together.
825    #[test]
826    fn routing_flags_distinguish_batches_sharing_a_texture() {
827        let base = BatchKey {
828            vbuf_id: 1,
829            mat_id: 42, // same cached texture bind group as the variants below
830            skeleton_id: None,
831            is_transparent: false,
832            unlit: false,
833            is_skybox: false,
834        };
835        let transparent = BatchKey {
836            is_transparent: true,
837            ..base.clone()
838        };
839        let unlit = BatchKey {
840            unlit: true,
841            ..base.clone()
842        };
843        let skybox = BatchKey {
844            is_skybox: true,
845            ..base.clone()
846        };
847
848        assert_ne!(base, transparent, "opaque and transparent must be separate batches");
849        assert_ne!(base, unlit, "PBR and unlit must be separate batches");
850        assert_ne!(base, skybox, "PBR and skybox must be separate batches");
851
852        // Identical routing + shared texture/mesh → same batch (instancing preserved).
853        assert_eq!(base, base.clone(), "identical materials must still batch together");
854    }
855}
856
857#[cfg(test)]
858mod golden_render_tests {
859    use super::default_render_pass;
860    use crate::bundles::{CameraBundle, DirectionalLightBundle};
861    use crate::core::World;
862    use crate::math::{Vec3, Vec4};
863    use crate::physics::components::{GlobalTransform, Transform};
864    use crate::renderer::asset::AssetManager;
865    use crate::renderer::components::{Material, MeshRenderer};
866    use crate::renderer::Renderer;
867
868    #[test]
869    fn default_render_pass_draws_a_cube_distinct_from_background() {
870        if !pollster::block_on(Renderer::headless_adapter_available()) {
871            eprintln!(
872                "skipping default_render_pass_draws_a_cube_distinct_from_background: \
873                 no GPU adapter available (headless render requires a GPU)"
874            );
875            return;
876        }
877        pollster::block_on(async {
878            const W: u32 = 128;
879            const H: u32 = 128;
880            const BPP: u32 = 4; // every surface format used here is 4 bytes/pixel
881
882            let mut renderer = Renderer::new_headless(W, H, None).await;
883            let mut asset_manager = AssetManager::new();
884            let mut world = World::new();
885
886            // --- one cube at the origin (create_cube spans -1..1 → size 2) ---
887            let mesh = AssetManager::create_cube(&renderer.device);
888            let tex = asset_manager.create_white_texture(
889                &renderer.device,
890                &renderer.queue,
891                &renderer.scene.texture_bind_group_layout,
892            );
893            let mat = Material::new(tex).with_pbr(Vec4::new(0.9, 0.15, 0.15, 1.0), 0.0, 1.0);
894            // Deliberately NO GlobalTransform: `default_render_pass` must backfill and
895            // sync it from the Transform (the "spawn Transform+Mesh+Material and it just
896            // renders" contract — regression guard for the empty-screen footgun).
897            let cube = world.spawn();
898            world.add_component(cube, Transform::new(Vec3::ZERO));
899            world.add_component(cube, mesh);
900            world.add_component(cube, mat);
901            world.add_component(cube, MeshRenderer::new());
902
903            // --- camera on -X looking toward +X (yaw 0 → front = +X), framing the cube ---
904            world.spawn_bundle(CameraBundle {
905                position: Vec3::new(-6.0, 0.0, 0.0),
906                yaw: 0.0,
907                pitch: 0.0,
908                primary: true,
909                ..Default::default()
910            });
911            // --- a sun so the cube is lit (role = Sun by default) ---
912            world.spawn_bundle(DirectionalLightBundle::default());
913
914            // --- run the REAL pipeline into an offscreen target ---
915            let format = renderer.config.format;
916            let target = renderer.device.create_texture(&wgpu::TextureDescriptor {
917                label: Some("golden-target"),
918                size: wgpu::Extent3d {
919                    width: W,
920                    height: H,
921                    depth_or_array_layers: 1,
922                },
923                mip_level_count: 1,
924                sample_count: 1,
925                dimension: wgpu::TextureDimension::D2,
926                format,
927                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
928                view_formats: &[],
929            });
930            let view = target.create_view(&wgpu::TextureViewDescriptor::default());
931            let mut encoder = renderer
932                .device
933                .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
934
935            default_render_pass(&mut world, &mut encoder, &view, &mut renderer);
936
937            // --- copy the result out (W*BPP = 512 → already 256-aligned) ---
938            let staging = renderer.device.create_buffer(&wgpu::BufferDescriptor {
939                label: Some("golden-readback"),
940                size: (W * H * BPP) as u64,
941                usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
942                mapped_at_creation: false,
943            });
944            encoder.copy_texture_to_buffer(
945                wgpu::TexelCopyTextureInfo {
946                    texture: &target,
947                    mip_level: 0,
948                    origin: wgpu::Origin3d::ZERO,
949                    aspect: wgpu::TextureAspect::All,
950                },
951                wgpu::TexelCopyBufferInfo {
952                    buffer: &staging,
953                    layout: wgpu::TexelCopyBufferLayout {
954                        offset: 0,
955                        bytes_per_row: Some(W * BPP),
956                        rows_per_image: Some(H),
957                    },
958                },
959                wgpu::Extent3d {
960                    width: W,
961                    height: H,
962                    depth_or_array_layers: 1,
963                },
964            );
965            renderer.queue.submit(Some(encoder.finish()));
966
967            let slice = staging.slice(..);
968            let (tx, rx) = std::sync::mpsc::channel();
969            slice.map_async(wgpu::MapMode::Read, move |v| tx.send(v).unwrap());
970            let _ = renderer.device.poll(wgpu::PollType::Wait {
971                submission_index: None,
972                timeout: None,
973            });
974            rx.recv().unwrap().unwrap();
975            let data = slice.get_mapped_range();
976
977            let px = |x: u32, y: u32| -> [u8; 4] {
978                let i = ((y * W + x) * BPP) as usize;
979                [data[i], data[i + 1], data[i + 2], data[i + 3]]
980            };
981            let background = px(2, 2); // a corner — the cube never reaches here
982            let centre = px(W / 2, H / 2);
983            assert_ne!(
984                centre, background,
985                "centre pixel equals the corner/background — default_render_pass drew no geometry"
986            );
987
988            // the cube should cover a sizeable central region, not a stray pixel
989            let mut differing = 0u32;
990            for y in 0..H {
991                for x in 0..W {
992                    if px(x, y) != background {
993                        differing += 1;
994                    }
995                }
996            }
997            let frac = differing as f32 / (W * H) as f32;
998            assert!(
999                frac > 0.05,
1000                "only {:.1}% of pixels differ from the background; the lit cube should fill a \
1001                 sizeable central region (regression dropping geometry?)",
1002                frac * 100.0
1003            );
1004        });
1005    }
1006
1007    /// Render the standard lit cube at a given camera `exposure` and return the mean of all
1008    /// RGB bytes in the frame. Shared by the exposure invariant test below.
1009    async fn render_mean_brightness(exposure: f32) -> f32 {
1010        const W: u32 = 128;
1011        const H: u32 = 128;
1012        const BPP: u32 = 4;
1013
1014        let mut renderer = Renderer::new_headless(W, H, None).await;
1015        let mut asset_manager = AssetManager::new();
1016        let mut world = World::new();
1017
1018        let mesh = AssetManager::create_cube(&renderer.device);
1019        let tex = asset_manager.create_white_texture(
1020            &renderer.device,
1021            &renderer.queue,
1022            &renderer.scene.texture_bind_group_layout,
1023        );
1024        let mat = Material::new(tex).with_pbr(Vec4::new(0.9, 0.15, 0.15, 1.0), 0.0, 1.0);
1025        let cube = world.spawn();
1026        world.add_component(cube, Transform::new(Vec3::ZERO));
1027        world.add_component(cube, GlobalTransform::default());
1028        world.add_component(cube, mesh);
1029        world.add_component(cube, mat);
1030        world.add_component(cube, MeshRenderer::new());
1031
1032        world.spawn_bundle(CameraBundle {
1033            position: Vec3::new(-6.0, 0.0, 0.0),
1034            yaw: 0.0,
1035            pitch: 0.0,
1036            primary: true,
1037            exposure,
1038            ..Default::default()
1039        });
1040        world.spawn_bundle(DirectionalLightBundle::default());
1041
1042        let format = renderer.config.format;
1043        let target = renderer.device.create_texture(&wgpu::TextureDescriptor {
1044            label: Some("exposure-target"),
1045            size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
1046            mip_level_count: 1,
1047            sample_count: 1,
1048            dimension: wgpu::TextureDimension::D2,
1049            format,
1050            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1051            view_formats: &[],
1052        });
1053        let view = target.create_view(&wgpu::TextureViewDescriptor::default());
1054        let mut encoder = renderer
1055            .device
1056            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
1057        default_render_pass(&mut world, &mut encoder, &view, &mut renderer);
1058
1059        let staging = renderer.device.create_buffer(&wgpu::BufferDescriptor {
1060            label: Some("exposure-readback"),
1061            size: (W * H * BPP) as u64,
1062            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
1063            mapped_at_creation: false,
1064        });
1065        encoder.copy_texture_to_buffer(
1066            wgpu::TexelCopyTextureInfo {
1067                texture: &target,
1068                mip_level: 0,
1069                origin: wgpu::Origin3d::ZERO,
1070                aspect: wgpu::TextureAspect::All,
1071            },
1072            wgpu::TexelCopyBufferInfo {
1073                buffer: &staging,
1074                layout: wgpu::TexelCopyBufferLayout {
1075                    offset: 0,
1076                    bytes_per_row: Some(W * BPP),
1077                    rows_per_image: Some(H),
1078                },
1079            },
1080            wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
1081        );
1082        renderer.queue.submit(Some(encoder.finish()));
1083
1084        let slice = staging.slice(..);
1085        let (tx, rx) = std::sync::mpsc::channel();
1086        slice.map_async(wgpu::MapMode::Read, move |v| tx.send(v).unwrap());
1087        let _ = renderer.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
1088        rx.recv().unwrap().unwrap();
1089        let data = slice.get_mapped_range();
1090
1091        // Mean of R,G,B over the whole frame (alpha excluded).
1092        let mut sum = 0u64;
1093        for i in (0..(W * H * BPP) as usize).step_by(BPP as usize) {
1094            sum += data[i] as u64 + data[i + 1] as u64 + data[i + 2] as u64;
1095        }
1096        sum as f32 / (W * H * 3) as f32
1097    }
1098
1099    /// Exposure is a SINGLE post-process knob applied over the whole composited HDR (the
1100    /// deferred pass no longer bakes it in). This guards that rework: a higher camera exposure
1101    /// must brighten the frame. If exposure were detached (or the deferred→post move dropped
1102    /// the wiring), the two renders would match and this fails. (Tone-mapping is non-linear so
1103    /// we assert monotonic increase, not an exact 2x.)
1104    #[test]
1105    fn camera_exposure_brightens_the_frame() {
1106        if !pollster::block_on(Renderer::headless_adapter_available()) {
1107            eprintln!("skipping camera_exposure_brightens_the_frame: no GPU adapter available");
1108            return;
1109        }
1110        pollster::block_on(async {
1111            let dim = render_mean_brightness(1.0).await;
1112            let bright = render_mean_brightness(2.0).await;
1113            assert!(
1114                bright > dim + 1.0,
1115                "higher camera exposure must brighten the scene, but exp=1.0 mean={dim:.2} \
1116                 vs exp=2.0 mean={bright:.2} (exposure not applied / detached from post?)"
1117            );
1118        });
1119    }
1120}