Skip to main content

concinnity_engine/gfx/
draw_preview.rs

1// src/gfx/draw_preview.rs
2//
3// Live reassignment of a placement's draw slots. A Prop's `material` and
4// `cull_distance` are read once at init: the draw list bakes the material into
5// the GPU draw object and the entity keeps only the handle, so an editor
6// changing either has nothing in the ECS the renderer re-reads. This is that
7// seam. Each function records the backend call into the frame's op queue, where
8// submission replays it before the next draw, and keeps the entity's renderer
9// component in step so the running world and its draws agree on what is bound.
10//
11// What a rebuild would show is the standard. A material the world never loaded,
12// a placement drawn from a Model (whose sub-meshes carry their own materials),
13// and a backend that bakes per-object material state at build time are all
14// refused here rather than reported as applied.
15
16use crate::components::MeshRenderer;
17use crate::ecs::asset_id::AssetId;
18use crate::ecs::{ActiveRenderQueues, Entity, MaterialHandle, World};
19use crate::gfx::material_entry::{self, MaterialEntry};
20use concinnity_render::ops::RenderOps;
21
22/// One draw slot's material as the backend holds it: the GPU uniforms plus the
23/// texture-pool slots they sample, and the handle the entity records.
24#[derive(Clone, Copy)]
25pub struct DrawMaterial {
26    // `None` for a placement drawing on its legacy texture (or on nothing),
27    // which is what the entity's renderer then records.
28    handle: Option<MaterialHandle>,
29    entry: MaterialEntry,
30}
31
32impl DrawMaterial {
33    /// Whether swapping this material for `other` is only a per-draw rewrite.
34    /// The shader bucket picks the pipeline a draw renders under, and the
35    /// transparency flags decide at init which pass draws it; both are baked
36    /// into structures no per-draw call rebuilds, so a swap that moves either
37    /// needs the world rebuilt.
38    pub fn swappable_with(&self, other: &Self) -> bool {
39        self.entry.shader_bucket == other.entry.shader_bucket
40            && self.entry.uniforms.transparent == other.entry.uniforms.transparent
41            && self.entry.uniforms.see_through == other.entry.uniforms.see_through
42    }
43}
44
45/// Whether the running world can take a draw change: it has a graphics context
46/// to record into, and its backend rewrites built draw slots in place.
47pub fn is_available(world: &World) -> bool {
48    world
49        .resource::<ActiveRenderQueues>()
50        .is_some_and(|slot| slot.0.is_some())
51        && world
52            .resource::<crate::ecs::ActiveDeviceCaps>()
53            .is_some_and(|caps| caps.0.rewrites_draws)
54}
55
56/// The material the `Material` asset interned as `name` resolves to in the
57/// running world. `None` when the world loaded no material under that name
58/// (only a dev session records their identities, see
59/// [`crate::resource::MaterialNames`]) or its record does not decode.
60pub fn material(world: &World, name: AssetId) -> Option<DrawMaterial> {
61    let handle = world
62        .resource::<crate::resource::MaterialNames>()?
63        .0
64        .iter()
65        .position(|&id| id == name.0)?;
66    by_handle(world, MaterialHandle(handle as u32))
67}
68
69/// The material `entity`'s draw slots currently render with. `None` for an
70/// entity the renderer draws from a Model: its materials come from the model's
71/// sub-meshes, which the placement's own `material` never reaches.
72pub fn drawn_material(world: &World, entity: Entity) -> Option<DrawMaterial> {
73    let renderer = world.get::<MeshRenderer>(entity)?;
74    match renderer.material {
75        Some(handle) => by_handle(world, handle),
76        None => Some(DrawMaterial {
77            handle: None,
78            entry: material_entry::from_texture(renderer.texture, texture_count(world)),
79        }),
80    }
81}
82
83/// Bind `material` to every draw slot `entity` owns, and record it on the
84/// entity so the world it was drawn from agrees. `false` when the world has
85/// nothing to record into or the entity owns no draws.
86pub fn apply_material(world: &mut World, entity: Entity, material: DrawMaterial) -> bool {
87    let draws = draws_of(world, entity);
88    // Asked before the entity is touched: a refused change leaves the world
89    // exactly as it was, so the caller's rebuild is what applies it.
90    if draws.is_empty() || !is_available(world) {
91        return false;
92    }
93    if let Some(renderer) = world.get_mut::<MeshRenderer>(entity) {
94        renderer.material = material.handle;
95    }
96    let entry = material.entry;
97    with_ops(world, |ops| {
98        for draw in draws {
99            ops.record(move |backend| {
100                backend.set_draw_material(
101                    draw as usize,
102                    entry.uniforms,
103                    entry.albedo_slot,
104                    entry.normal_map_slot,
105                );
106            });
107        }
108    })
109    .is_some()
110}
111
112/// Set the view-distance cutoff of every draw slot `entity` owns. Unlike a
113/// material, this reaches a model-backed placement too: the cutoff is the
114/// placement's own, applied to each of its sub-mesh draws.
115pub fn apply_cull_distance(world: &mut World, entity: Entity, cull_distance: f32) -> bool {
116    let draws = draws_of(world, entity);
117    if draws.is_empty() || !is_available(world) {
118        return false;
119    }
120    if let Some(renderer) = world.get_mut::<MeshRenderer>(entity) {
121        renderer.cull_distance = cull_distance;
122    } else if let Some(renderer) = world.get_mut::<crate::components::ModelRenderer>(entity) {
123        renderer.cull_distance = cull_distance;
124    }
125    with_ops(world, |ops| {
126        for draw in draws {
127            ops.record(move |backend| backend.set_draw_cull_distance(draw as usize, cull_distance));
128        }
129    })
130    .is_some()
131}
132
133// The material at `handle`, decoded from the running world's table by the same
134// translation the draw list ran at init.
135fn by_handle(world: &World, handle: MaterialHandle) -> Option<DrawMaterial> {
136    let bytes = world
137        .resource::<crate::resource::MaterialTable>()?
138        .data_bytes(handle.index())?;
139    let mat: crate::components::Material = postcard::from_bytes(bytes).ok()?;
140    Some(DrawMaterial {
141        handle: Some(handle),
142        entry: material_entry::of(&mat, texture_count(world)).ok()?,
143    })
144}
145
146// The shared texture pool's size, which the material's references index into.
147fn texture_count(world: &World) -> usize {
148    world
149        .resource::<crate::resource::TextureTable>()
150        .map_or(0, |t| t.len())
151}
152
153// The backend draw slots the entity owns; empty for an entity the renderer
154// never gave one (nothing drawable, or a world with no graphics).
155fn draws_of(world: &World, entity: Entity) -> Vec<u32> {
156    world
157        .get::<crate::components::RenderHandle>(entity)
158        .map(|h| h.draws.to_vec())
159        .unwrap_or_default()
160}
161
162// Run `f` against the frame's op queue, taking it for the call and parking it
163// again after (the handoff every recording system uses). `None` when the world
164// cannot take a draw change, which leaves the slot exactly as it was.
165fn with_ops<R>(world: &mut World, f: impl FnOnce(&mut RenderOps) -> R) -> Option<R> {
166    if !is_available(world) {
167        return None;
168    }
169    let mut queues = world.resource_mut::<ActiveRenderQueues>()?.0.take()?;
170    let out = f(&mut queues.ops);
171    if let Some(slot) = world.resource_mut::<ActiveRenderQueues>() {
172        slot.0 = Some(queues);
173    }
174    Some(out)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::components::{Material, RenderHandle};
181    use crate::ecs::{RenderQueues, TextureHandle};
182    use crate::gfx::backend::DeviceCapabilities;
183    use crate::gfx::mock_backend::{Call, MockBackend, MockState, recording_backend};
184    use crate::resource::{MaterialNames, MaterialTable, ResourceEntry, TextureTable};
185    use concinnity_core::ecs::ShaderHandle;
186    use std::sync::{Arc, Mutex};
187
188    struct Fixture {
189        world: World,
190        backend: MockBackend,
191        calls: Arc<Mutex<MockState>>,
192    }
193
194    // Two materials: "steel" at handle 0 and "glass" at handle 1, the latter
195    // transparent so a swap between them is not a per-draw rewrite.
196    fn materials() -> (Vec<ResourceEntry>, Vec<u32>) {
197        let steel = Material {
198            roughness: 0.5,
199            albedo: Some(TextureHandle(1)),
200            ..Default::default()
201        };
202        let glass = Material {
203            transparent: true,
204            ..Default::default()
205        };
206        let entry = |m: &Material| ResourceEntry {
207            payload: None,
208            data_bytes: postcard::to_allocvec(m).expect("serialises"),
209        };
210        (vec![entry(&steel), entry(&glass)], vec![10, 20])
211    }
212
213    impl Fixture {
214        fn new() -> Self {
215            Self::with_caps(DeviceCapabilities::ALL)
216        }
217
218        fn with_caps(caps: DeviceCapabilities) -> Self {
219            let mut world = World::new();
220            world.insert_resource(ActiveRenderQueues(Some(RenderQueues {
221                ops: RenderOps::default(),
222                slots: crate::gfx::render_slots::RenderSlots::new(0, true, &[]),
223            })));
224            world.insert_resource(crate::ecs::ActiveDeviceCaps(caps));
225            let (entries, names) = materials();
226            world.insert_resource(MaterialTable(entries));
227            world.insert_resource(MaterialNames(names));
228            world.insert_resource(TextureTable(vec![
229                ResourceEntry::default(),
230                ResourceEntry::default(),
231            ]));
232            let (calls, backend) = recording_backend();
233            Self {
234                world,
235                backend,
236                calls,
237            }
238        }
239
240        // A mesh-backed placement holding two draw slots.
241        fn prop(&mut self, material: Option<MaterialHandle>) -> Entity {
242            let entity = self.world.push(MeshRenderer {
243                material,
244                cull_distance: 10.0,
245                ..Default::default()
246            });
247            self.world.insert(
248                entity,
249                RenderHandle {
250                    draws: [3u32, 4].into_iter().collect(),
251                },
252            );
253            entity
254        }
255
256        // A model-backed placement, whose sub-meshes are its two draw slots.
257        fn model_prop(&mut self) -> Entity {
258            let entity = self.world.push(crate::components::ModelRenderer {
259                cull_distance: 10.0,
260                ..Default::default()
261            });
262            self.world.insert(
263                entity,
264                RenderHandle {
265                    draws: [5u32, 6].into_iter().collect(),
266                },
267            );
268            entity
269        }
270
271        fn replay(&mut self) -> Vec<Call> {
272            let mut queues = self
273                .world
274                .resource_mut::<ActiveRenderQueues>()
275                .and_then(|slot| slot.0.take())
276                .expect("the queue is parked again");
277            queues.ops.replay(&mut self.backend);
278            if let Some(slot) = self.world.resource_mut::<ActiveRenderQueues>() {
279                slot.0 = Some(queues);
280            }
281            self.calls.lock().unwrap().calls.clone()
282        }
283    }
284
285    // A world with no renderer takes nothing, and says so rather than
286    // pretending: that is the signal the caller rebuilds on.
287    #[test]
288    fn a_world_without_a_renderer_takes_nothing() {
289        let mut world = World::new();
290        let entity = world.push(MeshRenderer::default());
291        assert!(!is_available(&world));
292        assert!(material(&world, AssetId(10)).is_none());
293        assert!(!apply_cull_distance(&mut world, entity, 5.0));
294    }
295
296    // A backend that bakes per-object material state at build time would keep
297    // drawing the old material, so the seam is closed there.
298    #[test]
299    fn a_backend_that_bakes_its_draws_takes_nothing() {
300        let mut f = Fixture::with_caps(DeviceCapabilities {
301            rewrites_draws: false,
302            ..DeviceCapabilities::ALL
303        });
304        let entity = f.prop(None);
305        assert!(!is_available(&f.world));
306        let steel = material(&f.world, AssetId(10)).expect("the table still decodes");
307        assert!(!apply_material(&mut f.world, entity, steel));
308        assert!(f.replay().is_empty());
309        assert!(
310            f.world
311                .get::<MeshRenderer>(entity)
312                .and_then(|r| r.material)
313                .is_none(),
314            "a refused change leaves the entity as it was"
315        );
316    }
317
318    // A named material decodes to the entry its args describe; a name the
319    // world never loaded resolves to nothing.
320    #[test]
321    fn a_named_material_resolves_through_the_running_world() {
322        let f = Fixture::new();
323        let steel = material(&f.world, AssetId(10)).expect("steel is loaded");
324        assert_eq!(steel.entry.uniforms.roughness, 0.5);
325        assert_eq!(steel.entry.albedo_slot, 1);
326        assert_eq!(steel.handle, Some(MaterialHandle(0)));
327        assert!(material(&f.world, AssetId(99)).is_none());
328    }
329
330    // The swap reaches every draw slot the placement owns, and the entity
331    // records the new handle so the next edit compares against it.
332    #[test]
333    fn a_material_swap_reaches_every_draw_slot() {
334        let mut f = Fixture::new();
335        let entity = f.prop(None);
336        let steel = material(&f.world, AssetId(10)).expect("steel is loaded");
337        assert!(apply_material(&mut f.world, entity, steel));
338        assert_eq!(
339            f.replay(),
340            vec![
341                Call::SetDrawMaterial {
342                    draw_idx: 3,
343                    texture_slot: 1,
344                    normal_map_slot: crate::gfx::render_types::NO_NORMAL_MAP_SLOT,
345                },
346                Call::SetDrawMaterial {
347                    draw_idx: 4,
348                    texture_slot: 1,
349                    normal_map_slot: crate::gfx::render_types::NO_NORMAL_MAP_SLOT,
350                },
351            ]
352        );
353        assert_eq!(
354            f.world.get::<MeshRenderer>(entity).and_then(|r| r.material),
355            Some(MaterialHandle(0))
356        );
357    }
358
359    #[test]
360    fn a_cull_distance_change_reaches_every_draw_slot() {
361        let mut f = Fixture::new();
362        let entity = f.prop(None);
363        assert!(apply_cull_distance(&mut f.world, entity, 40.0));
364        assert_eq!(
365            f.replay(),
366            vec![
367                Call::SetDrawCullDistance(3, 40.0),
368                Call::SetDrawCullDistance(4, 40.0),
369            ]
370        );
371        assert_eq!(
372            f.world.get::<MeshRenderer>(entity).map(|r| r.cull_distance),
373            Some(40.0)
374        );
375    }
376
377    // The cutoff is the placement's own, so a model-backed one carries it to
378    // every sub-mesh draw even though its materials are out of reach.
379    #[test]
380    fn a_model_backed_placement_takes_the_cull_distance() {
381        let mut f = Fixture::new();
382        let entity = f.model_prop();
383        assert!(apply_cull_distance(&mut f.world, entity, 25.0));
384        assert_eq!(
385            f.replay(),
386            vec![
387                Call::SetDrawCullDistance(5, 25.0),
388                Call::SetDrawCullDistance(6, 25.0),
389            ]
390        );
391        assert_eq!(
392            f.world
393                .get::<crate::components::ModelRenderer>(entity)
394                .map(|r| r.cull_distance),
395            Some(25.0)
396        );
397    }
398
399    // An entity the renderer gave no draw slots has nothing to bind to.
400    #[test]
401    fn an_entity_with_no_draws_takes_nothing() {
402        let mut f = Fixture::new();
403        let entity = f.world.push(MeshRenderer::default());
404        let steel = material(&f.world, AssetId(10)).expect("steel is loaded");
405        assert!(!apply_material(&mut f.world, entity, steel));
406        assert!(f.replay().is_empty());
407    }
408
409    // What the placement draws with now: its own material, or the default over
410    // its legacy texture when it names none. A model-backed placement has no
411    // per-placement material at all.
412    #[test]
413    fn the_drawn_material_follows_the_renderer() {
414        let mut f = Fixture::new();
415        let bare = f.prop(None);
416        assert!(
417            drawn_material(&f.world, bare)
418                .expect("a mesh placement always draws with something")
419                .handle
420                .is_none()
421        );
422        let glassy = f.prop(Some(MaterialHandle(1)));
423        assert_eq!(
424            drawn_material(&f.world, glassy).expect("glass").handle,
425            Some(MaterialHandle(1))
426        );
427        let model_backed = f.world.push(crate::components::ModelRenderer::default());
428        assert!(drawn_material(&f.world, model_backed).is_none());
429    }
430
431    // A swap that moves the shader bucket or the transparency flags changes
432    // structures built at init, which no per-draw call rebuilds.
433    #[test]
434    fn a_swap_across_pass_or_pipeline_is_not_expressible() {
435        let f = Fixture::new();
436        let steel = material(&f.world, AssetId(10)).expect("steel");
437        let glass = material(&f.world, AssetId(20)).expect("glass");
438        assert!(steel.swappable_with(&steel));
439        assert!(
440            !steel.swappable_with(&glass),
441            "opaque to transparent moves the pass"
442        );
443
444        let mut shaded = steel;
445        shaded.entry.shader_bucket = 2;
446        assert!(!steel.swappable_with(&shaded), "the pipeline moves");
447    }
448
449    // A material whose texture reference points past the pool is a corrupt
450    // build; nothing is bound rather than a garbage slot.
451    #[test]
452    fn a_material_referencing_a_missing_texture_declines() {
453        let mut f = Fixture::new();
454        let broken = Material {
455            albedo: Some(TextureHandle(9)),
456            ..Default::default()
457        };
458        f.world.insert_resource(MaterialTable(vec![ResourceEntry {
459            payload: None,
460            data_bytes: postcard::to_allocvec(&broken).expect("serialises"),
461        }]));
462        f.world.insert_resource(MaterialNames(vec![10]));
463        assert!(material(&f.world, AssetId(10)).is_none());
464    }
465
466    // The shader bucket travels with the material, so the swappability check
467    // reads what the world compiled rather than a default.
468    #[test]
469    fn the_shader_reference_travels_with_the_material() {
470        let mut f = Fixture::new();
471        let shaded = Material {
472            shader: Some(ShaderHandle(3)),
473            ..Default::default()
474        };
475        f.world.insert_resource(MaterialTable(vec![ResourceEntry {
476            payload: None,
477            data_bytes: postcard::to_allocvec(&shaded).expect("serialises"),
478        }]));
479        f.world.insert_resource(MaterialNames(vec![10]));
480        assert_eq!(
481            material(&f.world, AssetId(10))
482                .expect("decodes")
483                .entry
484                .shader_bucket,
485            3
486        );
487    }
488}