1use 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_core::render::ops::RenderOps;
21
22#[derive(Clone, Copy)]
25pub struct DrawMaterial {
26 handle: Option<MaterialHandle>,
29 entry: MaterialEntry,
30}
31
32impl DrawMaterial {
33 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
45pub 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
56pub 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
69pub 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
83pub fn apply_material(world: &mut World, entity: Entity, material: DrawMaterial) -> bool {
87 let draws = draws_of(world, entity);
88 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
112pub 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
133fn 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
146fn texture_count(world: &World) -> usize {
148 world
149 .resource::<crate::resource::TextureTable>()
150 .map_or(0, |t| t.len())
151}
152
153fn 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
162fn 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}