awsm-scene-loader 0.3.0

Load an awsm-scene runtime bundle (scene.toml + assets/) into the renderer — the parallel to renderer-gltf's populate_gltf, for OUR own format. Used by the model-test page (round-trip: export a bundle → load it back → compare) and the player.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
//! `populate_awsm_scene` — load an [`awsm_scene::Scene`] (the runtime bundle's
//! `scene.toml`) into the renderer. The parallel to
//! `awsm_renderer_gltf::populate_gltf`: that loads *foreign* glTF, this loads
//! *our* format. They share the same renderer core — glb meshes in a bundle go
//! through `populate_gltf`'s machinery, primitives regenerate via `awsm-meshgen`,
//! and our materials / clips bind on top.
//!
//! The headline use is the **round-trip test**: in the MCP-controlled browser
//! session, `export_player_bundle` → `populate_awsm_scene` → screenshot, compared
//! against the source render. The model-test page can load a `.glb` *or* one of
//! our exported bundles this way.
//!
//! Runs as one batched, phased pass (build materials → upload textures → upload
//! meshes → load animation → compile pipelines), reporting each [`LoadPhase`]
//! through a callback. Handles: the node hierarchy (transforms); **primitive**
//! meshes with their built-in materials; **glb** meshes (`assets/<id>.glb`) AND
//! **skinned** meshes (`assets/<skin.source>.glb`), both fed through
//! `populate_gltf` with [`GltfMaterialSource::Single`] so they take OUR material
//! (no glTF default-mint) and ride the same geometry+skin+morph upload foreign
//! glTF uses; **lights** (shared `light_from_config` + shadow params);
//! **cameras**; textures + custom-WGSL materials; and **animation** — the scene's
//! clips + NLA mixer ([`animation::load_animations`]) lowered against the per-node
//! keys built here. The loader only LOADS the clips; the consumer drives the
//! clock (a player's `update_animations`, or the editor round-trip's playhead
//! pin). Remaining follow-on: driving a skinned mesh's rig glb joints from our
//! Transform tracks (skin correspondence — the rig still poses at bind pose, and
//! its bone tracks currently target the scene bone nodes, not the glb joints).

pub mod animation;
pub mod camera;
pub mod dynamic;
pub mod light;
pub mod material;
pub mod texture;

use std::collections::HashMap;

use animation::AnimResolveMaps;
use anyhow::{anyhow, Result};
use awsm_renderer::animation::AnimationClipKey;
use awsm_renderer::lights::LightKey;
use awsm_renderer::materials::unlit::UnlitMaterial;
use awsm_renderer::materials::{Material, MaterialAlphaMode, MaterialKey};
use awsm_renderer::meshes::MeshKey;
use awsm_renderer::raw_mesh::RawMeshData;
use awsm_renderer::transforms::{Transform, TransformKey};
use awsm_renderer::{AwsmRenderer, LoadPhase};
use awsm_renderer_core::texture::mipmap::MipmapTextureKind;
use awsm_renderer_gltf::loader::GltfLoader;
use awsm_renderer_gltf::{AwsmRendererGltfExt, GltfMaterialSource, PopulateGltfOpts};
use awsm_scene::{
    mesh_glb_filename, AssetId, AssetSource, EditorNode, MaterialInstance, MaterialShading, NodeId,
    NodeKind, RuntimeMesh, Scene, Trs, ASSETS_DIR,
};
use glam::{Quat, Vec3};

/// The renderer resources `populate_awsm_scene` created, returned so a host can
/// tear the loaded scene back down — these inserts live OUTSIDE any per-node
/// tracking the host keeps, so without removing them they'd leak/ghost on the
/// next load. Only the *visible* resources (meshes + lights) are tracked;
/// orphaned transforms / materials are invisible and cleared on a full renderer
/// rebuild.
#[derive(Default, Debug)]
pub struct LoadedScene {
    pub meshes: Vec<MeshKey>,
    pub lights: Vec<LightKey>,
    /// Animation clips inserted into `renderer.animations` (the scene's
    /// `StoredAnimation`s lowered to runtime clip groups). Tracked so a host can
    /// remove them on the next load — like meshes/lights, they live outside any
    /// per-node tracking. The mixer is rebuilt wholesale on each load.
    pub clips: Vec<AnimationClipKey>,
}

/// Load a runtime [`Scene`] into the renderer as one batched, phased pass.
/// Returns the [`LoadedScene`] handles for later teardown.
///
/// `assets` maps bundle-relative paths (e.g. `assets/<id>.glb`, `assets/<id>.png`)
/// to their bytes — the in-memory file set the bundle exporter produces, so the
/// round-trip never touches disk. `on_phase` is invoked at each
/// [`LoadPhase`](awsm_renderer::LoadPhase) boundary (and through the pipeline
/// compile) so a host can show live progress; pass `|_| {}` to ignore it.
///
/// The phases (in order) are why this is efficient for the player's typical
/// "load a bundle then render" case:
/// 1. **Build materials** — lower every node's authored material to a renderer
///    `Material` and insert it once, producing a ready `MaterialKey`. Built here
///    so meshes — including glb meshes via [`GltfMaterialSource::Single`] —
///    reference a ready key instead of letting the glTF loader mint (and compile
///    a pipeline for) a throwaway default that we'd then replace.
/// 2. **Upload textures** — one batched `finalize_gpu_textures` for the whole
///    scene, not once per glb.
/// 3. **Upload meshes** — transforms + geometry (+ skins) + lights, each mesh
///    handed its already-built `MaterialKey`.
/// 4. **Compile pipelines** — one drive-to-ready (`wait_for_pipelines_ready`)
///    for the whole scene's materials + shadows, so the first frame draws
///    everything rather than trickling pipelines across frames.
pub async fn populate_awsm_scene(
    renderer: &mut AwsmRenderer,
    scene: &Scene,
    assets: &HashMap<String, Vec<u8>>,
    mut on_phase: impl FnMut(LoadPhase),
) -> Result<LoadedScene> {
    // ── Phase 0: register custom-WGSL materials ──────────────────────────────
    // Build + register each custom material (material.json + wgsl) once; nodes
    // assigned one resolve to its shader id below. Built-in materials have no
    // folder, so they're skipped here and lower via their inline MaterialDef.
    let custom = dynamic::register_custom_materials(renderer, scene, assets);

    // ── Phase 1: build materials ──────────────────────────────────────────────
    // The missing-material sentinel (magenta) for unassigned meshes.
    let placeholder = insert_placeholder_material(renderer);
    // The key maps the animation resolver consults (filled across phases): node
    // material keys here, transform/light/camera/mesh keys while materializing.
    let mut maps = AnimResolveMaps::default();
    // Per-node material key. A built-in assignment's `inline` is a faithful,
    // complete MaterialDef (seeded from the shared variant at assign time), so
    // the player lowers it directly. NOT deduped by asset id: two nodes assigned
    // the same library material carry different per-mesh `inline` uniforms, so
    // they are distinct renderer materials.
    let renderables = collect_renderables(&scene.nodes);
    let total = renderables.len();
    for (i, (id, material)) in renderables.iter().enumerate() {
        on_phase(LoadPhase::BuildingMaterials { done: i, total });
        let key = resolve_material(renderer, material.as_ref(), placeholder, assets, &custom).await;
        maps.node_materials.insert(*id, key);
        // A custom-WGSL asset's first built key is the one a Uniform track drives
        // (an asset assigned to N nodes mints N keys; mirror the editor's
        // first-match `material_key_for_shader`).
        if let Some(inst) = material.as_ref() {
            if custom.contains_key(&inst.asset) {
                maps.custom_materials.entry(inst.asset).or_insert(key);
            }
        }
    }
    on_phase(LoadPhase::BuildingMaterials { done: total, total });
    // The custom-WGSL asset → shader-id table (Phase 0) feeds Uniform resolution.
    maps.custom_shaders = custom;

    // ── Phase 2: upload textures (one batch across the whole scene) ───────────
    on_phase(LoadPhase::UploadingTextures);
    renderer.finalize_gpu_textures().await?;

    // ── Phase 3: upload meshes (geometry + skins) + lights ────────────────────
    let mut loaded = LoadedScene::default();
    let mut uploaded = 0usize;
    for node in &scene.nodes {
        materialize(
            renderer,
            scene,
            node,
            None,
            assets,
            &mut maps,
            placeholder,
            &mut on_phase,
            &mut uploaded,
            total,
            &mut loaded,
        )
        .await?;
    }

    // ── Phase 3b: load animation clips + the NLA mixer ────────────────────────
    // Now that every node's transform / material / light / camera / mesh key
    // exists, lower the scene's clips + mixer against them and insert into the
    // renderer. The loader only LOADS animation; the consumer drives the clock
    // (`update_animations` each frame, or the editor round-trip's playhead pin).
    loaded.clips = animation::load_animations(renderer, scene, &maps);

    // ── Phase 4: compile pipelines to ready (materials + shadows) ─────────────
    renderer
        .wait_for_pipelines_ready_with_progress(|cp| on_phase(LoadPhase::CompilingPipelines(cp)))
        .await?;
    Ok(loaded)
}

/// Flatten the tree (DFS) to the renderable nodes that carry a material —
/// `Mesh` and `SkinnedMesh` — as `(node id, &material)`. Used to build every
/// material up front (Phase 1) and to size the mesh-upload progress.
fn collect_renderables(nodes: &[EditorNode]) -> Vec<(NodeId, &Option<MaterialInstance>)> {
    let mut out = Vec::new();
    fn walk<'a>(nodes: &'a [EditorNode], out: &mut Vec<(NodeId, &'a Option<MaterialInstance>)>) {
        for n in nodes {
            match &n.kind {
                NodeKind::Mesh { material, .. } | NodeKind::SkinnedMesh { material, .. } => {
                    out.push((n.id, material));
                }
                _ => {}
            }
            walk(&n.children, out);
        }
    }
    walk(nodes, &mut out);
    out
}

#[allow(clippy::too_many_arguments)]
async fn materialize(
    renderer: &mut AwsmRenderer,
    scene: &Scene,
    node: &EditorNode,
    parent: Option<TransformKey>,
    assets: &HashMap<String, Vec<u8>>,
    maps: &mut AnimResolveMaps,
    placeholder: MaterialKey,
    on_phase: &mut dyn FnMut(LoadPhase),
    uploaded: &mut usize,
    total: usize,
    loaded: &mut LoadedScene,
) -> Result<()> {
    let tk = renderer
        .transforms
        .insert(trs_to_transform(&node.transform), parent);
    // Record this node's transform key for animation Transform tracks.
    maps.transforms.insert(node.id, tk);
    // The material key built for this node in Phase 1 (placeholder if unassigned
    // or — defensively — somehow unbuilt).
    let mat = maps
        .node_materials
        .get(&node.id)
        .copied()
        .unwrap_or(placeholder);

    match &node.kind {
        NodeKind::Mesh { mesh, .. } => {
            if let Some(entry) = scene.assets.get(mesh.0) {
                match &entry.source {
                    AssetSource::Mesh(RuntimeMesh::Primitive(shape)) => {
                        let md = awsm_meshgen::primitive_mesh(shape);
                        let key = renderer.add_raw_mesh(mesh_data_to_raw(md), tk, mat)?;
                        maps.meshes.entry(node.id).or_insert(key);
                        loaded.meshes.push(key);
                    }
                    AssetSource::Mesh(RuntimeMesh::Glb) => {
                        // Bare geometry glb (single identity node) — root it UNDER
                        // the scene node's transform, which is what places it.
                        let (keys, _) = load_glb_under(
                            renderer,
                            assets,
                            &mesh_glb_filename(mesh.0),
                            Some(tk),
                            mat,
                        )
                        .await?;
                        if let Some(&first) = keys.first() {
                            maps.meshes.entry(node.id).or_insert(first);
                        }
                        loaded.meshes.extend(keys);
                    }
                    // A Mesh node always references an AssetSource::Mesh; other
                    // source kinds (Filename / Url / Material / Texture) can't be a
                    // mesh asset — ignore defensively.
                    _ => {}
                }
            }
            *uploaded += 1;
            on_phase(LoadPhase::UploadingMeshes {
                done: *uploaded,
                total,
            });
        }
        // A skinned mesh's whole rig glb (skeleton + mesh + skin + morph,
        // re-exported clean at export) loads keyed by the skin source. Unlike a
        // bare Mesh(Glb), the rig glb carries the original glTF's FULL hierarchy —
        // including its root basis-conversion node (e.g. RiggedSimple's `Z_UP`) —
        // so it is SELF-PLACING. We root it at the renderer root (`None`), exactly
        // as the editor's own import does (`populate_gltf` with parent=None).
        // Rooting it under the scene node's `tk` would double-apply that root
        // rotation, because scene.toml ALSO mirrors the `Z_UP` node — the cause of
        // the "skinned mesh loads lying on its side" bug. (Composing a user's
        // *repositioning* of the rig + driving the skin from our scene-node bones
        // is the remaining skin-correspondence follow-on; the glb poses at bind
        // pose for now.)
        NodeKind::SkinnedMesh { skin, .. } => {
            let (keys, node_index_transforms) =
                load_glb_under(renderer, assets, &mesh_glb_filename(skin.source), None, mat)
                    .await?;
            if let Some(&first) = keys.first() {
                maps.meshes.entry(node.id).or_insert(first);
            }
            // Bind each skeleton bone (NodeId) → the rig glb's baked joint
            // transform (by the joint's clean-glb node index), so our clips'
            // Transform tracks drive the joints the skin reads. (Empty `joints`
            // for legacy projects → no binding → bind-pose, as before.)
            for j in &skin.joints {
                if let Some(&tk) = node_index_transforms.get(&(j.index as usize)) {
                    maps.skin_joints.insert(j.node, tk);
                }
            }
            loaded.meshes.extend(keys);
            *uploaded += 1;
            on_phase(LoadPhase::UploadingMeshes {
                done: *uploaded,
                total,
            });
        }
        NodeKind::Light(cfg) => {
            // Same derivation as the editor bridge's `apply_light`: position from
            // the node translation, forward from rotating local -Z. Bind the
            // light to its transform so a moved/rotated light re-derives pos/dir.
            let pos = Vec3::from_array(node.transform.translation);
            let dir = (Quat::from_array(node.transform.rotation) * Vec3::NEG_Z).normalize_or_zero();
            let lt = light::light_from_config(cfg, pos, dir);
            let shadow = light::light_shadow_params_from_config(cfg.shadow());
            let casts = shadow.cast;
            if let Ok(k) = renderer.insert_light(lt, Some(shadow)) {
                renderer.lights.bind_transform(k, tk);
                maps.lights.insert(node.id, k);
                loaded.lights.push(k);
            }
            // Compile shadow pipelines on the first caster (no-op once compiled).
            if casts {
                renderer.ensure_shadow_pipelines_compiled().await?;
            }
        }
        NodeKind::Camera(cfg) => {
            // Register the camera's projection params in the renderer (under its
            // transform `tk`). A player's camera controller picks which camera
            // drives the view + reads `tk` for position; the editor round-trip
            // uses its own free camera, so this just makes the camera node load.
            let ck = renderer
                .cameras
                .insert(camera::camera_params_from_config(cfg));
            maps.cameras.insert(node.id, ck);
        }
        // Follow-on: our-clip (animation) wiring.
        _ => {}
    }

    for child in &node.children {
        Box::pin(materialize(
            renderer,
            scene,
            child,
            Some(tk),
            assets,
            maps,
            placeholder,
            on_phase,
            uploaded,
            total,
            loaded,
        ))
        .await?;
    }
    Ok(())
}

/// Load a glb (`assets/<leaf>`) rooted under `parent` (or the renderer root when
/// `None`), applying our pre-built `material` to every primitive — no glTF
/// material/texture mint (see [`GltfMaterialSource::Single`]). Texture finalize
/// is deferred to the batched Phase 2. Reuses the exact mesh/skin/morph upload
/// foreign glTF uses.
///
/// `parent`: `Some(tk)` for a bare geometry glb (the scene node's transform
/// places it); `None` for a self-placing rig glb that carries its own root
/// hierarchy (see the SkinnedMesh arm — rooting it under the scene chain would
/// double-apply the glTF's basis-conversion node).
async fn load_glb_under(
    renderer: &mut AwsmRenderer,
    assets: &HashMap<String, Vec<u8>>,
    leaf: &str,
    parent: Option<TransformKey>,
    material: MaterialKey,
) -> Result<(Vec<MeshKey>, HashMap<usize, TransformKey>)> {
    let key = format!("{ASSETS_DIR}/{leaf}");
    let bytes = assets
        .get(&key)
        .ok_or_else(|| anyhow!("bundle is missing mesh glb `{key}`"))?;
    // The bundle's glb is geometry-only (materials stripped), so `populate_gltf`
    // would decide every primitive Opaque and build no transparency geometry —
    // but we apply OUR material via `Single`. If that material is transparent the
    // transparency pass would then fail (`TransparencyGeometryBufferNotFound`), so
    // override the geometry kind from our material (per-load — the same glb asset
    // can be shared by nodes with different materials).
    use awsm_renderer_gltf::data::GltfGeometryOverride;
    let transparent = renderer.materials.is_transparency_pass(material);
    let geometry_override = if transparent {
        GltfGeometryOverride::Transparent
    } else {
        GltfGeometryOverride::FromMaterial
    };
    let hints = awsm_renderer_gltf::data::GltfDataHints::default()
        .with_geometry_override(geometry_override);
    let data = GltfLoader::from_glb_bytes(bytes)
        .await?
        .into_data(Some(hints))?;
    let ctx = renderer
        .populate_gltf_with(
            data,
            PopulateGltfOpts {
                scene: None,
                parent_transform: parent,
                material_source: GltfMaterialSource::Single(material),
                finalize_textures: false,
            },
        )
        .await?;
    let (keys, node_index_transforms): (Vec<MeshKey>, HashMap<usize, TransformKey>) = {
        let lookups = ctx.key_lookups.lock().unwrap();
        // The renderer mesh keys this glb produced (one per primitive), so the host
        // can remove them on teardown.
        let keys = lookups.all_mesh_keys.values().flatten().copied().collect();
        // glb node index → baked transform key — the skinned-mesh arm binds each
        // skeleton joint (by its clean-glb node index) to drive the skin.
        (keys, lookups.node_index_to_transform.clone())
    };
    // A transparent mesh is built with transparency geometry only (above), so it
    // must NOT enter the shadow pass — that pass draws from VISIBILITY geometry
    // (`shadows/render_pass.rs`), which transparent meshes lack →
    // `VisibilityGeometryBufferNotFound`. Matches `MeshShadowConfig::
    // TRANSPARENT_DEFAULT` (transparent = no cast / no receive); the bundle's
    // geometry-only glb carries no per-mesh shadow flags, so set them here.
    if transparent {
        for &k in &keys {
            let _ = renderer.set_mesh_shadow_flags(
                k,
                awsm_renderer::shadows::MeshShadowFlags {
                    cast: false,
                    receive: false,
                },
            );
        }
    }
    Ok((keys, node_index_transforms))
}

fn trs_to_transform(trs: &Trs) -> Transform {
    Transform {
        translation: Vec3::from_array(trs.translation),
        rotation: Quat::from_array(trs.rotation),
        scale: Vec3::from_array(trs.scale),
    }
}

fn mesh_data_to_raw(md: awsm_meshgen::MeshData) -> RawMeshData {
    RawMeshData {
        positions: md.positions,
        normals: md.normals,
        uvs: md.uvs,
        uvs1: None,
        colors: md.colors,
        indices: md.indices,
    }
}

/// Resolve a mesh node's assigned material to a renderer key.
///
/// A built-in assignment's `inline` is a faithful, complete `MaterialDef` — it's
/// seeded from the shared variant when the material is assigned, and per-mesh
/// edits only touch uniform fields — so the player lowers it directly via the
/// shared [`material`] conversion. For a **PBR** material this also binds the five
/// standard texture slots from the bundle's `assets/<id>.png` (mirroring the
/// editor's `apply_textures`); Unlit/Toon are texture-less (as in the editor).
/// Custom-WGSL materials are a follow-on; an unassigned node (`None`) renders the
/// magenta placeholder.
async fn resolve_material(
    renderer: &mut AwsmRenderer,
    instance: Option<&MaterialInstance>,
    placeholder: MaterialKey,
    assets: &HashMap<String, Vec<u8>>,
    custom: &HashMap<AssetId, awsm_materials::MaterialShaderId>,
) -> MaterialKey {
    let Some(inst) = instance else {
        return placeholder;
    };
    // Custom-WGSL assignment: the asset resolved to a registered shader (Phase 0).
    // Build a Material::Custom (defaults + uniform overrides); `inline` is ignored.
    if let Some(&shader_id) = custom.get(&inst.asset) {
        if let Some(mat) = dynamic::build_custom_material(renderer, shader_id, inst, assets).await {
            // Upload the instance's per-slot buffer-override words into the extras
            // pool BEFORE insert (insert packs `MaterialData.<slot>_offset` from
            // `extras_pool.slice_for`, so the slice must exist first).
            renderer.upload_dynamic_material_buffers(&mat);
            return renderer.materials.insert(
                mat,
                &renderer.textures,
                &renderer.dynamic_materials,
                &renderer.extras_pool,
            );
        }
        return placeholder;
    }
    let def = &inst.inline;
    let material = match def.shading {
        MaterialShading::Pbr => {
            let alpha = material::alpha_mode_of(def);
            let mut pbr = material::material_to_pbr(def, alpha, None);
            // Bind each enabled standard PBR texture slot. sRGB for color data
            // (base-color / emissive), linear for the rest.
            use MipmapTextureKind as K;
            if let Some(t) = &def.base_color_texture {
                pbr.base_color_tex =
                    texture::load_texture(renderer, assets, t, true, K::Albedo).await;
            }
            if let Some(t) = &def.metallic_roughness_texture {
                pbr.metallic_roughness_tex =
                    texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
            }
            if let Some(t) = &def.normal_texture {
                pbr.normal_tex = texture::load_texture(renderer, assets, t, false, K::Normal).await;
            }
            if let Some(t) = &def.occlusion_texture {
                pbr.occlusion_tex =
                    texture::load_texture(renderer, assets, t, false, K::Occlusion).await;
            }
            if let Some(t) = &def.emissive_texture {
                pbr.emissive_tex =
                    texture::load_texture(renderer, assets, t, true, K::Emissive).await;
            }
            // KHR-extension texture slots (the factors are already mapped by
            // `material_to_pbr`; bind their textures the same way the editor does).
            bind_extension_textures(renderer, assets, def, &mut pbr).await;
            Material::Pbr(Box::new(pbr))
        }
        _ => material::material_to_renderer(def),
    };
    renderer.materials.insert(
        material,
        &renderer.textures,
        &renderer.dynamic_materials,
        &renderer.extras_pool,
    )
}

/// Bind the KHR-extension texture slots on a PBR material from the bundle,
/// mirroring the editor's `apply_extension_textures`. The extension *factors* are
/// already mapped by [`material::material_to_pbr`] (so each `pbr.<ext>` is `Some`
/// iff the material carries it); here we bind the *textures*. `color_tex` slots
/// are colour data (sRGB + albedo mips); normal maps use the normal mip kind; the
/// rest are linear data (metallic-roughness mips).
async fn bind_extension_textures(
    renderer: &mut AwsmRenderer,
    assets: &HashMap<String, Vec<u8>>,
    def: &awsm_scene::MaterialDef,
    pbr: &mut awsm_renderer::materials::pbr::PbrMaterial,
) {
    use MipmapTextureKind as K;
    let ext = &def.extensions;
    if let (Some(e), Some(p)) = (ext.specular.as_ref(), pbr.specular.as_mut()) {
        if let Some(t) = &e.tex {
            p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
        if let Some(t) = &e.color_tex {
            p.color_tex = texture::load_texture(renderer, assets, t, true, K::Albedo).await;
        }
    }
    if let (Some(e), Some(p)) = (ext.transmission.as_ref(), pbr.transmission.as_mut()) {
        if let Some(t) = &e.tex {
            p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
    }
    if let (Some(e), Some(p)) = (
        ext.diffuse_transmission.as_ref(),
        pbr.diffuse_transmission.as_mut(),
    ) {
        if let Some(t) = &e.tex {
            p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
        if let Some(t) = &e.color_tex {
            p.color_tex = texture::load_texture(renderer, assets, t, true, K::Albedo).await;
        }
    }
    if let (Some(e), Some(p)) = (ext.volume.as_ref(), pbr.volume.as_mut()) {
        if let Some(t) = &e.thickness_tex {
            p.thickness_tex =
                texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
    }
    if let (Some(e), Some(p)) = (ext.clearcoat.as_ref(), pbr.clearcoat.as_mut()) {
        if let Some(t) = &e.tex {
            p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
        if let Some(t) = &e.roughness_tex {
            p.roughness_tex =
                texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
        if let Some(t) = &e.normal_tex {
            p.normal_tex = texture::load_texture(renderer, assets, t, false, K::Normal).await;
        }
    }
    if let (Some(e), Some(p)) = (ext.sheen.as_ref(), pbr.sheen.as_mut()) {
        if let Some(t) = &e.color_tex {
            p.color_tex = texture::load_texture(renderer, assets, t, true, K::Albedo).await;
        }
        if let Some(t) = &e.roughness_tex {
            p.roughness_tex =
                texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
    }
    if let (Some(e), Some(p)) = (ext.anisotropy.as_ref(), pbr.anisotropy.as_mut()) {
        if let Some(t) = &e.tex {
            p.tex = texture::load_texture(renderer, assets, t, false, K::Normal).await;
        }
    }
    if let (Some(e), Some(p)) = (ext.iridescence.as_ref(), pbr.iridescence.as_mut()) {
        if let Some(t) = &e.tex {
            p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
        if let Some(t) = &e.thickness_tex {
            p.thickness_tex =
                texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
        }
    }
}

/// A magenta unlit placeholder for unassigned meshes (and glb meshes until their
/// material reassignment lands).
fn insert_placeholder_material(renderer: &mut AwsmRenderer) -> MaterialKey {
    let mut m = UnlitMaterial::new(MaterialAlphaMode::Opaque, false);
    m.base_color_factor = [1.0, 0.0, 1.0, 1.0];
    renderer.materials.insert(
        Material::Unlit(m),
        &renderer.textures,
        &renderer.dynamic_materials,
        &renderer.extras_pool,
    )
}