concinnity-device 0.19.0

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
// src/vulkan/resources/skinning.rs
//
// Skinned-mesh upload, per-frame joint upload, and helpers for VkContext.
// Builds the skinned pipelines + per-(frame, object) joint storage buffers
// once at init; per-frame `update_skinned_pose` + `upload_joint_matrices`
// keep the matrices fresh from the gameplay-side pose update.

use ash::vk;
use concinnity_core::gfx::transform::IDENTITY;

use crate::gfx::mesh_payload::SkinnedVertex;
use crate::gfx::render_types::*;

use super::super::context::*;
use super::super::pipeline::{
    MeshPipelineTargets, compile_skinned_shaders, create_skinned_pipeline,
    create_skinned_shadow_pipeline,
};
use super::{alloc_descriptor_sets, create_descriptor_set_layout};

impl VkContext {
    // Upload skinned-mesh geometry and build the skinned render pipelines.
    pub(crate) fn upload_skinned(
        &mut self,
        vertices: &[SkinnedVertex],
        indices: &[u32],
        draw_objects: Vec<SkinnedDrawObject>,
        frag_bytes: &[u8],
    ) -> Result<(), String> {
        if draw_objects.is_empty() || vertices.is_empty() || indices.is_empty() {
            return Ok(());
        }
        self.wait_idle();
        let frames = self.frames_in_flight.max(1);
        let n = draw_objects.len();

        let (skinned_vs, skinned_shadow_vs, frag_spv) =
            compile_skinned_shaders(self.hot_reload.enabled, frag_bytes)?;

        let joint_set_layout = create_descriptor_set_layout(
            &self.device,
            &[(
                0,
                vk::DescriptorType::STORAGE_BUFFER,
                vk::ShaderStageFlags::VERTEX,
            )],
        )?;

        let main_pc = vk::PushConstantRange::default()
            .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT)
            .offset(0)
            .size(112);
        let main_set_layouts = [
            self.descriptors.global_set_layout.handle(),
            self.descriptors.object_set_layout.handle(),
            joint_set_layout.handle(),
        ];
        let skinned_pipeline_layout = self
            .device
            .create_pipeline_layout(
                &vk::PipelineLayoutCreateInfo::default()
                    .set_layouts(&main_set_layouts)
                    .push_constant_ranges(std::slice::from_ref(&main_pc)),
            )
            .map_err(|e| format!("skinned pipeline layout: {e}"))?;
        let skinned_pipeline = create_skinned_pipeline(
            &self.device,
            MeshPipelineTargets {
                render_pass: self.main_render_pass.handle(),
                layout: skinned_pipeline_layout.handle(),
                vert_spv: &skinned_vs,
                frag_spv: &frag_spv,
            },
            self.msaa_samples,
        )?;

        let (skinned_shadow_pipeline, skinned_shadow_pipeline_layout) =
            if let (Some(_), Some(shadow_global)) = (
                self.shadow.pipeline.as_ref(),
                self.shadow.global_set_layout.as_ref(),
            ) {
                let shadow_pc = vk::PushConstantRange::default()
                    .stage_flags(vk::ShaderStageFlags::VERTEX)
                    .offset(0)
                    .size(80);
                let shadow_set_layouts = [shadow_global.handle(), joint_set_layout.handle()];
                let layout = self
                    .device
                    .create_pipeline_layout(
                        &vk::PipelineLayoutCreateInfo::default()
                            .set_layouts(&shadow_set_layouts)
                            .push_constant_ranges(std::slice::from_ref(&shadow_pc)),
                    )
                    .map_err(|e| format!("skinned shadow pipeline layout: {e}"))?;
                let pipeline = create_skinned_shadow_pipeline(
                    &self.device,
                    self.shadow.render_pass.handle(),
                    layout.handle(),
                    &skinned_shadow_vs,
                )?;
                (Some(pipeline), Some(layout))
            } else {
                (None, None)
            };

        let vtx_bytes = bytemuck::cast_slice(vertices);
        let idx_bytes = bytemuck::cast_slice(indices);
        // The skin compute kernel reads the bind-pose VB as a storage buffer, so
        // STORAGE_BUFFER is unconditional: the main-pass skinning fold runs
        // whether or not the device is RT-capable.
        //
        // The IB's extra flags are genuinely RT-only: it is the skinned BLAS
        // index input (device-addressed) and the hit shader's index SSBO,
        // and nothing outside the RT path binds it as a buffer. Added whenever
        // the device is RT-capable (not only when RT is on at launch) so a later
        // live toggle finds the skinned IB already usable, mirroring how the
        // static VB/IB gate their RT flags at init. Inert when RT is never built.
        let skinned_ib_rt = if self.rt_capable {
            vk::BufferUsageFlags::STORAGE_BUFFER
                | vk::BufferUsageFlags::SHADER_DEVICE_ADDRESS
                | vk::BufferUsageFlags::ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_KHR
        } else {
            vk::BufferUsageFlags::empty()
        };
        let skinned_vbuf = self.alloc.create_buffer(
            vtx_bytes.len() as u64,
            vk::BufferUsageFlags::VERTEX_BUFFER
                | vk::BufferUsageFlags::TRANSFER_DST
                | vk::BufferUsageFlags::STORAGE_BUFFER,
            vk::MemoryPropertyFlags::DEVICE_LOCAL,
        )?;
        // Never zero-length: the ray-traced hit path binds this as a storage
        // buffer of index words and its descriptor takes the whole size.
        let skinned_ibuf = self.alloc.create_buffer(
            crate::gfx::rt_geom::skinned_index_buffer_bytes(indices.len()) as u64,
            vk::BufferUsageFlags::INDEX_BUFFER | vk::BufferUsageFlags::TRANSFER_DST | skinned_ib_rt,
            vk::MemoryPropertyFlags::DEVICE_LOCAL,
        )?;
        self.write_geometry_region(skinned_vbuf.buffer(), 0, vtx_bytes)?;
        self.write_geometry_region(skinned_ibuf.buffer(), 0, idx_bytes)?;

        let pool_sizes = [
            vk::DescriptorPoolSize::default()
                .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
                .descriptor_count((n * 2) as u32),
            vk::DescriptorPoolSize::default()
                .ty(vk::DescriptorType::STORAGE_BUFFER)
                .descriptor_count((n * frames) as u32),
        ];
        let pool = self
            .device
            .create_descriptor_pool(
                &vk::DescriptorPoolCreateInfo::default()
                    .max_sets((n + n * frames) as u32)
                    .pool_sizes(&pool_sizes),
            )
            .map_err(|e| format!("skinned descriptor pool: {e}"))?;

        let object_layouts: Vec<_> = (0..n)
            .map(|_| self.descriptors.object_set_layout.handle())
            .collect();
        let object_sets = alloc_descriptor_sets(&self.device, pool.handle(), &object_layouts)?;
        for (&set, obj) in object_sets.iter().zip(draw_objects.iter()) {
            let albedo_info = vk::DescriptorImageInfo::default()
                .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
                .image_view(self.albedo_pool_view(obj.texture_slot))
                .sampler(self.linear_sampler.handle());
            let nm_info = vk::DescriptorImageInfo::default()
                .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
                .image_view(self.normal_pool_view(obj.normal_map_slot))
                .sampler(self.linear_sampler.handle());
            let writes = [
                vk::WriteDescriptorSet::default()
                    .dst_set(set)
                    .dst_binding(0)
                    .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
                    .image_info(std::slice::from_ref(&albedo_info)),
                vk::WriteDescriptorSet::default()
                    .dst_set(set)
                    .dst_binding(1)
                    .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
                    .image_info(std::slice::from_ref(&nm_info)),
            ];
            // SAFETY: `writes` and the buffer/image infos it borrows are live for the call, and
            // every set and resource it names belongs to this device.
            unsafe { self.device.update_descriptor_sets(&writes, &[]) };
        }

        // Per-(frame, object) joint storage buffers seeded with identity
        // matrices so any not-yet-overwritten slot reads as identity.
        let joint_buf_bytes = (MAX_JOINTS * std::mem::size_of::<[[f32; 4]; 4]>()) as u64;
        let identity_seed: Vec<[[f32; 4]; 4]> = vec![IDENTITY; MAX_JOINTS];
        let mut joint_buffers: Vec<Vec<super::super::allocator::PooledBuffer>> =
            Vec::with_capacity(frames);
        let mut joint_sets: Vec<Vec<vk::DescriptorSet>> = Vec::with_capacity(frames);
        for _ in 0..frames {
            let mut bufs: Vec<super::super::allocator::PooledBuffer> = Vec::with_capacity(n);
            for _ in 0..n {
                let buf = self.alloc.create_buffer(
                    joint_buf_bytes,
                    vk::BufferUsageFlags::STORAGE_BUFFER,
                    vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
                )?;
                buf.write_slice(0, &identity_seed);
                bufs.push(buf);
            }
            let layouts: Vec<_> = (0..n).map(|_| joint_set_layout.handle()).collect();
            let sets = alloc_descriptor_sets(&self.device, pool.handle(), &layouts)?;
            for (i, &set) in sets.iter().enumerate() {
                let info = vk::DescriptorBufferInfo::default()
                    .buffer(bufs[i].buffer())
                    .offset(0)
                    .range(vk::WHOLE_SIZE);
                let write = vk::WriteDescriptorSet::default()
                    .dst_set(set)
                    .dst_binding(0)
                    .descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
                    .buffer_info(std::slice::from_ref(&info));
                // SAFETY: `writes` and the buffer/image infos it borrows are live for the call, and
                // every set and resource it names belongs to this device.
                unsafe {
                    self.device
                        .update_descriptor_sets(std::slice::from_ref(&write), &[])
                };
            }
            joint_buffers.push(bufs);
            joint_sets.push(sets);
        }

        self.skinned.joint_matrices = draw_objects
            .iter()
            .map(|o| vec![IDENTITY; o.joint_count.max(1)])
            .collect();

        // A skinned pipeline just came live, so let the next wireframe frame
        // build its twin.
        self.invalidate_wireframe_pipelines();
        self.skinned.pipeline = Some(skinned_pipeline);
        self.skinned.pipeline_layout = Some(skinned_pipeline_layout);
        self.shadow.skinned_pipeline = skinned_shadow_pipeline;
        self.shadow.skinned_pipeline_layout = skinned_shadow_pipeline_layout;
        let joint_set_layout_handle = joint_set_layout.handle();
        self.skinned.joint_set_layout = Some(joint_set_layout);
        self.skinned.descriptor_pool = Some(pool);
        self.skinned.vertex_buffer = skinned_vbuf;
        self.skinned.vertex_buffer_bytes = vtx_bytes.len() as u64;
        self.skinned.index_buffer = skinned_ibuf;
        self.skinned.index_buffer_bytes = idx_bytes.len() as u64;
        self.skinned.object_sets = object_sets;
        self.skinned.joint_buffers = joint_buffers;
        self.skinned.joint_sets = joint_sets;
        self.skinned.draw_objects = draw_objects;

        // Morph targets are attached by a later `upload_skinned_morphs`; until
        // then every object is morphless (a re-upload resets here).
        self.skinned.morph_delta_unique = Vec::new();
        self.skinned.morph_delta_buffers = vec![vk::Buffer::null(); n];
        self.skinned.morph_target_counts = vec![0; n];
        self.skinned.morph_weights = vec![Vec::new(); n];
        self.skinned.morph_weight_buffers = Vec::new();

        // GPU-driven main-pass skinning fold: when the bindless cull path is active,
        // build the `rt_skin` compute pipeline + per-frame deformed-vertex buffers +
        // their descriptor sets, and set `self.draw.n_skinned` (which engages the fold so
        // `cull_count()` reserves the skinned tail). A build failure leaves it 0 and
        // the legacy skinned main pass runs. Mirrors the DirectX `upload_skinned`.
        //
        // The gate counts the objects being uploaded here: `cull_count()` reads
        // `draw.n_skinned`, which only `build_main_skin` sets, so consulting it
        // alone would leave a world whose only geometry is skinned on the legacy
        // pass forever -- and that pass does not morph (rt_skin is the only
        // Vulkan shader that reads morph targets).
        if self.cull.bindless_pipeline.is_some()
            && self.cull_count() + self.skinned.draw_objects.len() > 0
            && let Err(e) = self.build_main_skin(vertices.len())
        {
            tracing::warn!(
                "skinned: main-pass skin fold build failed ({e}); skinned meshes \
                 use the legacy main pass"
            );
        }

        if let Some(gb) = self.gbuffer.as_mut() {
            gb.ensure_skinned_gbuffer_pso(&self.device, joint_set_layout_handle)?;
        }
        Ok(())
    }

    // Replace a `SkinnedMesh` draw slot's vertex + index data in place.
    // Driven by asset hot-reload (`cn debug` only). The shared skinned VB
    // / IB were sized once at `upload_skinned` to hold every skinned
    // mesh's geometry, so the new payload must fit within this slot's
    // existing region (size-changing reloads route through
    // `rebuild_skinned_geometry`). `vertex_base` is the slot's vertex
    // offset *in vertices*; `indices` are mesh-relative and get rebased
    // by `vertex_base` before being written into the shared IB.
    // Mirrors `DxContext::update_skinned_mesh_geometry`. Reached only through
    // the bin's `cn debug` runtime-mutation path (dead in the FFI lib, live in
    // the bin).
    pub(crate) fn update_skinned_mesh_geometry(
        &mut self,
        skinned_index: usize,
        vertex_base: u32,
        vertices: &[SkinnedVertex],
        indices: &[u16],
    ) -> Result<(), String> {
        let obj = self
            .skinned
            .draw_objects
            .get(skinned_index)
            .ok_or_else(|| {
                format!(
                    "update_skinned_mesh_geometry: skinned object {} out of range",
                    skinned_index
                )
            })?;
        if indices.len() != obj.index_count {
            return Err(format!(
                "update_skinned_mesh_geometry: skinned {} expects {} indices, got {} \
                 (in-place path is size-matched only; size changes route through \
                 rebuild_skinned_geometry)",
                skinned_index,
                obj.index_count,
                indices.len()
            ));
        }
        if self.skinned.vertex_buffer.is_null() || self.skinned.index_buffer.is_null() {
            return Err(
                "update_skinned_mesh_geometry: no skinned vertex/index buffer (was \
                 upload_skinned called?)"
                    .to_string(),
            );
        }
        let v_byte_off =
            (vertex_base as usize).saturating_mul(std::mem::size_of::<SkinnedVertex>());
        let v_byte_len = std::mem::size_of_val(vertices);
        let v_buf_len = self.skinned.vertex_buffer_bytes as usize;
        if v_byte_off + v_byte_len > v_buf_len {
            return Err(format!(
                "update_skinned_mesh_geometry: vertex region [{}, {}) overruns skinned \
                 vertex buffer length {}",
                v_byte_off,
                v_byte_off + v_byte_len,
                v_buf_len
            ));
        }
        let i_byte_off = (obj.index_offset * std::mem::size_of::<u32>()) as u64;
        let rebased: Vec<u32> = indices
            .iter()
            .map(|&i| u32::from(i) + vertex_base)
            .collect();

        self.wait_idle();

        let vert_bytes = bytemuck::cast_slice(vertices);
        self.write_geometry_region(
            self.skinned.vertex_buffer.buffer(),
            v_byte_off as u64,
            vert_bytes,
        )?;
        let idx_bytes = bytemuck::cast_slice(&rebased);
        self.write_geometry_region(self.skinned.index_buffer.buffer(), i_byte_off, idx_bytes)?;
        Ok(())
    }

    // Update a skinned slot's joint count to match a re-imported `.glb`
    // skeleton. The per-(frame, object) joint storage buffers were sized
    // for `MAX_JOINTS` matrices at init, so no GPU resource needs to grow:
    // only the CPU-side `SkinnedDrawObject::joint_count` and the parallel
    // `skinned_joint_matrices` slot are touched. Shrinking truncates the
    // matrix slot; growing seeds the new entries to identity so the
    // shader sees a valid pose until the next `update_skinned_pose` runs.
    // Counts above `MAX_JOINTS` are clamped (the storage buffer is fixed
    // at that size). Driven by asset hot-reload. Mirrors
    // `DxContext::update_skinned_skeleton`. Reached only through the bin's
    // `cn debug` runtime-mutation path (dead in the FFI lib, live in the bin).
    pub(crate) fn update_skinned_skeleton(
        &mut self,
        skinned_index: usize,
        new_joint_count: usize,
    ) -> Result<(), String> {
        let obj = self
            .skinned
            .draw_objects
            .get_mut(skinned_index)
            .ok_or_else(|| {
                format!(
                    "update_skinned_skeleton: skinned object {} out of range",
                    skinned_index
                )
            })?;
        let capped = new_joint_count.min(MAX_JOINTS);
        obj.joint_count = capped;
        let size = capped.max(1);
        if let Some(slot) = self.skinned.joint_matrices.get_mut(skinned_index) {
            slot.resize(size, IDENTITY);
        }
        Ok(())
    }

    // Replace the skinning matrices for one skinned object.
    pub(crate) fn update_skinned_pose(&mut self, skinned_index: usize, matrices: &[[[f32; 4]; 4]]) {
        if let Some(slot) = self.skinned.joint_matrices.get_mut(skinned_index) {
            slot.clear();
            slot.extend_from_slice(matrices);
            if slot.is_empty() {
                slot.push(IDENTITY);
            }
        }
    }

    // Reveal the pre-reserved skinned instance at `instance_index` (the
    // engine's instance pool decided which): show it at `model` and reset its
    // joint palette to the bind pose so it does not flash its previous
    // occupant's last frame (the owning `SkeletonPose`'s first pose push
    // replaces it next frame). The copy's deformed region is already valid
    // because `encode_skin` folds every pre-reserved copy each frame. A no-op
    // if the index is out of range. Mirrors the Metal path.
    pub(crate) fn reveal_skinned_instance(&mut self, instance_index: usize, model: [[f32; 4]; 4]) {
        let Some(obj) = self.skinned.draw_objects.get_mut(instance_index) else {
            return;
        };
        obj.model = model;
        obj.visible = true;
        if let Some(palette) = self.skinned.joint_matrices.get_mut(instance_index) {
            palette.iter_mut().for_each(|m| *m = IDENTITY);
        }
    }

    // Hide a skinned object; the engine's instance pool recycles the slot. A
    // no-op if the index is out of range. Mirrors the Metal path.
    pub(crate) fn retire_skinned_draw_object(&mut self, skinned_index: usize) {
        if let Some(obj) = self.skinned.draw_objects.get_mut(skinned_index) {
            obj.visible = false;
        }
    }

    // Push the model-to-world matrices of the given skinned objects, one
    // `(skinned index, matrix)` entry per moved instance. The per-frame cull
    // records and the legacy skinned draw both read `obj.model` directly, so
    // this only writes the fields. Out-of-range indices have no effect.
    pub(crate) fn update_skinned_models(&mut self, updates: &[(u32, [[f32; 4]; 4])]) {
        for &(skinned_index, model) in updates {
            if let Some(obj) = self.skinned.draw_objects.get_mut(skinned_index as usize) {
                obj.model = model;
            }
        }
    }

    // Copy this frame's skinning matrices into the per-frame joint buffers.
    pub(in crate::vulkan) fn upload_joint_matrices(&self, frame_idx: usize) {
        let Some(frame_bufs) = self.skinned.joint_buffers.get(frame_idx) else {
            return;
        };
        for (i, mats) in self.skinned.joint_matrices.iter().enumerate() {
            let Some(dst) = frame_bufs.get(i) else {
                continue;
            };
            let count = mats.len().min(MAX_JOINTS);
            dst.write_slice(0, &mats[..count]);
        }
    }

    // Attach morph-target buffers (`PayloadMorphs::packed_words`) to the skinned
    // draw objects. `morphs[i]` pairs with draw object `i`; instance copies share
    // their template's `Arc`, so each unique entry set becomes one device buffer. Allocates the per-frame
    // weight buffers (one f32 per target per object) and re-points the main fold's
    // skin descriptor-set morph bindings when any object carries morphs. Called
    // once after `upload_skinned`. Mirrors the DirectX `upload_skinned_morphs`.
    pub(in crate::vulkan) fn upload_skinned_morphs(
        &mut self,
        morphs: Vec<Option<std::sync::Arc<crate::gfx::mesh_payload::PayloadMorphs>>>,
    ) -> Result<(), String> {
        use std::collections::HashMap;

        let n = self.skinned.draw_objects.len();
        let device = self.device.clone();
        let frames = self.frames_in_flight.max(1);

        let mut delta_unique: Vec<super::super::allocator::PooledBuffer> = Vec::new();
        let mut delta_buffers: Vec<vk::Buffer> = vec![vk::Buffer::null(); n];
        let mut target_counts: Vec<u32> = vec![0; n];
        let mut weights: Vec<Vec<f32>> = vec![Vec::new(); n];
        let mut by_source: HashMap<usize, (vk::Buffer, u32)> = HashMap::new();

        for (i, m) in morphs.iter().take(n).enumerate() {
            let Some(data) = m else { continue };
            let key = std::sync::Arc::as_ptr(data) as usize;
            let (buf, count) = match by_source.get(&key) {
                Some(e) => *e,
                None => {
                    let words = data.packed_words();
                    let bytes: &[u8] = bytemuck::cast_slice(&words);
                    let pooled = self.alloc.create_buffer(
                        bytes.len().max(4) as u64,
                        vk::BufferUsageFlags::STORAGE_BUFFER | vk::BufferUsageFlags::TRANSFER_DST,
                        vk::MemoryPropertyFlags::DEVICE_LOCAL,
                    )?;
                    let buf = pooled.buffer();
                    self.write_geometry_region(buf, 0, bytes)?;
                    let count = data.target_count() as u32;
                    delta_unique.push(pooled);
                    by_source.insert(key, (buf, count));
                    (buf, count)
                }
            };
            delta_buffers[i] = buf;
            target_counts[i] = count;
            weights[i] = vec![0.0; count as usize];
        }

        // Per-(frame, object) host-mapped weight buffers, one f32 per target
        // (>= 1 so every binding has a valid buffer), zero-seeded. Only allocated
        // when some object carries morphs.
        let mut weight_buffers: Vec<Vec<super::super::allocator::PooledBuffer>> = Vec::new();
        if target_counts.iter().any(|&c| c > 0) {
            for _ in 0..frames {
                let mut bufs = Vec::with_capacity(n);
                for &count in &target_counts {
                    let size = (count.max(1) as u64) * std::mem::size_of::<f32>() as u64;
                    let buf = self.alloc.create_buffer(
                        size,
                        vk::BufferUsageFlags::STORAGE_BUFFER,
                        vk::MemoryPropertyFlags::HOST_VISIBLE
                            | vk::MemoryPropertyFlags::HOST_COHERENT,
                    )?;
                    buf.zero_bytes(0, size as usize);
                    bufs.push(buf);
                }
                weight_buffers.push(bufs);
            }
        }

        // Re-point the main fold's skin descriptor sets' morph bindings (3 =
        // deltas, 4 = weights). Morphless objects keep the dummy SSBO the
        // `build_main_skin` write left. A no-op when the fold is inactive.
        if let Some(skin) = self.skinned.skin.as_ref() {
            let dummy = skin.morph_dummy;
            for (f, frame_sets) in skin.sets.iter().enumerate() {
                for (o, &set) in frame_sets.iter().enumerate() {
                    let delta_buf = match delta_buffers.get(o) {
                        Some(&b) if b != vk::Buffer::null() => b,
                        _ => dummy,
                    };
                    let weight_buf = match weight_buffers.get(f).and_then(|fb| fb.get(o)) {
                        Some(b) => b.buffer(),
                        None => dummy,
                    };
                    let delta_info = vk::DescriptorBufferInfo::default()
                        .buffer(delta_buf)
                        .offset(0)
                        .range(vk::WHOLE_SIZE);
                    let weight_info = vk::DescriptorBufferInfo::default()
                        .buffer(weight_buf)
                        .offset(0)
                        .range(vk::WHOLE_SIZE);
                    let writes = [
                        vk::WriteDescriptorSet::default()
                            .dst_set(set)
                            .dst_binding(3)
                            .descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
                            .buffer_info(std::slice::from_ref(&delta_info)),
                        vk::WriteDescriptorSet::default()
                            .dst_set(set)
                            .dst_binding(4)
                            .descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
                            .buffer_info(std::slice::from_ref(&weight_info)),
                    ];
                    // SAFETY: `writes` and the buffer/image infos it borrows are live for the call,
                    // and every set and resource it names belongs to this device.
                    unsafe { device.update_descriptor_sets(&writes, &[]) };
                }
            }
        }

        self.skinned.morph_delta_unique = delta_unique;
        self.skinned.morph_delta_buffers = delta_buffers;
        self.skinned.morph_target_counts = target_counts;
        self.skinned.morph_weights = weights;
        self.skinned.morph_weight_buffers = weight_buffers;
        Ok(())
    }

    // Replace one skinned object's morph weights. Out-of-range indices and
    // objects without morph targets are ignored; extra weights are dropped.
    pub(crate) fn update_morph_weights(&mut self, skinned_index: usize, weights: &[f32]) {
        if let Some(slot) = self.skinned.morph_weights.get_mut(skinned_index) {
            for (i, w) in slot.iter_mut().enumerate() {
                *w = weights.get(i).copied().unwrap_or(0.0);
            }
        }
    }

    // Copy this frame's morph weights into the per-frame weight buffers the skin
    // fold reads. Called alongside `upload_joint_matrices`. A no-op when no
    // object carries morphs (the buffers are empty).
    pub(in crate::vulkan) fn upload_morph_weights(&self, frame_idx: usize) {
        let Some(frame_bufs) = self.skinned.morph_weight_buffers.get(frame_idx) else {
            return;
        };
        for (i, w) in self.skinned.morph_weights.iter().enumerate() {
            let (Some(dst), false) = (frame_bufs.get(i), w.is_empty()) else {
                continue;
            };
            dst.write_slice(0, w);
        }
    }

    // Bind the skinned vertex + index buffers for the skinned passes.
    pub(in crate::vulkan) fn skinned_geometry(&self) -> (vk::Buffer, vk::Buffer) {
        (
            self.skinned.vertex_buffer.buffer(),
            self.skinned.index_buffer.buffer(),
        )
    }
}