Skip to main content

celestialsim/gpu/
chunk_gpu.rs

1//! All RD resources for one chunk multimesh: the `ChunkRealize` compute
2//! pipeline + the shared vertex pool (positions buffer + attribute texture) +
3//! the descriptor/params buffers + uniform set + an indirect `MultiMesh`.
4//! Render-thread only. The chunk realize is a single vertex pass: it reads
5//! `chunks`/`params` and writes the vertex pool — it does NOT bind the
6//! multimesh, so the compute path is multimesh-independent.
7
8use std::sync::Arc;
9
10use bytemuck::Zeroable;
11use godot::classes::rendering_device::UniformType;
12use godot::classes::rendering_server::MultimeshTransformFormat;
13use godot::classes::{
14    ArrayMesh, Material, MultiMesh, RdUniform, RenderingDevice, RenderingServer, StandardMaterial3D,
15};
16use godot::prelude::*;
17
18use super::device;
19use super::owned::{
20    LocalDeviceSink, MainDeviceSink, Owned, RdBuffer, RdPipeline, RdShader, RdTexture, RdUniformSet,
21    RidSink,
22};
23use crate::chunk_descriptors::{pack_params, verts_per_chunk};
24use crate::chunk_mesh::reference_chunk_mesh;
25use crate::descriptors::TerrainGpu;
26use crate::gpu::ATTR_TEX_WIDTH;
27
28const CHUNK_REALIZE_SPV: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/ChunkRealize.spv"));
29const CHUNK_TILE_BAKE_SPV: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/ChunkTileBake.spv"));
30const SCATTER_PLACE_SPV: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/ScatterPlace.spv"));
31const SCATTER_COMPACT_SPV: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/ScatterCompact.spv"));
32
33/// Static config of one scatter layer's GPU side (CEL-73): the target indirect
34/// MultiMesh + the per-slot candidate capacity (`k_per_cell * 4^S_MAX`) + the
35/// instance cap the compact pass clamps to.
36#[derive(Clone, Copy, Debug)]
37pub struct ScatterConfig {
38    pub mm_rid: Rid,
39    pub capacity: u32,
40    pub max_instances: u32,
41}
42
43/// RD resources owned by one scatter layer: the cached candidate pool
44/// (`budget * capacity` records × 64 B), its params block, the compact pass's
45/// atomic counter, and the two uniform sets.
46///
47/// FIELD ORDER IS THE DROP ORDER — see the drop-order contract in `gpu::owned`:
48/// the uniform sets (dependents of the scatter shaders AND of the buffers below)
49/// must be declared, hence freed, first.
50struct ScatterLayerGpu {
51    cfg: ScatterConfig,
52    place_set: RdUniformSet,
53    compact_set: RdUniformSet,
54    pool_buf: RdBuffer,
55    params_buf: RdBuffer,
56    counter_buf: RdBuffer,
57}
58
59/// GPU resources owned by one chunk multimesh.
60///
61/// FIELD ORDER IS THE DROP ORDER (see the DROP-ORDER CONTRACT in `gpu::owned`):
62/// **uniform sets → scatter layers (whose own sets come first) → pipelines →
63/// shaders → buffers/textures**. RD frees dependents together with their parent,
64/// so a set/pipeline must go before the shader or buffer it was built from.
65/// Nothing enforces this but the declaration order below — do not reshuffle.
66pub struct ChunkGpuResources {
67    /// Who frees this set's RIDs (main device = deferred to the render thread;
68    /// local device = no-op). Every `Owned` field holds a clone.
69    sink: Arc<dyn RidSink>,
70
71    mm_rid: Rid,
72    /// Max chunk count (vertex-pool slots): the pool holds `budget * vpc` verts.
73    budget: u32,
74    res: u32,
75    radius: f32,
76    attr_w: u32,
77    /// Vertices per chunk (`verts_per_chunk(res)`).
78    vpc: u32,
79    /// Phase 4: per-chunk detail-tile resolution (atlas is `tile_res²` / slot).
80    tile_res: u32,
81    /// Detail-normal bump enable (1.0 on, 0.0 off) — debug toggle passed through
82    /// `ChunkParams` to `ChunkTileBake.slang`. Defaults to 1.0 (production look).
83    bump_enable: f32,
84    /// CPU-surface global toggle passed through `ChunkParams.surface_enabled`
85    /// (1.0 on / 0.0 off). Defaults to 0.0 (procedural, identical to today).
86    surface_enabled: f32,
87    /// CPU-surface displaced-radius factor per meter, passed through
88    /// `ChunkParams.surface_height_scale`. Defaults to 0.0.
89    surface_height_scale: f32,
90
91    // ---- 1. uniform sets (dependents — freed first) ----
92    realize_set: RdUniformSet,
93    bake_set: RdUniformSet,
94    custom_set: RdUniformSet,
95
96    /// CEL-73 scatter passes' per-layer resources (each holds its own sets first,
97    /// then its buffers). Declared here so a layer's sets die before the scatter
98    /// shaders below. Empty configs = scatter disabled.
99    scatter_layers: Vec<ScatterLayerGpu>,
100
101    // ---- 2. pipelines ----
102    pipeline: RdPipeline,
103    /// Phase-4 tile-bake compute pipeline (`ChunkTileBake.spv`).
104    bake_pipeline: RdPipeline,
105    custom_pipeline: RdPipeline,
106    scatter_pipeline: RdPipeline,
107    compact_pipeline: RdPipeline,
108
109    // ---- 3. shaders (parents of the sets + pipelines above) ----
110    shader: RdShader,
111    bake_shader: RdShader,
112    custom_shader: RdShader,
113    scatter_shader: RdShader,
114    compact_shader: RdShader,
115
116    // ---- 4. buffers / textures (parents of the sets above) ----
117    /// Per-chunk descriptors (`ChunkGpu` batch), binding 0.
118    desc_buf: RdBuffer,
119    /// `ChunkParams` (header + embedded `TerrainGpu` + tile_res), binding 1.
120    params_buf: RdBuffer,
121    /// Per-vertex attributes (2 rgba16f texels: color+layer, normal+height),
122    /// binding 2 — sampled by the surface material.
123    verts_tex: RdTexture,
124    /// Per-vertex positions (1 float4: pos+height), binding 3 — the readback
125    /// source the verification compares against `chunk_subvertex_base`.
126    verts_buf: RdBuffer,
127    /// Per-vertex world positions (1 rgba32f texel/vertex), binding 4 — the
128    /// SAMPLEABLE position store the surface material reads VERTEX from (a
129    /// spatial shader can't sample the `verts_buf` storage buffer).
130    pos_tex: RdTexture,
131    /// Per-chunk colour detail atlas (rgba8, `budget*tile_res²` texels wrapped
132    /// at `attr_w`) — sampled by the surface fragment shader.
133    color_atlas: RdTexture,
134    /// Per-chunk world-normal detail atlas (rgba8, normal encoded `*0.5+0.5`).
135    normal_atlas: RdTexture,
136    /// Per-chunk CPU-surface colour storage buffer: one rgba8-packed `u32` per
137    /// tile texel (`budget*tile_res²` u32s), indexed by linear texel `g`. Bound at
138    /// bake-set binding 4.
139    surface_color_buf: RdBuffer,
140    /// Per-chunk CPU-surface NORMAL storage buffer: one rgba8-packed `u32`
141    /// per tile texel (CPU-computed, curvature-correct).
142    surface_normal_buf: RdBuffer,
143    /// Per-chunk CPU-surface height storage buffer: one `f32` (meters) per tile
144    /// texel, indexed by linear texel `g`. Bound at bake-set binding 5 and
145    /// realize-set binding 5.
146    surface_height_buf: RdBuffer,
147    /// std430 `{chunk_count, tile_res, water_height, height_scale, cels_user[16]}`.
148    custom_params_buf: RdBuffer,
149    /// Per-realize-chunk `{path_lo, path_hi, face, pad}` aux (16 B × budget).
150    scatter_aux_buf: RdBuffer,
151    /// Per-visible-instance `{slot, depth}` gather list (8 B × budget).
152    scatter_vis_buf: RdBuffer,
153
154    // ---- plain state ----
155    /// Custom **GPU** surface (user GLSL). Assembled source set before
156    /// `ensure_ready`; the pipeline is compiled AT RUNTIME by Godot
157    /// (`device::compute_pipeline_from_glsl`) and, when it succeeds, a per-slot
158    /// dispatch fills `surface_color/height/normal` from the user's terrain
159    /// functions (the `celestial/chunk-surface-custom` node). `None` source =
160    /// no custom surface (procedural or CPU-provider path).
161    custom_source: Option<String>,
162    /// The layer's normalized sea level, fed to the template as `CELS_WATER_HEIGHT`.
163    custom_water_height: f32,
164    /// The layer's geometry displacement multiplier: used as
165    /// `surface_height_scale` (realize does `radius·(1 + h·scale)`) and passed to
166    /// the template as `CELS_HEIGHT_SCALE`.
167    custom_height_scale: f32,
168    /// Generic user params (one per `@export var name: float` on a GPU builder),
169    /// packed into the tail of the custom params buffer as `cels_user[16]`.
170    custom_user_params: Vec<f32>,
171    custom_built: bool,
172    /// Runtime GLSL compile failed — fall back to procedural (do NOT crash the
173    /// planet). Distinct from `init_failed`, which disables the whole node.
174    custom_failed: bool,
175    scatter_cfgs: Vec<ScatterConfig>,
176    scatter_built: bool,
177
178    /// Compute-only mode: build the realize pipeline + vertex pool only, with
179    /// no `MultiMesh` (used for the local-device readback verification, where
180    /// the server-side multimesh — tied to the main device — has no role).
181    compute_only: bool,
182    /// Whether the pipeline + pool + set are built.
183    built: bool,
184    init_failed: bool,
185    /// Keep-alive for the server-side multimesh (created here when `mm_rid` was
186    /// `Invalid`); its RD buffers live as long as this `Gd` does. SERVER-side
187    /// RIDs (RenderingServer / Resource), not RenderingDevice ones — freed by
188    /// dropping the `Gd`, not through the sink.
189    multimesh: Option<Gd<MultiMesh>>,
190    /// Keep-alives so the reference mesh + material outlive the multimesh.
191    _template: Option<Gd<ArrayMesh>>,
192    _material: Option<Gd<Material>>,
193}
194
195impl ChunkGpuResources {
196    /// Construct an (unbuilt) resource set on the MAIN `RenderingDevice`.
197    /// `mm_rid` is the target multimesh, or `Rid::Invalid` to have `ensure_ready`
198    /// create its own (CEL-58 order).
199    pub fn new(mm_rid: Rid, budget: u32, res: u32, tile_res: u32, radius: f32) -> Self {
200        Self::with_sink(mm_rid, budget, res, tile_res, radius, false, MainDeviceSink::new())
201    }
202
203    /// Compute-only resources (no `MultiMesh`) on a LOCAL `RenderingDevice`, for
204    /// the readback verification. `ensure_ready` builds just the realize pipeline
205    /// + pool; the local device frees its own resources when it dies, so the sink
206    /// is a no-op.
207    pub fn new_compute_only(budget: u32, res: u32, tile_res: u32, radius: f32) -> Self {
208        Self::with_sink(Rid::Invalid, budget, res, tile_res, radius, true, LocalDeviceSink::new())
209    }
210
211    /// Construct with an explicit [`RidSink`] (tests inject a spy).
212    pub fn with_sink(
213        mm_rid: Rid,
214        budget: u32,
215        res: u32,
216        tile_res: u32,
217        radius: f32,
218        compute_only: bool,
219        sink: Arc<dyn RidSink>,
220    ) -> Self {
221        Self {
222            mm_rid,
223            budget: budget.max(1),
224            res,
225            radius,
226            attr_w: ATTR_TEX_WIDTH,
227            vpc: verts_per_chunk(res),
228            tile_res: tile_res.max(1),
229            bump_enable: 0.0,
230            surface_enabled: 0.0,
231            surface_height_scale: 0.0,
232            realize_set: Owned::invalid(sink.clone()),
233            bake_set: Owned::invalid(sink.clone()),
234            custom_set: Owned::invalid(sink.clone()),
235            scatter_layers: Vec::new(),
236            pipeline: Owned::invalid(sink.clone()),
237            bake_pipeline: Owned::invalid(sink.clone()),
238            custom_pipeline: Owned::invalid(sink.clone()),
239            scatter_pipeline: Owned::invalid(sink.clone()),
240            compact_pipeline: Owned::invalid(sink.clone()),
241            shader: Owned::invalid(sink.clone()),
242            bake_shader: Owned::invalid(sink.clone()),
243            custom_shader: Owned::invalid(sink.clone()),
244            scatter_shader: Owned::invalid(sink.clone()),
245            compact_shader: Owned::invalid(sink.clone()),
246            desc_buf: Owned::invalid(sink.clone()),
247            params_buf: Owned::invalid(sink.clone()),
248            verts_tex: Owned::invalid(sink.clone()),
249            verts_buf: Owned::invalid(sink.clone()),
250            pos_tex: Owned::invalid(sink.clone()),
251            color_atlas: Owned::invalid(sink.clone()),
252            normal_atlas: Owned::invalid(sink.clone()),
253            surface_color_buf: Owned::invalid(sink.clone()),
254            surface_normal_buf: Owned::invalid(sink.clone()),
255            surface_height_buf: Owned::invalid(sink.clone()),
256            custom_params_buf: Owned::invalid(sink.clone()),
257            scatter_aux_buf: Owned::invalid(sink.clone()),
258            scatter_vis_buf: Owned::invalid(sink.clone()),
259            custom_source: None,
260            custom_water_height: 0.45,
261            custom_height_scale: 1.0,
262            custom_user_params: Vec::new(),
263            custom_built: false,
264            custom_failed: false,
265            scatter_cfgs: Vec::new(),
266            scatter_built: false,
267            compute_only,
268            built: false,
269            init_failed: false,
270            multimesh: None,
271            _template: None,
272            _material: None,
273            sink,
274        }
275    }
276
277    /// Set the detail-normal bump enable (1.0 on, 0.0 off) before the params are
278    /// packed. Takes effect on the next `upload_params`/bake.
279    pub fn set_bump_enable(&mut self, v: f32) {
280        self.bump_enable = v;
281    }
282
283    /// Set the CPU-surface provider params (global toggle +
284    /// displaced-radius factor per unit) before the params are packed. Takes
285    /// effect on the next `upload_params`/bake.
286    pub fn set_surface(&mut self, enabled: f32, height_scale: f32) {
287        self.surface_enabled = enabled;
288        self.surface_height_scale = height_scale;
289    }
290
291    /// Install a custom **GPU** surface: assembled GLSL `source`
292    /// (`crate::custom_surface::assemble_source` output) plus the layer's
293    /// `water_height` / `height_scale`. Call before the first `ensure_ready`; the
294    /// pipeline is compiled lazily on the render thread. A shader-file change
295    /// rebuilds the whole job, so this is only ever set once per job.
296    pub fn set_custom_surface(
297        &mut self,
298        source: String,
299        water_height: f32,
300        height_scale: f32,
301        user_params: Vec<f32>,
302    ) {
303        debug_assert!(!self.custom_built, "custom surface must be set before ensure_ready");
304        self.custom_source = Some(source);
305        self.custom_water_height = water_height;
306        self.custom_height_scale = height_scale;
307        self.custom_user_params = user_params;
308    }
309
310    /// Update the custom surface's LIVE knobs (water level + geometry scale +
311    /// generic user params) without recompiling. Picked up by the next
312    /// `upload_custom_params`; pair with a cache `invalidate_all` so resident
313    /// chunks re-fill with the new values. No-op if no custom surface is
314    /// installed.
315    pub fn set_custom_knobs(&mut self, water_height: f32, height_scale: f32, user_params: Vec<f32>) {
316        self.custom_water_height = water_height;
317        self.custom_height_scale = height_scale;
318        self.custom_user_params = user_params;
319    }
320
321    /// Was a custom GPU surface requested (source installed)?
322    pub fn custom_requested(&self) -> bool {
323        self.custom_source.is_some()
324    }
325
326    /// Is the custom GPU surface compiled and ready to dispatch? `false` while
327    /// still building, or permanently after a compile error (fall back to
328    /// procedural).
329    pub fn custom_ready(&self) -> bool {
330        self.custom_built && !self.custom_failed && self.custom_pipeline.is_valid()
331    }
332
333    /// The sink every owned RID of this set is released through.
334    fn sink(&self) -> Arc<dyn RidSink> {
335        self.sink.clone()
336    }
337
338    /// The custom layer's geometry displacement multiplier (used as
339    /// `surface_height_scale` for the realize displacement).
340    pub fn custom_height_scale(&self) -> f32 {
341        self.custom_height_scale
342    }
343
344    /// Configure the scatter layers (CEL-73). Call before the first
345    /// `ensure_ready`; layer resources are built there once every target
346    /// multimesh's RD buffers exist.
347    pub fn set_scatter_configs(&mut self, cfgs: Vec<ScatterConfig>) {
348        debug_assert!(!self.scatter_built, "scatter configs must be set before ensure_ready");
349        self.scatter_cfgs = cfgs;
350    }
351
352    /// Rows in the verts texture (2 texels per vertex) for the whole pool.
353    fn verts_tex_rows(&self) -> u32 {
354        (self.budget as u64 * self.vpc as u64 * 2).div_ceil(self.attr_w as u64).max(1) as u32
355    }
356
357    /// Rows in the position texture (1 texel per vertex) for the whole pool.
358    fn pos_tex_rows(&self) -> u32 {
359        (self.budget as u64 * self.vpc as u64).div_ceil(self.attr_w as u64).max(1) as u32
360    }
361
362    /// Texels per atlas slot (`tile_res²`).
363    fn tile_texels(&self) -> u64 {
364        self.tile_res as u64 * self.tile_res as u64
365    }
366
367    /// Rows in a detail atlas (1 texel per tile texel) for the whole budget.
368    fn atlas_rows(&self) -> u32 {
369        (self.budget as u64 * self.tile_texels()).div_ceil(self.attr_w as u64).max(1) as u32
370    }
371
372    /// Build the pipeline + vertex pool + uniform set + multimesh. Idempotent;
373    /// returns false (retry next frame) until everything — including the
374    /// multimesh's lazily-created command buffer — exists.
375    pub fn ensure_ready(&mut self, rd: &mut Gd<RenderingDevice>) -> bool {
376        if self.init_failed {
377            return false;
378        }
379        if !self.built {
380            // CEL-58 alloc order: pipeline → pool buffers/texture → set, then
381            // the multimesh (indirect alloc BEFORE set_mesh).
382            let sink = self.sink();
383            let Some((shader, pipeline)) =
384                device::compute_pipeline(rd, &sink, CHUNK_REALIZE_SPV, "ChunkRealize")
385            else {
386                self.init_failed = true;
387                return false;
388            };
389            self.shader = shader;
390            self.pipeline = pipeline;
391
392            // Phase-4 bake pipeline (shares the desc/params buffers).
393            let Some((bake_shader, bake_pipeline)) =
394                device::compute_pipeline(rd, &sink, CHUNK_TILE_BAKE_SPV, "ChunkTileBake")
395            else {
396                self.init_failed = true;
397                return false;
398            };
399            self.bake_shader = bake_shader;
400            self.bake_pipeline = bake_pipeline;
401
402            let desc_bytes =
403                self.budget as usize * std::mem::size_of::<crate::chunk_descriptors::ChunkGpu>();
404            self.desc_buf = device::storage_buffer(rd, &sink, &vec![0u8; desc_bytes]);
405            // 96-byte ChunkParams; pre-fill with a terrain-off header.
406            let params0 = pack_params(
407                self.res,
408                self.vpc,
409                self.attr_w,
410                0,
411                self.tile_res,
412                self.bump_enable,
413                &TerrainGpu::zeroed(),
414                0.0,
415                0.0,
416            );
417            self.params_buf = device::storage_buffer(rd, &sink, &params0);
418            self.verts_buf =
419                device::storage_buffer_empty(rd, &sink, self.budget as u64 * self.vpc as u64 * 16);
420            self.verts_tex =
421                device::attribute_texture(rd, &sink, self.attr_w, self.verts_tex_rows());
422            self.pos_tex = device::position_texture(rd, &sink, self.attr_w, self.pos_tex_rows());
423            self.color_atlas = device::atlas_texture(rd, &sink, self.attr_w, self.atlas_rows());
424            self.normal_atlas = device::atlas_texture(rd, &sink, self.attr_w, self.atlas_rows());
425
426            // Per-chunk CPU-surface color/height storage buffers: one u32 / one
427            // f32 per tile texel across the whole budget (4 bytes each).
428            let surface_bytes = self.budget as u64 * self.tile_texels() * 4;
429            self.surface_color_buf = device::storage_buffer_empty(rd, &sink, surface_bytes);
430            self.surface_height_buf = device::storage_buffer_empty(rd, &sink, surface_bytes);
431            self.surface_normal_buf = device::storage_buffer_empty(rd, &sink, surface_bytes);
432
433            let uniforms: Array<Gd<RdUniform>> = [
434                device::uniform(UniformType::STORAGE_BUFFER, 0, self.desc_buf.rid()),
435                device::uniform(UniformType::STORAGE_BUFFER, 1, self.params_buf.rid()),
436                device::uniform(UniformType::IMAGE, 2, self.verts_tex.rid()),
437                device::uniform(UniformType::STORAGE_BUFFER, 3, self.verts_buf.rid()),
438                device::uniform(UniformType::IMAGE, 4, self.pos_tex.rid()),
439                device::uniform(UniformType::STORAGE_BUFFER, 5, self.surface_height_buf.rid()),
440            ]
441            .into_iter()
442            .collect();
443            self.realize_set =
444                Owned::new(rd.uniform_set_create(&uniforms, self.shader.rid(), 0), sink.clone());
445
446            // Bake set (set 0 against the bake shader): desc + params + atlases.
447            let bake_uniforms: Array<Gd<RdUniform>> = [
448                device::uniform(UniformType::STORAGE_BUFFER, 0, self.desc_buf.rid()),
449                device::uniform(UniformType::STORAGE_BUFFER, 1, self.params_buf.rid()),
450                device::uniform(UniformType::IMAGE, 2, self.color_atlas.rid()),
451                device::uniform(UniformType::IMAGE, 3, self.normal_atlas.rid()),
452                device::uniform(UniformType::STORAGE_BUFFER, 4, self.surface_color_buf.rid()),
453                device::uniform(UniformType::STORAGE_BUFFER, 5, self.surface_height_buf.rid()),
454                device::uniform(UniformType::STORAGE_BUFFER, 6, self.surface_normal_buf.rid()),
455            ]
456            .into_iter()
457            .collect();
458            self.bake_set = Owned::new(
459                rd.uniform_set_create(&bake_uniforms, self.bake_shader.rid(), 0),
460                sink.clone(),
461            );
462
463            if !self.compute_only && self.mm_rid.is_invalid() {
464                self.create_multimesh();
465            }
466            self.built = true;
467        }
468
469        // Custom GPU surface (user GLSL): compile once, on the render thread, now
470        // that the desc + surface buffers exist. A compile error disables the
471        // custom path (falls back to procedural) WITHOUT failing the planet.
472        if self.custom_source.is_some() && !self.custom_built && !self.custom_failed {
473            self.ensure_custom_ready(rd);
474        }
475
476        if self.compute_only {
477            // No multimesh to wait on. Scatter (CEL-73) still builds its
478            // PLACE side for the local-device verification (configs carry
479            // `Rid::Invalid` multimeshes, so no compact sets are created).
480            if !self.scatter_cfgs.is_empty() && !self.scatter_built {
481                return self.ensure_scatter_ready(rd);
482            }
483            return true;
484        }
485
486        // The multimesh's RD buffers are created lazily by the renderer.
487        let rs = RenderingServer::singleton();
488        if rs.multimesh_get_buffer_rd_rid(self.mm_rid).is_invalid()
489            || rs.multimesh_get_command_buffer_rd_rid(self.mm_rid).is_invalid()
490        {
491            return false;
492        }
493
494        // CEL-73 second stage: scatter pipelines + per-layer pools/sets, once
495        // every layer's lazily-created multimesh RD buffers exist too.
496        if !self.scatter_cfgs.is_empty() && !self.scatter_built {
497            if !self.ensure_scatter_ready(rd) {
498                return false;
499            }
500        }
501        true
502    }
503
504    /// Build the scatter pipelines + per-layer resources (CEL-73). Returns
505    /// false (retry next frame) while any layer's multimesh RD buffers are
506    /// still pending.
507    fn ensure_scatter_ready(&mut self, rd: &mut Gd<RenderingDevice>) -> bool {
508        let rs = RenderingServer::singleton();
509        for cfg in &self.scatter_cfgs {
510            // A config without a multimesh (compute-only verification) skips
511            // the wait — its compact set simply stays Invalid.
512            if cfg.mm_rid.is_valid()
513                && (rs.multimesh_get_buffer_rd_rid(cfg.mm_rid).is_invalid()
514                    || rs.multimesh_get_command_buffer_rd_rid(cfg.mm_rid).is_invalid())
515            {
516                return false;
517            }
518        }
519
520        let sink = self.sink();
521        let Some((shader, pipeline)) =
522            device::compute_pipeline(rd, &sink, SCATTER_PLACE_SPV, "ScatterPlace")
523        else {
524            self.init_failed = true;
525            return false;
526        };
527        self.scatter_shader = shader;
528        self.scatter_pipeline = pipeline;
529        let Some((cshader, cpipeline)) =
530            device::compute_pipeline(rd, &sink, SCATTER_COMPACT_SPV, "ScatterCompact")
531        else {
532            self.init_failed = true;
533            return false;
534        };
535        self.compact_shader = cshader;
536        self.compact_pipeline = cpipeline;
537
538        self.scatter_aux_buf = device::storage_buffer_empty(rd, &sink, self.budget as u64 * 16);
539        // 1 uint (slot) per visible chunk.
540        self.scatter_vis_buf = device::storage_buffer_empty(rd, &sink, self.budget as u64 * 4);
541
542        let cfgs = std::mem::take(&mut self.scatter_cfgs);
543        for cfg in &cfgs {
544            let pool_bytes = self.budget as u64 * cfg.capacity as u64 * 64;
545            let pool_buf = device::storage_buffer_empty(rd, &sink, pool_bytes);
546            let params_buf = device::storage_buffer(
547                rd,
548                &sink,
549                &[0u8; std::mem::size_of::<crate::scatter_descriptors::ScatterParamsGpu>()],
550            );
551            let counter_buf = device::storage_buffer_empty(rd, &sink, 4);
552
553            let mut layer = ScatterLayerGpu {
554                cfg: *cfg,
555                place_set: Owned::invalid(sink.clone()),
556                compact_set: Owned::invalid(sink.clone()),
557                pool_buf,
558                params_buf,
559                counter_buf,
560            };
561            self.build_scatter_sets(rd, &mut layer);
562            self.scatter_layers.push(layer);
563        }
564        self.scatter_built = true;
565        true
566    }
567
568    /// Compile the user's custom-surface GLSL and build its uniform set (render
569    /// thread). On a compile error, set `custom_failed` and return — the planet
570    /// keeps running on the procedural path. Its inputs (desc + surface buffers)
571    /// are created in the base `built` block, so this can run right after.
572    fn ensure_custom_ready(&mut self, rd: &mut Gd<RenderingDevice>) {
573        let Some(src) = self.custom_source.clone() else { return };
574        let sink = self.sink();
575        let Some((shader, pipeline)) =
576            device::compute_pipeline_from_glsl(rd, &sink, &src, "CustomSurface")
577        else {
578            self.custom_failed = true;
579            return;
580        };
581        self.custom_shader = shader;
582        self.custom_pipeline = pipeline;
583        let params0 = crate::custom_surface::pack_params(
584            0,
585            self.tile_res,
586            self.custom_water_height,
587            self.custom_height_scale,
588            &self.custom_user_params,
589        );
590        self.custom_params_buf = device::storage_buffer(rd, &sink, &params0);
591        let uniforms: Array<Gd<RdUniform>> = [
592            device::uniform(UniformType::STORAGE_BUFFER, 0, self.desc_buf.rid()),
593            device::uniform(UniformType::STORAGE_BUFFER, 1, self.surface_color_buf.rid()),
594            device::uniform(UniformType::STORAGE_BUFFER, 2, self.surface_height_buf.rid()),
595            device::uniform(UniformType::STORAGE_BUFFER, 3, self.surface_normal_buf.rid()),
596            device::uniform(UniformType::STORAGE_BUFFER, 4, self.custom_params_buf.rid()),
597        ]
598        .into_iter()
599        .collect();
600        self.custom_set =
601            Owned::new(rd.uniform_set_create(&uniforms, self.custom_shader.rid(), 0), sink.clone());
602        self.custom_built = true;
603    }
604
605    /// (Re)create one layer's place/compact uniform sets. The compact set binds
606    /// the layer's multimesh RD buffers, which the renderer can RECREATE when
607    /// the multimesh is first actually drawn — invalidating the set — so this
608    /// is also called from `upload_scatter` whenever a set has gone invalid. The
609    /// assignment of a fresh set over a live one DROPS the old handle, which frees
610    /// the stale set (this used to leak).
611    fn build_scatter_sets(&self, rd: &mut Gd<RenderingDevice>, layer: &mut ScatterLayerGpu) {
612        let rs = RenderingServer::singleton();
613        let sink = self.sink();
614        let place_uniforms: Array<Gd<RdUniform>> = [
615            device::uniform(UniformType::STORAGE_BUFFER, 0, self.desc_buf.rid()),
616            device::uniform(UniformType::STORAGE_BUFFER, 1, self.scatter_aux_buf.rid()),
617            device::uniform(UniformType::STORAGE_BUFFER, 2, layer.params_buf.rid()),
618            device::uniform(UniformType::STORAGE_BUFFER, 3, layer.pool_buf.rid()),
619            // CPU-surface elevation grid: place samples the SAME heightmap
620            // realize displaces the terrain with (procedural route ignores it).
621            device::uniform(UniformType::STORAGE_BUFFER, 4, self.surface_height_buf.rid()),
622        ]
623        .into_iter()
624        .collect();
625        layer.place_set = Owned::new(
626            rd.uniform_set_create(&place_uniforms, self.scatter_shader.rid(), 0),
627            sink.clone(),
628        );
629
630        if layer.cfg.mm_rid.is_invalid() {
631            // Compute-only verification: place side only.
632            layer.compact_set = Owned::invalid(sink);
633            return;
634        }
635        let mm_buf = rs.multimesh_get_buffer_rd_rid(layer.cfg.mm_rid);
636        let cmd_buf = rs.multimesh_get_command_buffer_rd_rid(layer.cfg.mm_rid);
637        let compact_uniforms: Array<Gd<RdUniform>> = [
638            device::uniform(UniformType::STORAGE_BUFFER, 0, layer.params_buf.rid()),
639            device::uniform(UniformType::STORAGE_BUFFER, 1, layer.pool_buf.rid()),
640            device::uniform(UniformType::STORAGE_BUFFER, 2, self.scatter_vis_buf.rid()),
641            device::uniform(UniformType::STORAGE_BUFFER, 3, layer.counter_buf.rid()),
642            device::uniform(UniformType::STORAGE_BUFFER, 4, mm_buf),
643            device::uniform(UniformType::STORAGE_BUFFER, 5, cmd_buf),
644        ]
645        .into_iter()
646        .collect();
647        layer.compact_set = Owned::new(
648            rd.uniform_set_create(&compact_uniforms, self.compact_shader.rid(), 0),
649            sink,
650        );
651    }
652
653    /// Upload one execute's scatter staging (CEL-73): the realize batch's aux
654    /// paths, the visible `{slot, depth}` list, and each layer's fresh params
655    /// snapshot; zero each layer's compact counter + indirect draw count so the
656    /// compact pass rebuilds the draw list from scratch. Also heals any uniform
657    /// set the renderer invalidated by recreating a multimesh buffer.
658    pub fn upload_scatter(
659        &mut self,
660        rd: &mut Gd<RenderingDevice>,
661        aux_bytes: &[u8],
662        vis_bytes: &[u8],
663        layer_params: &[Vec<u8>],
664    ) {
665        if !self.scatter_built {
666            return;
667        }
668        let mut layers = std::mem::take(&mut self.scatter_layers);
669        for layer in &mut layers {
670            if !rd.uniform_set_is_valid(layer.place_set.rid())
671                || (layer.cfg.mm_rid.is_valid() && !rd.uniform_set_is_valid(layer.compact_set.rid()))
672            {
673                self.build_scatter_sets(rd, layer);
674            }
675        }
676        self.scatter_layers = layers;
677        if !aux_bytes.is_empty() {
678            let n = aux_bytes.len().min(self.budget as usize * 16);
679            rd.buffer_update(
680                self.scatter_aux_buf.rid(),
681                0,
682                n as u32,
683                &PackedByteArray::from(&aux_bytes[..n]),
684            );
685        }
686        if !vis_bytes.is_empty() {
687            let n = vis_bytes.len().min(self.budget as usize * 4);
688            rd.buffer_update(
689                self.scatter_vis_buf.rid(),
690                0,
691                n as u32,
692                &PackedByteArray::from(&vis_bytes[..n]),
693            );
694        }
695        let rs = RenderingServer::singleton();
696        let zero = PackedByteArray::from(&0u32.to_le_bytes()[..]);
697        for (layer, params) in self.scatter_layers.iter().zip(layer_params.iter()) {
698            rd.buffer_update(
699                layer.params_buf.rid(),
700                0,
701                params.len() as u32,
702                &PackedByteArray::from(&params[..]),
703            );
704            rd.buffer_update(layer.counter_buf.rid(), 0, 4, &zero);
705            if layer.cfg.mm_rid.is_valid() {
706                let cmd = rs.multimesh_get_command_buffer_rd_rid(layer.cfg.mm_rid);
707                if cmd.is_valid() {
708                    rd.buffer_update(cmd, 4, 4, &zero);
709                }
710            }
711        }
712    }
713
714    /// Record layer `li`'s place dispatch: one thread per candidate of each
715    /// realize-batch chunk, clamped to the pool capacity (device-loss guard).
716    pub fn record_scatter_place(
717        &self,
718        rd: &mut Gd<RenderingDevice>,
719        list: i64,
720        li: usize,
721        chunk_count: u32,
722    ) {
723        let Some(layer) = self.scatter_layers.get(li) else { return };
724        let max_threads = self.budget * layer.cfg.capacity;
725        let threads = (chunk_count * layer.cfg.capacity).min(max_threads);
726        let groups = threads.div_ceil(64);
727        if groups == 0 {
728            return;
729        }
730        rd.compute_list_bind_compute_pipeline(list, self.scatter_pipeline.rid());
731        rd.compute_list_bind_uniform_set(list, layer.place_set.rid(), 0);
732        rd.compute_list_dispatch(list, groups, 1, 1);
733        rd.compute_list_add_barrier(list);
734    }
735
736    /// Record layer `li`'s compact dispatch: one thread per cached candidate of
737    /// each VISIBLE chunk, clamped to the pool capacity.
738    pub fn record_scatter_compact(
739        &self,
740        rd: &mut Gd<RenderingDevice>,
741        list: i64,
742        li: usize,
743        vis_count: u32,
744    ) {
745        let Some(layer) = self.scatter_layers.get(li) else { return };
746        if !layer.compact_set.is_valid() {
747            return; // compute-only verification: no multimesh to compact into
748        }
749        let max_threads = self.budget * layer.cfg.capacity;
750        let threads = (vis_count * layer.cfg.capacity).min(max_threads);
751        let groups = threads.div_ceil(64);
752        if groups == 0 {
753            return;
754        }
755        rd.compute_list_bind_compute_pipeline(list, self.compact_pipeline.rid());
756        rd.compute_list_bind_uniform_set(list, layer.compact_set.rid(), 0);
757        rd.compute_list_dispatch(list, groups, 1, 1);
758        rd.compute_list_add_barrier(list);
759    }
760
761    /// Number of built scatter layers.
762    pub fn scatter_layer_count(&self) -> usize {
763        self.scatter_layers.len()
764    }
765
766    /// Layer `li`'s cached candidate pool buffer (readback for the debug-only
767    /// GPU-vs-CPU placement verification).
768    pub fn scatter_pool_buf(&self, li: usize) -> Rid {
769        self.scatter_layers.get(li).map_or(Rid::Invalid, |l| l.pool_buf.rid())
770    }
771
772    /// Allocate a server-side indirect MultiMesh (CEL-58 order) and keep its
773    /// `Gd` alive.
774    fn create_multimesh(&mut self) {
775        let mut rs = RenderingServer::singleton();
776        let material: Gd<Material> = StandardMaterial3D::new_gd().upcast();
777        let template = reference_chunk_mesh(self.res, &material);
778        let multimesh = MultiMesh::new_gd();
779        let mm_rid = multimesh.get_rid();
780        rs.multimesh_allocate_data_ex(mm_rid, self.budget as i32, MultimeshTransformFormat::TRANSFORM_3D)
781            .custom_data_format(true)
782            .use_indirect(true)
783            .done();
784        rs.multimesh_set_mesh(mm_rid, template.get_rid());
785        self.mm_rid = mm_rid;
786        self.multimesh = Some(multimesh);
787        self._template = Some(template);
788        self._material = Some(material);
789    }
790
791    /// Upload one batch of packed chunk descriptors (`count` × `ChunkGpu`).
792    ///
793    /// Guards `count <= budget`: an over-budget batch is clamped to the desc
794    /// buffer's capacity so it can never overrun the descriptor buffer / vertex
795    /// pool (the stale-oversized-buffer shape that caused a KRACKAN1 device-loss).
796    pub fn upload_descs(&mut self, rd: &mut Gd<RenderingDevice>, bytes: &[u8], count: u32) {
797        if bytes.is_empty() {
798            return;
799        }
800        debug_assert!(
801            count <= self.budget,
802            "chunk realize count {count} exceeds budget {} — would overrun desc buffer",
803            self.budget
804        );
805        let max = self.budget as usize * std::mem::size_of::<crate::chunk_descriptors::ChunkGpu>();
806        let n = bytes.len().min(max);
807        rd.buffer_update(self.desc_buf.rid(), 0, n as u32, &PackedByteArray::from(&bytes[..n]));
808    }
809
810    /// Rewrite the whole `ChunkParams` for a realize batch of `chunk_count`
811    /// chunks with the given terrain.
812    pub fn upload_params(
813        &mut self,
814        rd: &mut Gd<RenderingDevice>,
815        chunk_count: u32,
816        terrain: &TerrainGpu,
817    ) {
818        let bytes = pack_params(
819            self.res,
820            self.vpc,
821            self.attr_w,
822            chunk_count,
823            self.tile_res,
824            self.bump_enable,
825            terrain,
826            self.surface_enabled,
827            self.surface_height_scale,
828        );
829        rd.buffer_update(
830            self.params_buf.rid(),
831            0,
832            bytes.len() as u32,
833            &PackedByteArray::from(&bytes[..]),
834        );
835    }
836
837    /// Update only the embedded terrain (offset 16, 64 bytes) of `ChunkParams`.
838    pub fn upload_terrain(&mut self, rd: &mut Gd<RenderingDevice>, terrain: &TerrainGpu) {
839        let bytes = bytemuck::bytes_of(terrain);
840        rd.buffer_update(self.params_buf.rid(), 16, bytes.len() as u32, &PackedByteArray::from(bytes));
841    }
842
843    /// Upload one chunk's CPU-surface color + height patch into the per-slot
844    /// region of the surface storage buffers.
845    ///
846    /// `color_bytes` is `tile_res²` rgba8-packed texels (little-endian, 4 bytes
847    /// each); `height` is `tile_res²` elevations in meters. Both are indexed by the
848    /// linear texel `g = slot*tile_texels + ty*tile_res + tx`, matching the `g`
849    /// `ChunkTileBake.slang` computes for `color_atlas`. No-op if `slot >= budget`,
850    /// either buffer is unbuilt, or the inputs are empty.
851    pub fn upload_surface(
852        &mut self,
853        rd: &mut Gd<RenderingDevice>,
854        slot: u32,
855        color_bytes: &[u8],
856        height: &[f32],
857        normal_bytes: &[u8],
858    ) {
859        if slot >= self.budget
860            || !self.surface_color_buf.is_valid()
861            || !self.surface_height_buf.is_valid()
862            || !self.surface_normal_buf.is_valid()
863        {
864            return;
865        }
866        let off = slot as u64 * self.tile_texels() * 4;
867        if !color_bytes.is_empty() {
868            rd.buffer_update(
869                self.surface_color_buf.rid(),
870                off as u32,
871                color_bytes.len() as u32,
872                &PackedByteArray::from(color_bytes),
873            );
874        }
875        if !height.is_empty() {
876            let h: &[u8] = bytemuck::cast_slice(height);
877            rd.buffer_update(
878                self.surface_height_buf.rid(),
879                off as u32,
880                h.len() as u32,
881                &PackedByteArray::from(h),
882            );
883        }
884        if !normal_bytes.is_empty() {
885            rd.buffer_update(
886                self.surface_normal_buf.rid(),
887                off as u32,
888                normal_bytes.len() as u32,
889                &PackedByteArray::from(normal_bytes),
890            );
891        }
892    }
893
894    /// Upload per-instance data into the multimesh buffer and set the indirect
895    /// draw instance count (`cmd[1]`, byte offset 4). Mirrors
896    /// `PlanetGpu::upload_face` / `validate_instances`' command-buffer write.
897    pub fn upload_instances(&mut self, rd: &mut Gd<RenderingDevice>, bytes: &[u8], count: u32) {
898        let rs = RenderingServer::singleton();
899        let buffer = rs.multimesh_get_buffer_rd_rid(self.mm_rid);
900        let command = rs.multimesh_get_command_buffer_rd_rid(self.mm_rid);
901        if buffer.is_invalid() || command.is_invalid() {
902            return;
903        }
904        if !bytes.is_empty() {
905            rd.buffer_update(buffer, 0, bytes.len() as u32, &PackedByteArray::from(bytes));
906        }
907        let c = count.min(self.budget);
908        rd.buffer_update(command, 4, 4, &PackedByteArray::from(&c.to_le_bytes()[..]));
909    }
910
911    /// Record the realize dispatch into `list`: one thread per pool vertex
912    /// (`vert_threads` total), then a barrier so readers see the writes.
913    pub fn record_realize(&self, rd: &mut Gd<RenderingDevice>, list: i64, vert_threads: u32) {
914        // Guard the dispatch against an over-budget batch: clamp to the pool's
915        // vertex capacity so the realize can never write past `verts`/`pos_tex`
916        // (defensive against the stale-oversized-buffer device-loss shape).
917        let max_threads = self.budget * self.vpc;
918        debug_assert!(
919            vert_threads <= max_threads,
920            "chunk realize vert_threads {vert_threads} exceeds pool capacity {max_threads}"
921        );
922        let vert_threads = vert_threads.min(max_threads);
923        let groups = vert_threads.div_ceil(64);
924        if groups == 0 {
925            return;
926        }
927        rd.compute_list_bind_compute_pipeline(list, self.pipeline.rid());
928        rd.compute_list_bind_uniform_set(list, self.realize_set.rid(), 0);
929        rd.compute_list_dispatch(list, groups, 1, 1);
930        rd.compute_list_add_barrier(list);
931    }
932
933    /// Record the Phase-4 tile-bake dispatch into `list`: one thread per tile
934    /// texel (`texel_threads` total = `realize_count * tile_res²`), clamped to
935    /// the atlas capacity so a stale/over-budget batch can never write past the
936    /// atlas (the same defensive guard as `record_realize`).
937    pub fn record_bake(&self, rd: &mut Gd<RenderingDevice>, list: i64, texel_threads: u32) {
938        let max_threads = (self.budget as u64 * self.tile_texels()).min(u32::MAX as u64) as u32;
939        debug_assert!(
940            texel_threads <= max_threads,
941            "chunk bake texel_threads {texel_threads} exceeds atlas capacity {max_threads}"
942        );
943        let texel_threads = texel_threads.min(max_threads);
944        let groups = texel_threads.div_ceil(64);
945        if groups == 0 {
946            return;
947        }
948        rd.compute_list_bind_compute_pipeline(list, self.bake_pipeline.rid());
949        rd.compute_list_bind_uniform_set(list, self.bake_set.rid(), 0);
950        rd.compute_list_dispatch(list, groups, 1, 1);
951        rd.compute_list_add_barrier(list);
952    }
953
954    /// Refresh the custom-surface params UBO with this batch's `chunk_count`
955    /// (and the live water/height knobs). A buffer transfer — call from the
956    /// upload node, OUTSIDE any compute list. No-op unless the custom pipeline
957    /// is ready.
958    pub fn upload_custom_params(&self, rd: &mut Gd<RenderingDevice>, chunk_count: u32) {
959        if !self.custom_ready() || !self.custom_params_buf.is_valid() {
960            return;
961        }
962        let bytes = crate::custom_surface::pack_params(
963            chunk_count.min(self.budget),
964            self.tile_res,
965            self.custom_water_height,
966            self.custom_height_scale,
967            &self.custom_user_params,
968        );
969        rd.buffer_update(
970            self.custom_params_buf.rid(),
971            0,
972            bytes.len() as u32,
973            &PackedByteArray::from(&bytes[..]),
974        );
975    }
976
977    /// Record the custom-surface dispatch into `list`: one thread per tile texel
978    /// of each realized chunk (`chunk_count · tile_res²`), clamped to the surface
979    /// buffer capacity. No-op unless the custom pipeline is ready.
980    pub fn record_custom_surface(&self, rd: &mut Gd<RenderingDevice>, list: i64, chunk_count: u32) {
981        if !self.custom_ready() {
982            return;
983        }
984        let max_threads = (self.budget as u64 * self.tile_texels()).min(u32::MAX as u64) as u32;
985        let threads = ((chunk_count as u64 * self.tile_texels()).min(max_threads as u64)) as u32;
986        let groups = threads.div_ceil(64);
987        if groups == 0 {
988            return;
989        }
990        rd.compute_list_bind_compute_pipeline(list, self.custom_pipeline.rid());
991        rd.compute_list_bind_uniform_set(list, self.custom_set.rid(), 0);
992        rd.compute_list_dispatch(list, groups, 1, 1);
993        rd.compute_list_add_barrier(list);
994    }
995
996    /// Vertices per chunk (pool stride per slot).
997    pub fn verts_per_chunk(&self) -> u32 {
998        self.vpc
999    }
1000
1001    /// Per-chunk detail-tile resolution (atlas texels per slot = `tile_res²`).
1002    pub fn tile_res(&self) -> u32 {
1003        self.tile_res
1004    }
1005
1006    /// The per-chunk colour detail atlas (rgba8) — sampled by the surface shader.
1007    pub fn color_atlas(&self) -> Rid {
1008        self.color_atlas.rid()
1009    }
1010
1011    /// The per-chunk world-normal detail atlas (rgba8, encoded `*0.5+0.5`).
1012    pub fn normal_atlas(&self) -> Rid {
1013        self.normal_atlas.rid()
1014    }
1015
1016    /// Planet radius (for the verification envelope check).
1017    pub fn radius(&self) -> f32 {
1018        self.radius
1019    }
1020
1021    /// The vertex-pool positions buffer (1 float4/vertex: pos+height) — readback.
1022    pub fn pos_buf(&self) -> Rid {
1023        self.verts_buf.rid()
1024    }
1025
1026    /// The per-vertex attribute texture (color+layer, normal+height).
1027    pub fn attr_tex(&self) -> Rid {
1028        self.verts_tex.rid()
1029    }
1030
1031    /// The per-vertex SAMPLEABLE world-position texture (rgba32f); the surface
1032    /// material reads VERTEX from this.
1033    pub fn pos_tex(&self) -> Rid {
1034        self.pos_tex.rid()
1035    }
1036
1037    /// The target multimesh.
1038    pub fn mm_rid(&self) -> Rid {
1039        self.mm_rid
1040    }
1041
1042    /// True once `ensure_ready` failed permanently (shader/pipeline build error).
1043    pub fn init_failed(&self) -> bool {
1044        self.init_failed
1045    }
1046
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052    use crate::gpu::owned::{Owned, RidSink};
1053    use std::sync::{Arc, Mutex};
1054
1055    /// Records every RID handed to it, in order.
1056    #[derive(Default)]
1057    struct SpySink {
1058        freed: Mutex<Vec<Rid>>,
1059    }
1060
1061    impl SpySink {
1062        fn freed(&self) -> Vec<Rid> {
1063            self.freed.lock().unwrap().clone()
1064        }
1065    }
1066
1067    impl RidSink for SpySink {
1068        fn free(&self, rid: Rid) {
1069            self.freed.lock().unwrap().push(rid);
1070        }
1071    }
1072
1073    /// Ownership contract (CEL-91): dropping the resource set frees every RD RID it
1074    /// holds exactly once, and frees dependents (uniform sets) before the parents
1075    /// (shaders / buffers) they were created from — see `gpu::owned`'s drop-order
1076    /// contract. No GPU needed: the sink is a spy.
1077    #[test]
1078    fn dropping_resources_frees_every_rid_once_dependents_first() {
1079        let spy = Arc::new(SpySink::default());
1080        let sink: Arc<dyn RidSink> = spy.clone();
1081
1082        {
1083            let mut r =
1084                ChunkGpuResources::with_sink(Rid::Invalid, 4, 8, 8, 1.0, true, sink.clone());
1085            // Uniform sets (dependents).
1086            r.realize_set = Owned::new(Rid::new(101), sink.clone());
1087            r.bake_set = Owned::new(Rid::new(102), sink.clone());
1088            r.custom_set = Owned::new(Rid::new(103), sink.clone());
1089            // Pipelines.
1090            r.pipeline = Owned::new(Rid::new(201), sink.clone());
1091            r.bake_pipeline = Owned::new(Rid::new(202), sink.clone());
1092            // Shaders (parents of the sets/pipelines).
1093            r.shader = Owned::new(Rid::new(301), sink.clone());
1094            r.bake_shader = Owned::new(Rid::new(302), sink.clone());
1095            r.custom_shader = Owned::new(Rid::new(303), sink.clone());
1096            r.scatter_shader = Owned::new(Rid::new(304), sink.clone());
1097            r.compact_shader = Owned::new(Rid::new(305), sink.clone());
1098            // Buffers / textures (parents).
1099            r.desc_buf = Owned::new(Rid::new(401), sink.clone());
1100            r.params_buf = Owned::new(Rid::new(402), sink.clone());
1101            r.verts_buf = Owned::new(Rid::new(403), sink.clone());
1102            r.verts_tex = Owned::new(Rid::new(404), sink.clone());
1103            r.pos_tex = Owned::new(Rid::new(405), sink.clone());
1104            // One scatter layer: its sets are dependents of the scatter shaders and
1105            // of its own pool/params/counter buffers.
1106            r.scatter_layers.push(ScatterLayerGpu {
1107                cfg: ScatterConfig { mm_rid: Rid::Invalid, capacity: 1, max_instances: 1 },
1108                place_set: Owned::new(Rid::new(111), sink.clone()),
1109                compact_set: Owned::new(Rid::new(112), sink.clone()),
1110                pool_buf: Owned::new(Rid::new(411), sink.clone()),
1111                params_buf: Owned::new(Rid::new(412), sink.clone()),
1112                counter_buf: Owned::new(Rid::new(413), sink.clone()),
1113            });
1114            assert!(spy.freed().is_empty(), "nothing freed while alive");
1115        }
1116
1117        let freed = spy.freed();
1118        let expected: Vec<Rid> = [
1119            101, 102, 103, 111, 112, 201, 202, 301, 302, 303, 304, 305, 401, 402, 403, 404, 405,
1120            411, 412, 413,
1121        ]
1122        .iter()
1123        .map(|&n| Rid::new(n))
1124        .collect();
1125
1126        // Freed exactly once each (no double-free), and every RID accounted for.
1127        let mut sorted = freed.clone();
1128        sorted.sort_by_key(|r| r.to_u64());
1129        let mut want = expected.clone();
1130        want.sort_by_key(|r| r.to_u64());
1131        assert_eq!(sorted, want, "every owned RID freed exactly once");
1132
1133        let pos = |n: u64| freed.iter().position(|r| *r == Rid::new(n)).unwrap();
1134        // Dependents before parents.
1135        assert!(pos(101) < pos(301), "realize_set freed before its shader");
1136        assert!(pos(102) < pos(302), "bake_set freed before its shader");
1137        assert!(pos(103) < pos(303), "custom_set freed before its shader");
1138        assert!(pos(111) < pos(304), "place_set freed before the scatter shader");
1139        assert!(pos(112) < pos(305), "compact_set freed before the compact shader");
1140        assert!(pos(111) < pos(411), "place_set freed before the layer pool buffer");
1141        assert!(pos(101) < pos(401), "realize_set freed before the desc buffer");
1142        assert!(pos(201) < pos(301), "pipeline freed before its shader");
1143    }
1144}